Compare commits
10 Commits
6264c9959b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
792b4f1b17 | ||
|
|
903e5465d5 | ||
|
|
7a60e57f46 | ||
|
|
1ba85e3235 | ||
|
|
4d2db834ac | ||
|
|
5dff87a90d | ||
|
|
1b446c54df | ||
|
|
062826e126 | ||
|
|
f6b184406d | ||
|
|
9c8ba9a752 |
186
.claude/skills/illustrate-blueprint/SKILL.md
Normal file
186
.claude/skills/illustrate-blueprint/SKILL.md
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
---
|
||||||
|
name: illustrate-blueprint
|
||||||
|
description: >-
|
||||||
|
Illustrate a UI-pattern blueprint from the blueprint-ontology repo as an
|
||||||
|
interactive schematic in this app. Use when asked to illustrate, add, render,
|
||||||
|
or build the illustration for a blueprint — e.g. "illustrate the table
|
||||||
|
blueprint", "add the feed blueprint", "/illustrate-blueprint stack". Reads the
|
||||||
|
source contract from ../blueprint-ontology, authors the typed data module and a
|
||||||
|
bespoke specimen, registers the pair, and verifies the build.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Illustrate a blueprint
|
||||||
|
|
||||||
|
Turn one blueprint from the `blueprint-ontology` repo into an interactive
|
||||||
|
illustration in this app (`blueprints`, deployed at https://blueprints.apoena.dev).
|
||||||
|
|
||||||
|
Read `CONTEXT.md` (ubiquitous language) and `DESIGN.md` (architecture) at the repo
|
||||||
|
root first if you haven't this session — they define the vocabulary (Illustration,
|
||||||
|
Viewer, Specimen, Readout, Companion, Function) used throughout.
|
||||||
|
|
||||||
|
## What an illustration is
|
||||||
|
|
||||||
|
The generic **Viewer** (`src/components/BlueprintViewer.vue`) renders every generic
|
||||||
|
part — title block, legend, function tabs, the full-view Readout, the composition
|
||||||
|
map, and the state machine — from a blueprint's **typed data**. You author two
|
||||||
|
things per blueprint and register them:
|
||||||
|
|
||||||
|
| File | What it is |
|
||||||
|
| ------------------------------------ | --------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `src/data/<camel>Blueprint.ts` | the `Blueprint` object — the contract as typed data (see the `Blueprint` type in `src/data/blueprint.ts`) |
|
||||||
|
| `src/specimens/<Pascal>Specimen.vue` | the **bespoke** static visual mock (left pane), with per-function highlighting |
|
||||||
|
| `src/data/blueprints.ts` | one line registering the `{ blueprint, specimen }` pair under its slug |
|
||||||
|
|
||||||
|
Registering in `blueprints.ts` is all that's needed — the Gallery (`/`) and the
|
||||||
|
route `/b/<slug>` both derive from the registry automatically. Do **not** hand-edit
|
||||||
|
the router or the Gallery.
|
||||||
|
|
||||||
|
> **This is interpretive authoring, not parsing.** `DESIGN.md` D1 deliberately
|
||||||
|
> rejects auto-generating from the ontology: the specimen must be hand-built, and
|
||||||
|
> the contract material (behaviors, invariants, failures, performance) has to be
|
||||||
|
> **redistributed per function**. Do the transcription by hand, faithfully.
|
||||||
|
|
||||||
|
## Reference examples
|
||||||
|
|
||||||
|
Study the closest existing pair before you start — copy its shape, don't reinvent:
|
||||||
|
|
||||||
|
| Kind | Data module | Specimen | Notes |
|
||||||
|
| ---------------------- | --------------------------------- | -------------------------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| **Feature** (set) | `src/data/listBlueprint.ts` | `src/specimens/ListSpecimen.vue` | reuses the shared `LOADSTATE_MACHINE` (`src/data/loadStateMachine.ts`) |
|
||||||
|
| **Feature** (set) | `src/data/gridBlueprint.ts` | `src/specimens/GridSpecimen.vue` | 2D tile layout |
|
||||||
|
| **Feature** (temporal) | `src/data/calendarBlueprint.ts` | `src/specimens/CalendarSpecimen.vue` | annotates one surface per function |
|
||||||
|
| **Capability** | `src/data/rememberMeBlueprint.ts` | `src/specimens/RememberMeSpecimen.vue` | defines a **bespoke** state machine + new `SmKind`s |
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
### 0 · Read the source blueprint
|
||||||
|
|
||||||
|
The source of truth is the sibling repo, default `../blueprint-ontology`
|
||||||
|
(confirm the path if it's not there). For a blueprint with slug `<slug>`, read all
|
||||||
|
three:
|
||||||
|
|
||||||
|
- `blueprints/<slug>/README.md` — the prose contract
|
||||||
|
- `blueprints/<slug>/<slug>.als` — the formal model (the real invariants/behaviors)
|
||||||
|
- `blueprints/<slug>/<slug>.test.als` — checks that pin the contract
|
||||||
|
|
||||||
|
The README anatomy is fixed: **Signature → UI snapshot → State machine → Behaviors
|
||||||
|
→ Invariants → Functional analysis → Critical performance → Failure modes → Formal
|
||||||
|
model**. Map it to the `Blueprint` type like this:
|
||||||
|
|
||||||
|
| README section | `Blueprint` field |
|
||||||
|
| -------------------------------- | ------------------------------------------------------------------------------- |
|
||||||
|
| `# Name<Item>` + intro one-liner | `name`, `tagline` |
|
||||||
|
| Signature / Standard composition | `sig` (`header`, `fields`, `types`), `role`, `extendsName`, `composesCount` |
|
||||||
|
| Relations | `related[]` (`{ name, relation }`) |
|
||||||
|
| UI snapshot (ASCII sketch) | informs the **Specimen** — not a data field |
|
||||||
|
| State machine | `stateMachine` — reuse `LOADSTATE_MACHINE` or author a bespoke one (see step 2) |
|
||||||
|
| Behaviors | distributed into `functions[].behaviors[]` (`{ sig, pre, eff }`) |
|
||||||
|
| Invariants → Core | top-level `coreInvariants[]` |
|
||||||
|
| Invariants → per-behavior | the owning `functions[].invariants[]` |
|
||||||
|
| Functional analysis (the table) | **`functions[]`** — one row = one `BlueprintFunction` |
|
||||||
|
| Critical performance | distributed into `functions[].perf[]` |
|
||||||
|
| Failure modes | distributed into `functions[].failures[]` |
|
||||||
|
| Formal model | rendered automatically from `slug` in the footer |
|
||||||
|
|
||||||
|
### 1 · Decide role and state-machine strategy
|
||||||
|
|
||||||
|
- **Role** — `"feature"` (a pattern the user interacts with: List, Grid, Calendar)
|
||||||
|
or `"capability"` (composes onto a host: Async, RememberMe, MFA). Set
|
||||||
|
`extendsName` (the base/host) and, for features that compose capabilities,
|
||||||
|
`composesCount`.
|
||||||
|
- **State machine** —
|
||||||
|
- Set features that compose Async + Paginated + Pullable (List, Grid, Feed,
|
||||||
|
Table…) **reuse** the shared machine: `import { LOADSTATE_MACHINE }` and set
|
||||||
|
`stateMachine: LOADSTATE_MACHINE`.
|
||||||
|
- A blueprint with its own lifecycle authors a **bespoke** `StateMachine`
|
||||||
|
(`nodes` with hand-placed `x/y/w/h`, `edges`, `initId`, `caption`, `field`).
|
||||||
|
Copy the layout approach from `rememberMeBlueprint.ts`.
|
||||||
|
- **If the bespoke machine has transition kinds not already in `SmKind`**, add
|
||||||
|
them to both `SmKind` **and** `SM_COLORS` in `src/data/blueprint.ts` (a color
|
||||||
|
per kind). Reuse an existing kind whenever the semantics match.
|
||||||
|
- A blueprint with no lifecycle simply omits `stateMachine`.
|
||||||
|
|
||||||
|
### 2 · Author `src/data/<camel>Blueprint.ts`
|
||||||
|
|
||||||
|
Export a `const <camel>: Blueprint`. Conform exactly to the `Blueprint` interface —
|
||||||
|
open `src/data/blueprint.ts` and follow it field by field. Start the file with a
|
||||||
|
header comment citing the ontology source (README + `.als`), like the existing
|
||||||
|
modules do.
|
||||||
|
|
||||||
|
Per function (one per Functional-analysis row):
|
||||||
|
|
||||||
|
- `id` — short, kebab/lowercase, **stable**; the specimen switches on it.
|
||||||
|
- `name` — the table's Function name.
|
||||||
|
- `fig` — `"01"`, `"02"`, … in table order (shown as `FIG.NN` in the specimen cap).
|
||||||
|
- `caps` — the blueprint(s)/capability that provide this function (drives the
|
||||||
|
composition-map links and the Readout's "source capability").
|
||||||
|
- `verb` — the table's Responsibility, tightened to one imperative sentence.
|
||||||
|
- `state` — `[name, type]` pairs for the state this function touches.
|
||||||
|
- `behaviors` / `invariants` / `failures` / `perf` / `notes` — pull the matching
|
||||||
|
lines from the README's Behaviors / Invariants / Failure modes / Critical
|
||||||
|
performance sections. Leave arrays empty (`[]`) when a function has none.
|
||||||
|
|
||||||
|
Fill `composition` when the pattern is a host-plus-capabilities or a capability
|
||||||
|
over a host: `caps[]` (nodes, mark the core one `core: true`), `funcs[]` (function
|
||||||
|
names in order), `links[]` (`[capId, functionName]` pairs). The composition map
|
||||||
|
only renders when `composition` is present.
|
||||||
|
|
||||||
|
### 3 · Author `src/specimens/<Pascal>Specimen.vue`
|
||||||
|
|
||||||
|
The Specimen is a **static** (never live) visual mock of the pattern's real UI,
|
||||||
|
bespoke to this blueprint. Contract:
|
||||||
|
|
||||||
|
- `<script setup lang="ts">`, props `defineProps<{ activeId: string }>()`.
|
||||||
|
- A `computed` `frame` with **one `case` per function `id`** plus a `default`
|
||||||
|
(the `"full"`/overview view). Function ids **must match** the data module — a
|
||||||
|
mismatch means selecting that tab highlights nothing.
|
||||||
|
- Each frame highlights the region that function owns using the shared callout
|
||||||
|
convention: the highlighted element gets the `hl` class and a `data-tag`
|
||||||
|
attribute (e.g. `◈ DISPLAY`), and the `.hl` / `.hl[data-tag]::after` CSS from an
|
||||||
|
existing specimen renders the dashed-amber outline + label. Copy that CSS block
|
||||||
|
verbatim.
|
||||||
|
- Use theme variables, not hard-coded colors: `var(--color-base-content)`,
|
||||||
|
`--color-accent` (amber highlight), `--color-secondary` (cyan ink),
|
||||||
|
`--color-error`, `--color-success`. Reuse the local aliases the existing
|
||||||
|
specimens define (`--ink`, `--amber`, `--cyan`, `--green`, `--red`, `--paper`,
|
||||||
|
`--line`, `--line-strong`).
|
||||||
|
- Pick the specimen shape that fits: features with a real screen mock its surface
|
||||||
|
and move the highlight per function (List/Grid/Calendar); a capability with
|
||||||
|
little UI of its own shows its few screens plus the machinery it owns
|
||||||
|
(RememberMe's device + secure-storage vault + state badges).
|
||||||
|
|
||||||
|
Keep it self-contained and scoped (`<style scoped>`); no new dependencies.
|
||||||
|
|
||||||
|
### 4 · Register the pair
|
||||||
|
|
||||||
|
In `src/data/blueprints.ts`: import the data export and the specimen component, then
|
||||||
|
add one entry to `REGISTRY` keyed by the **slug** (kebab-case, matching the ontology
|
||||||
|
folder and `blueprint.slug`). That's the only wiring required.
|
||||||
|
|
||||||
|
### 5 · Verify (all must pass)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build # vue-tsc type-check + vite build — catches Blueprint-type drift
|
||||||
|
pnpm lint # oxlint
|
||||||
|
pnpm fmt # oxfmt (pnpm fmt:check to verify only)
|
||||||
|
```
|
||||||
|
|
||||||
|
Then sanity-check it renders: `pnpm dev` and open `/b/<slug>` — every function tab
|
||||||
|
should highlight a region, the Readout should populate, and the composition map /
|
||||||
|
state machine (if present) should draw. The `/run` or `/verify` skills can drive
|
||||||
|
this if you want runtime confirmation.
|
||||||
|
|
||||||
|
## Conventions checklist
|
||||||
|
|
||||||
|
- Code style: **no semicolons, double quotes** (oxfmt enforces; see `.oxfmtrc.json`).
|
||||||
|
- Names: slug = kebab-case (`remember-me`); data export = camelCase (`rememberMe`);
|
||||||
|
data file = `<camel>Blueprint.ts`; specimen = `<Pascal>Specimen.vue`.
|
||||||
|
- Function `id`s are shared contract between the data module and the specimen —
|
||||||
|
keep them identical.
|
||||||
|
- Transcribe faithfully from the ontology; cite the source in the file header. The
|
||||||
|
ontology stays the source of truth — the data module is a transcription.
|
||||||
|
- Don't edit the router or Gallery; don't add "planned" cards (D3 — the Gallery
|
||||||
|
lists only illustrated blueprints).
|
||||||
|
- Scope discipline: add the new files + the registry line (+ `SmKind`/`SM_COLORS`
|
||||||
|
only if a new transition kind is genuinely needed). Don't refactor the Viewer or
|
||||||
|
existing blueprints as a side effect.
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
color: var(--color-secondary);
|
color: var(--color-secondary);
|
||||||
}
|
}
|
||||||
.sub {
|
.sub {
|
||||||
color: #4f7099;
|
color: #7ea6cd;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch, type Component } from "vue"
|
import { computed, ref, watch, type Component } from "vue"
|
||||||
import type { Blueprint } from "@/data/blueprint"
|
import type { Blueprint } from "@/data/blueprint"
|
||||||
|
import { slugForName } from "@/data/blueprints"
|
||||||
import FunctionReadout from "@/components/FunctionReadout.vue"
|
import FunctionReadout from "@/components/FunctionReadout.vue"
|
||||||
import CompositionMap from "@/components/CompositionMap.vue"
|
import CompositionMap from "@/components/CompositionMap.vue"
|
||||||
import StateMachine from "@/components/StateMachine.vue"
|
import StateMachine from "@/components/StateMachine.vue"
|
||||||
|
|
||||||
const props = defineProps<{ blueprint: Blueprint; specimen: Component }>()
|
const props = defineProps<{ blueprint: Blueprint; specimen: Component }>()
|
||||||
|
|
||||||
|
const extendsSlug = computed(() => slugForName(props.blueprint.extendsName))
|
||||||
|
|
||||||
const activeId = ref("full")
|
const activeId = ref("full")
|
||||||
// reset to the overview whenever we switch blueprints
|
// reset to the overview whenever we switch blueprints
|
||||||
watch(
|
watch(
|
||||||
@@ -47,7 +50,12 @@ const funcCount = computed(() => props.blueprint.functions.length)
|
|||||||
</div>
|
</div>
|
||||||
<div class="tb-cell">
|
<div class="tb-cell">
|
||||||
<div class="tb-k">Extends</div>
|
<div class="tb-k">Extends</div>
|
||||||
<div class="tb-v">{{ blueprint.extendsName ?? "—" }}</div>
|
<div class="tb-v">
|
||||||
|
<RouterLink v-if="extendsSlug" :to="`/b/${extendsSlug}`" class="xlink">{{
|
||||||
|
blueprint.extendsName
|
||||||
|
}}</RouterLink>
|
||||||
|
<template v-else>{{ blueprint.extendsName ?? "—" }}</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tb-cell">
|
<div class="tb-cell">
|
||||||
<div class="tb-k">Role</div>
|
<div class="tb-k">Role</div>
|
||||||
@@ -104,7 +112,7 @@ const funcCount = computed(() => props.blueprint.functions.length)
|
|||||||
<div class="flex flex-wrap items-baseline gap-2">
|
<div class="flex flex-wrap items-baseline gap-2">
|
||||||
<span class="text-lg font-bold tracking-wide text-secondary">FULL VIEW</span>
|
<span class="text-lg font-bold tracking-wide text-secondary">FULL VIEW</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-4 mt-1 text-xs text-base-content/50">
|
<p class="mb-4 mt-1 text-xs text-base-content/75">
|
||||||
The standard composition and its {{ funcCount }} functions. Select a function above to
|
The standard composition and its {{ funcCount }} functions. Select a function above to
|
||||||
focus it.
|
focus it.
|
||||||
</p>
|
</p>
|
||||||
@@ -156,14 +164,39 @@ const funcCount = computed(() => props.blueprint.functions.length)
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- COMPANION 2: state machine -->
|
<!-- COMPANION 2: state machine(s) -->
|
||||||
<template v-if="blueprint.stateMachine">
|
<template v-if="blueprint.stateMachines?.length">
|
||||||
<h2 class="section">Lifecycle — state machine</h2>
|
<h2 class="section">
|
||||||
<div class="panel">
|
Lifecycle — state machine{{ blueprint.stateMachines.length > 1 ? "s" : "" }}
|
||||||
|
</h2>
|
||||||
|
<div
|
||||||
|
v-for="(m, i) in blueprint.stateMachines"
|
||||||
|
:key="i"
|
||||||
|
class="panel"
|
||||||
|
:class="{ stacked: i > 0 }"
|
||||||
|
>
|
||||||
<div class="cap">
|
<div class="cap">
|
||||||
<span>Composed lifecycle</span><span class="id">loadState : LoadState</span>
|
<span>{{ m.caption ?? "Composed lifecycle" }}</span
|
||||||
|
><span class="id">{{ m.field ?? "loadState : LoadState" }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="body svg-wrap"><StateMachine :machine="blueprint.stateMachine" /></div>
|
<div class="body svg-wrap"><StateMachine :machine="m" /></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- COMPANION 3: related blueprints -->
|
||||||
|
<template v-if="blueprint.related?.length">
|
||||||
|
<h2 class="section">Related blueprints</h2>
|
||||||
|
<div class="related">
|
||||||
|
<template v-for="r in blueprint.related" :key="r.name">
|
||||||
|
<RouterLink v-if="slugForName(r.name)" :to="`/b/${slugForName(r.name)}`" class="rel link">
|
||||||
|
<span class="rel-name">{{ r.name }} <span class="rel-arrow">▸</span></span>
|
||||||
|
<span class="rel-rel">{{ r.relation }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
<div v-else class="rel">
|
||||||
|
<span class="rel-name">{{ r.name }}</span>
|
||||||
|
<span class="rel-rel">{{ r.relation }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -208,7 +241,7 @@ code {
|
|||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
}
|
}
|
||||||
.tb-k {
|
.tb-k {
|
||||||
color: #4f7099;
|
color: #7ea6cd;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.14em;
|
letter-spacing: 0.14em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
@@ -364,7 +397,7 @@ code {
|
|||||||
background: rgba(255, 255, 255, 0.02);
|
background: rgba(255, 255, 255, 0.02);
|
||||||
}
|
}
|
||||||
.panel .cap .id {
|
.panel .cap .id {
|
||||||
color: #4f7099;
|
color: #7ea6cd;
|
||||||
}
|
}
|
||||||
.panel .body {
|
.panel .body {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
@@ -372,6 +405,10 @@ code {
|
|||||||
.svg-wrap {
|
.svg-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
/* stacked state-machine panels (a blueprint with more than one lifecycle) */
|
||||||
|
.panel.stacked {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
/* full-view readout */
|
/* full-view readout */
|
||||||
.rblock {
|
.rblock {
|
||||||
@@ -384,7 +421,7 @@ code {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: color-mix(in srgb, var(--color-base-content) 50%, transparent);
|
color: color-mix(in srgb, var(--color-base-content) 75%, transparent);
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
.rk .rule {
|
.rk .rule {
|
||||||
@@ -425,7 +462,7 @@ code {
|
|||||||
color: var(--color-secondary);
|
color: var(--color-secondary);
|
||||||
}
|
}
|
||||||
.sig-note {
|
.sig-note {
|
||||||
color: #4f7099;
|
color: #7ea6cd;
|
||||||
}
|
}
|
||||||
.sig-types {
|
.sig-types {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
@@ -455,11 +492,59 @@ code {
|
|||||||
color: #7ea6cd;
|
color: #7ea6cd;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* cross-links */
|
||||||
|
.xlink {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 1px dashed color-mix(in srgb, var(--color-secondary) 50%, transparent);
|
||||||
|
}
|
||||||
|
.xlink:hover {
|
||||||
|
border-bottom-style: solid;
|
||||||
|
}
|
||||||
|
.related {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.rel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
border: 1px solid rgba(200, 226, 255, 0.14);
|
||||||
|
padding: 8px 12px;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.rel.link {
|
||||||
|
border-color: rgba(200, 226, 255, 0.3);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.12s;
|
||||||
|
}
|
||||||
|
.rel.link:hover {
|
||||||
|
border-color: var(--color-secondary);
|
||||||
|
background: var(--color-base-200);
|
||||||
|
}
|
||||||
|
.rel-name {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #7ea6cd;
|
||||||
|
}
|
||||||
|
.rel.link .rel-name {
|
||||||
|
color: var(--color-secondary);
|
||||||
|
}
|
||||||
|
.rel-arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.rel-rel {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #4f7099;
|
||||||
|
}
|
||||||
|
|
||||||
.credit {
|
.credit {
|
||||||
margin-top: 34px;
|
margin-top: 34px;
|
||||||
padding-top: 14px;
|
padding-top: 14px;
|
||||||
border-top: 1px solid rgba(200, 226, 255, 0.14);
|
border-top: 1px solid rgba(200, 226, 255, 0.14);
|
||||||
color: #4f7099;
|
color: #7ea6cd;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.8;
|
line-height: 1.8;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ const PAL = {
|
|||||||
boxFill: "#103763",
|
boxFill: "#103763",
|
||||||
boxStroke: "#3d5f85",
|
boxStroke: "#3d5f85",
|
||||||
ink: "#dbe9f7",
|
ink: "#dbe9f7",
|
||||||
inkFaint: "#4f7099",
|
inkFaint: "#4f7099", // decorative edges only
|
||||||
|
inkDim: "#7ea6cd", // dim text — meets WCAG AA on navy paper
|
||||||
cyan: "#8fd0ff",
|
cyan: "#8fd0ff",
|
||||||
amber: "#ffb84d",
|
amber: "#ffb84d",
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,7 @@ const edges = computed(() =>
|
|||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
/>
|
/>
|
||||||
<text :x="b.x + 12" :y="b.y + 15" :fill="PAL.ink" font-size="12">{{ b.label }}</text>
|
<text :x="b.x + 12" :y="b.y + 15" :fill="PAL.ink" font-size="12">{{ b.label }}</text>
|
||||||
<text :x="b.x + 12" :y="b.y + 27" :fill="PAL.inkFaint" font-size="9.5">{{ b.sub }}</text>
|
<text :x="b.x + 12" :y="b.y + 27" :fill="PAL.inkDim" font-size="9.5">{{ b.sub }}</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<g v-for="b in rightBoxes" :key="b.label">
|
<g v-for="b in rightBoxes" :key="b.label">
|
||||||
@@ -111,12 +112,10 @@ const edges = computed(() =>
|
|||||||
<text :x="b.x + 12" :y="b.y + 21" :fill="PAL.amber" font-size="12">{{ b.label }}</text>
|
<text :x="b.x + 12" :y="b.y + 21" :fill="PAL.amber" font-size="12">{{ b.label }}</text>
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<text :x="lx" :y="H - 4" :fill="PAL.inkFaint" font-size="10" letter-spacing="1.5">
|
<text :x="lx" :y="H - 4" :fill="PAL.inkDim" font-size="10" letter-spacing="1.5">
|
||||||
CAPABILITIES (compose onto host)
|
CAPABILITIES (compose onto host)
|
||||||
</text>
|
</text>
|
||||||
<text :x="rx" :y="H - 4" :fill="PAL.inkFaint" font-size="10" letter-spacing="1.5">
|
<text :x="rx" :y="H - 4" :fill="PAL.inkDim" font-size="10" letter-spacing="1.5">FUNCTIONS</text>
|
||||||
FUNCTIONS
|
|
||||||
</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -16,13 +16,13 @@ defineProps<{ fn: BlueprintFunction }>()
|
|||||||
>{{ c }}</span
|
>{{ c }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<p class="mb-4 mt-1 text-xs text-base-content/50">{{ fn.verb }}</p>
|
<p class="mb-4 mt-1 text-xs text-base-content/75">{{ fn.verb }}</p>
|
||||||
|
|
||||||
<section v-if="fn.state.length" class="block-sec">
|
<section v-if="fn.state.length" class="block-sec">
|
||||||
<div class="k"><span>State touched</span><span class="rule" /></div>
|
<div class="k"><span>State touched</span><span class="rule" /></div>
|
||||||
<div v-for="[name, type] in fn.state" :key="name" class="flex gap-2">
|
<div v-for="[name, type] in fn.state" :key="name" class="flex gap-2">
|
||||||
<span>{{ name }}</span
|
<span>{{ name }}</span
|
||||||
><span class="text-base-content/40">:</span><span class="text-secondary">{{ type }}</span>
|
><span class="text-base-content/70">:</span><span class="text-secondary">{{ type }}</span>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -34,11 +34,11 @@ defineProps<{ fn: BlueprintFunction }>()
|
|||||||
<div v-for="b in fn.behaviors" :key="b.sig" class="beh">
|
<div v-for="b in fn.behaviors" :key="b.sig" class="beh">
|
||||||
<div class="text-accent">{{ b.sig }}</div>
|
<div class="text-accent">{{ b.sig }}</div>
|
||||||
<div class="flex gap-2 text-xs text-base-content/70">
|
<div class="flex gap-2 text-xs text-base-content/70">
|
||||||
<span class="w-7 shrink-0 text-base-content/40">pre</span
|
<span class="w-7 shrink-0 text-base-content/70">pre</span
|
||||||
><span class="text-base-content">{{ b.pre }}</span>
|
><span class="text-base-content">{{ b.pre }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2 text-xs text-base-content/70">
|
<div class="flex gap-2 text-xs text-base-content/70">
|
||||||
<span class="w-7 shrink-0 text-base-content/40">eff</span
|
<span class="w-7 shrink-0 text-base-content/70">eff</span
|
||||||
><span class="text-base-content">{{ b.eff }}</span>
|
><span class="text-base-content">{{ b.eff }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +71,7 @@ defineProps<{ fn: BlueprintFunction }>()
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p v-for="n in fn.notes" :key="n" class="text-[11px] italic text-base-content/40">— {{ n }}</p>
|
<p v-for="n in fn.notes" :key="n" class="text-[11px] italic text-base-content/70">— {{ n }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -100,7 +100,7 @@ defineProps<{ fn: BlueprintFunction }>()
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.16em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: color-mix(in srgb, var(--color-base-content) 50%, transparent);
|
color: color-mix(in srgb, var(--color-base-content) 75%, transparent);
|
||||||
margin-bottom: 0.35rem;
|
margin-bottom: 0.35rem;
|
||||||
}
|
}
|
||||||
.k .rule {
|
.k .rule {
|
||||||
|
|||||||
@@ -36,9 +36,9 @@ const markers = (Object.keys(SM_COLORS) as (keyof typeof SM_COLORS)[]).map((k) =
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
const init = computed(() => {
|
const init = computed(() => {
|
||||||
const loading = byId.value.loading
|
const start = byId.value[props.machine.initId ?? "loading"]
|
||||||
const cy = loading.y + loading.h / 2
|
const cy = start.y + start.h / 2
|
||||||
const e = edgePoint(loading, 26, cy)
|
const e = edgePoint(start, 26, cy)
|
||||||
return { cy, path: `M31 ${cy} L ${e[0]} ${e[1]}` }
|
return { cy, path: `M31 ${cy} L ${e[0]} ${e[1]}` }
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -46,6 +46,26 @@ const edges = computed(() =>
|
|||||||
props.machine.edges.map((e) => {
|
props.machine.edges.map((e) => {
|
||||||
const a = byId.value[e.from]
|
const a = byId.value[e.from]
|
||||||
const b = byId.value[e.to]
|
const b = byId.value[e.to]
|
||||||
|
// Self-loop (a === b): a small arc off the top edge of the node, arrowhead
|
||||||
|
// curling back in. Used by the recurring transitions that don't change state
|
||||||
|
// — BLE's discover(p) on Scanning and read/write/subscribe on Ready.
|
||||||
|
if (a === b) {
|
||||||
|
const cx = a.x + a.w / 2
|
||||||
|
const top = a.y
|
||||||
|
const r = Math.min(a.w / 3.2, 20)
|
||||||
|
const h = 30
|
||||||
|
const sx = cx - r
|
||||||
|
const tx = cx + r
|
||||||
|
return {
|
||||||
|
key: `${e.from}-${e.to}-${e.label}`,
|
||||||
|
d: `M${sx} ${top} C ${sx} ${top - h} ${tx} ${top - h} ${tx} ${top}`,
|
||||||
|
color: SM_COLORS[e.kind],
|
||||||
|
marker: `url(#ar-${e.kind})`,
|
||||||
|
label: e.label,
|
||||||
|
lx: cx,
|
||||||
|
ly: top - h - 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
const [acx, acy] = center(a)
|
const [acx, acy] = center(a)
|
||||||
const [bcx, bcy] = center(b)
|
const [bcx, bcy] = center(b)
|
||||||
const [sx, sy] = edgePoint(a, bcx, bcy)
|
const [sx, sy] = edgePoint(a, bcx, bcy)
|
||||||
@@ -81,16 +101,74 @@ const KIND_LABELS: Record<SmKind, string> = {
|
|||||||
loadMore: "loadMore()",
|
loadMore: "loadMore()",
|
||||||
navigate: "navigate(δ)",
|
navigate: "navigate(δ)",
|
||||||
changeScope: "changeScope(s)",
|
changeScope: "changeScope(s)",
|
||||||
|
persist: "startPersist()",
|
||||||
|
store: "tokenStored(t)",
|
||||||
|
storeFail: "storeFailed()",
|
||||||
|
revoke: "revoke()",
|
||||||
|
expire: "expired()",
|
||||||
|
clear: "clear()",
|
||||||
|
submit: "signIn(c)",
|
||||||
|
lock: "lockOut()",
|
||||||
|
signout: "signOut()",
|
||||||
|
reset: "reset()",
|
||||||
|
unlock: "unlock()",
|
||||||
|
powerOn: "powerOn()",
|
||||||
|
powerOff: "powerOff()",
|
||||||
|
startScan: "startScan()",
|
||||||
|
stopScan: "stopScan()",
|
||||||
|
discover: "discover(p)",
|
||||||
|
connect: "connect()",
|
||||||
|
discoverServices: "discoverServices()",
|
||||||
|
exchange: "read / write / subscribe",
|
||||||
|
disconnect: "disconnect() / drop",
|
||||||
}
|
}
|
||||||
const KIND_ORDER: SmKind[] = ["succeed", "fail", "refresh", "loadMore", "navigate", "changeScope"]
|
const KIND_ORDER: SmKind[] = [
|
||||||
|
"submit",
|
||||||
|
"succeed",
|
||||||
|
"fail",
|
||||||
|
"refresh",
|
||||||
|
"loadMore",
|
||||||
|
"navigate",
|
||||||
|
"changeScope",
|
||||||
|
"lock",
|
||||||
|
"signout",
|
||||||
|
"reset",
|
||||||
|
"unlock",
|
||||||
|
"persist",
|
||||||
|
"store",
|
||||||
|
"storeFail",
|
||||||
|
"revoke",
|
||||||
|
"expire",
|
||||||
|
"clear",
|
||||||
|
"powerOn",
|
||||||
|
"powerOff",
|
||||||
|
"startScan",
|
||||||
|
"stopScan",
|
||||||
|
"discover",
|
||||||
|
"connect",
|
||||||
|
"discoverServices",
|
||||||
|
"exchange",
|
||||||
|
"disconnect",
|
||||||
|
]
|
||||||
|
|
||||||
|
// Lay the legend out left-to-right, wrapping to a new row when it would overflow
|
||||||
|
// the viewBox. Machines with few kinds (List, Grid, Calendar) stay on one row
|
||||||
|
// exactly as before; richer machines (PasswordAuth) wrap instead of clipping.
|
||||||
const legendItems = computed(() => {
|
const legendItems = computed(() => {
|
||||||
const present = new Set(props.machine.edges.map((e) => e.kind))
|
const present = new Set(props.machine.edges.map((e) => e.kind))
|
||||||
|
const maxX = 880
|
||||||
let lxp = 70
|
let lxp = 70
|
||||||
|
let row = 0
|
||||||
return KIND_ORDER.filter((k) => present.has(k)).map((k) => {
|
return KIND_ORDER.filter((k) => present.has(k)).map((k) => {
|
||||||
const t = KIND_LABELS[k]
|
const t = KIND_LABELS[k]
|
||||||
const item = { t, color: SM_COLORS[k], x1: lxp, x2: lxp + 18, tx: lxp + 24, y: 8, ty: 12 }
|
const width = 24 + t.length * 6.6 + 26
|
||||||
lxp += 24 + t.length * 6.6 + 26
|
if (lxp > 70 && lxp + width > maxX) {
|
||||||
|
row += 1
|
||||||
|
lxp = 70
|
||||||
|
}
|
||||||
|
const y = 8 + row * 16
|
||||||
|
const item = { t, color: SM_COLORS[k], x1: lxp, x2: lxp + 18, tx: lxp + 24, y, ty: y + 4 }
|
||||||
|
lxp += width
|
||||||
return item
|
return item
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
388
src/data/bleBlueprint.ts
Normal file
388
src/data/bleBlueprint.ts
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
// The BLE blueprint, as data. Transcribed by hand from the ontology `ble`
|
||||||
|
// README + ble.als. A *feature* — a device acting as a Bluetooth Low Energy
|
||||||
|
// *central*: it scans for advertising peripherals, connects to one, and
|
||||||
|
// reads/writes/subscribes to its GATT characteristics over a link that may drop
|
||||||
|
// at any time. It composes the Permission capability, which owns the consent
|
||||||
|
// lifecycle that gates startScan().
|
||||||
|
//
|
||||||
|
// BLE has TWO independent lifecycles, so it renders two state machines:
|
||||||
|
// 1. adapter + scan (central-level) — the radio power and the scan loop,
|
||||||
|
// doubly gated by Permission + a powered-on radio;
|
||||||
|
// 2. per-peripheral link — Disconnected → Connecting → Connected → Ready,
|
||||||
|
// with drop/disconnect returns and on-Ready GATT exchange.
|
||||||
|
|
||||||
|
import type { Blueprint, StateMachine } from "./blueprint"
|
||||||
|
|
||||||
|
// ── Machine 1 · adapter + scan (central) ───────────────────────────────────
|
||||||
|
// The README nests ScanIdle/Scanning inside a PoweredOn superstate; the flat
|
||||||
|
// diagram unfolds that superstate into its two substates. powerOn() enters at
|
||||||
|
// ScanIdle; powerOff() forces the central dormant from either substate (two
|
||||||
|
// edges back to PoweredOff). discover(p) is the OS callback that accumulates
|
||||||
|
// peripherals while Scanning — a self-loop.
|
||||||
|
const BLE_ADAPTER_MACHINE: StateMachine = {
|
||||||
|
initId: "poweredoff",
|
||||||
|
caption: "Adapter + scan · central",
|
||||||
|
field: "adapter : AdapterState · scan : ScanState",
|
||||||
|
nodes: [
|
||||||
|
{ id: "poweredoff", x: 60, y: 150, w: 130, h: 44, label: "PoweredOff" },
|
||||||
|
{ id: "scanidle", x: 360, y: 150, w: 120, h: 44, label: "ScanIdle" },
|
||||||
|
{ id: "scanning", x: 660, y: 150, w: 120, h: 44, label: "Scanning" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
// forward path (arcs up, labels above the row)
|
||||||
|
{ from: "poweredoff", to: "scanidle", label: "powerOn()", kind: "powerOn", off: -34 },
|
||||||
|
{
|
||||||
|
from: "scanidle",
|
||||||
|
to: "scanning",
|
||||||
|
label: "startScan() [granted]",
|
||||||
|
kind: "startScan",
|
||||||
|
off: -34,
|
||||||
|
},
|
||||||
|
// recurring OS callback while scanning
|
||||||
|
{ from: "scanning", to: "scanning", label: "discover(p)", kind: "discover", off: 0 },
|
||||||
|
// returns (arc below the row; deeper = wider span)
|
||||||
|
{ from: "scanning", to: "scanidle", label: "stopScan()", kind: "stopScan", off: -55 },
|
||||||
|
{ from: "scanidle", to: "poweredoff", label: "powerOff()", kind: "powerOff", off: -95 },
|
||||||
|
{ from: "scanning", to: "poweredoff", label: "powerOff()", kind: "powerOff", off: -145 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Machine 2 · per-peripheral link ────────────────────────────────────────
|
||||||
|
// Disconnected → Connecting → Connected → Ready. established()/connectFail() are
|
||||||
|
// the two OS resolutions of a connect() attempt. On Ready, read/write/subscribe
|
||||||
|
// leave the state unchanged (self-loop). disconnect()/drop returns to
|
||||||
|
// Disconnected from either Connected or Ready, invalidating cached handles.
|
||||||
|
const BLE_LINK_MACHINE: StateMachine = {
|
||||||
|
initId: "disconnected",
|
||||||
|
caption: "Link lifecycle · per peripheral",
|
||||||
|
field: "peripheral.link : LinkState",
|
||||||
|
nodes: [
|
||||||
|
{ id: "disconnected", x: 40, y: 150, w: 140, h: 44, label: "Disconnected" },
|
||||||
|
{ id: "connecting", x: 290, y: 150, w: 125, h: 44, label: "Connecting" },
|
||||||
|
{ id: "connected", x: 525, y: 150, w: 125, h: 44, label: "Connected" },
|
||||||
|
{ id: "ready", x: 760, y: 150, w: 95, h: 44, label: "Ready" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
// forward path (arcs up, labels above the row)
|
||||||
|
{ from: "disconnected", to: "connecting", label: "connect()", kind: "connect", off: -34 },
|
||||||
|
{ from: "connecting", to: "connected", label: "established()", kind: "succeed", off: -34 },
|
||||||
|
{
|
||||||
|
from: "connected",
|
||||||
|
to: "ready",
|
||||||
|
label: "discoverServices()",
|
||||||
|
kind: "discoverServices",
|
||||||
|
off: -34,
|
||||||
|
},
|
||||||
|
// on-Ready GATT operations — state unchanged (self-loop)
|
||||||
|
{ from: "ready", to: "ready", label: "read / write / subscribe", kind: "exchange", off: 0 },
|
||||||
|
// returns to Disconnected (arc below the row; deeper = wider span)
|
||||||
|
{ from: "connecting", to: "disconnected", label: "connectFail()", kind: "fail", off: -55 },
|
||||||
|
{ from: "connected", to: "disconnected", label: "disconnect()", kind: "disconnect", off: -100 },
|
||||||
|
{
|
||||||
|
from: "ready",
|
||||||
|
to: "disconnected",
|
||||||
|
label: "disconnect() / drop",
|
||||||
|
kind: "disconnect",
|
||||||
|
off: -150,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ble: Blueprint = {
|
||||||
|
slug: "ble",
|
||||||
|
name: "BLE",
|
||||||
|
tagline:
|
||||||
|
"A device acting as a Bluetooth Low Energy central — scan for advertising peripherals, connect to one, and read/write/subscribe to its GATT characteristics over a link that may drop at any time.",
|
||||||
|
role: "feature",
|
||||||
|
composesCount: 1,
|
||||||
|
|
||||||
|
sig: {
|
||||||
|
header: "Central",
|
||||||
|
fields: [
|
||||||
|
{ name: "permission", type: "one Permission", note: "consent state (owned by Permission)" },
|
||||||
|
{ name: "adapter", type: "one AdapterState", note: "radio power: PoweredOff | PoweredOn" },
|
||||||
|
{ name: "scan", type: "one ScanState", note: "ScanIdle | Scanning" },
|
||||||
|
{ name: "discovered", type: "set Peripheral", note: "peripherals seen while scanning" },
|
||||||
|
],
|
||||||
|
types: [
|
||||||
|
"AdapterState = PoweredOff | PoweredOn",
|
||||||
|
"ScanState = ScanIdle | Scanning",
|
||||||
|
"LinkState = Disconnected | Connecting | Connected | Ready",
|
||||||
|
"Peripheral { link : LinkState, characteristics, subscriptions : set Characteristic }",
|
||||||
|
"scan = Scanning ⟹ permission = Granted ∧ adapter = PoweredOn (doubly gated)",
|
||||||
|
"some p.characteristics ⟹ p.link = Ready",
|
||||||
|
"subscriptions ⊆ characteristics",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
functions: [
|
||||||
|
{
|
||||||
|
id: "permission",
|
||||||
|
name: "Permission",
|
||||||
|
fig: "01",
|
||||||
|
caps: ["Permission"],
|
||||||
|
verb: "Hold the single consent status and gate startScan() behind it; delegate request/grant/deny to the Permission capability.",
|
||||||
|
state: [["permission.status", "PermissionStatus"]],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "requestPermission()",
|
||||||
|
pre: "permission.status = NotAsked",
|
||||||
|
eff: "delegates to Permission.request; rest of Central preserved",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "grant()",
|
||||||
|
pre: "permission.status = Requested",
|
||||||
|
eff: "delegates to Permission.grant; rest of Central preserved",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "deny()",
|
||||||
|
pre: "permission.status = Requested",
|
||||||
|
eff: "delegates to Permission.deny; scan ← ScanIdle",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"scan = Scanning ⟹ permission.status = Granted — consent is one of the two scan gates",
|
||||||
|
"Granted and Denied are terminal from the capability's view — the app cannot re-prompt the OS dialog",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Location coupling — Android < 12 requires ACCESS_FINE_LOCATION to scan; a confusing permission prompt blocks scanning entirely if location is denied",
|
||||||
|
],
|
||||||
|
perf: [],
|
||||||
|
notes: [
|
||||||
|
"The three permission behaviors compose Permission's transitions with a frame condition on the rest of Central. On Web, navigator.bluetooth.requestDevice() has the browser render the chooser — silent enumeration is impossible.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "power",
|
||||||
|
name: "Power",
|
||||||
|
fig: "02",
|
||||||
|
caps: ["BLE"],
|
||||||
|
verb: "Track the adapter's on/off state; force the central fully dormant the moment the radio powers off.",
|
||||||
|
state: [
|
||||||
|
["adapter", "AdapterState"],
|
||||||
|
["scan", "ScanState"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "powerOn()",
|
||||||
|
pre: "adapter = PoweredOff",
|
||||||
|
eff: "adapter ← PoweredOn; scan ← ScanIdle",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "powerOff()",
|
||||||
|
pre: "adapter = PoweredOn",
|
||||||
|
eff: "adapter ← PoweredOff; scan ← ScanIdle; discovered ← ∅",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"adapter = PoweredOff ⟹ scan = ScanIdle ∧ every known peripheral is Disconnected — a dead radio is fully dormant",
|
||||||
|
"scan = Scanning ⟹ adapter = PoweredOn — a live radio is the second scan gate",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Adapter off mid-session — the user toggles Bluetooth off in Control Center / Settings; every link drops and subscriptions are lost, so the app must observe adapter state and clean up",
|
||||||
|
],
|
||||||
|
perf: [],
|
||||||
|
notes: [
|
||||||
|
"powerOn/powerOff are OS/user events, not app calls — the app reacts to them. Losing the radio is modelled as the central collapsing to its dormant state.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "scan",
|
||||||
|
name: "Scan",
|
||||||
|
fig: "03",
|
||||||
|
caps: ["BLE", "Permission"],
|
||||||
|
verb: "Discover advertising peripherals once doubly gated (Granted + PoweredOn) and accumulate them into the discovered set.",
|
||||||
|
state: [
|
||||||
|
["scan", "ScanState"],
|
||||||
|
["discovered", "set Peripheral"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "startScan()",
|
||||||
|
pre: "permission = Granted; adapter = PoweredOn; scan = ScanIdle",
|
||||||
|
eff: "scan ← Scanning",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "discover(p)",
|
||||||
|
pre: "scan = Scanning",
|
||||||
|
eff: "discovered ← discovered ∪ {p}",
|
||||||
|
},
|
||||||
|
{ sig: "stopScan()", pre: "scan = Scanning", eff: "scan ← ScanIdle" },
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"scan = Scanning ⟹ permission = Granted ∧ adapter = PoweredOn — scanning is doubly gated",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Scan drain — startScan() never paired with stopScan(); a continuous radio scan drains the battery and Android throttles or drops results",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"First advertisement — a discovered peripheral surfaces within ~1 s of startScan() under normal RF conditions",
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"discover(p) is an OS callback fired repeatedly while Scanning — a self-loop that grows the discovered set without changing state.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "connect",
|
||||||
|
name: "Connect",
|
||||||
|
fig: "04",
|
||||||
|
caps: ["BLE", "Peripheral"],
|
||||||
|
verb: "Establish the link to a chosen peripheral and tear it down; resolve both the successful connection and involuntary drops.",
|
||||||
|
state: [["peripheral.link", "LinkState"]],
|
||||||
|
behaviors: [
|
||||||
|
{ sig: "connect(p)", pre: "p.link = Disconnected", eff: "p.link ← Connecting" },
|
||||||
|
{ sig: "established(p)", pre: "p.link = Connecting", eff: "p.link ← Connected" },
|
||||||
|
{ sig: "connectFail(p)", pre: "p.link = Connecting", eff: "p.link ← Disconnected" },
|
||||||
|
{
|
||||||
|
sig: "disconnect(p)",
|
||||||
|
pre: "p.link ≠ Disconnected",
|
||||||
|
eff: "p.link ← Disconnected; characteristics, subscriptions ← ∅",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"p.link = Disconnected ⟹ no p.characteristics ∧ no p.subscriptions — a dropped link invalidates every cached handle",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Silent disconnect — the peripheral moves out of range or its battery dies; link → Disconnected with no app-initiated call and cached handles go stale",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"Connection establishment — Connecting → Connected within ~2–3 s of connect() (bounded by the connection interval)",
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"disconnect(p) covers both an app-initiated teardown and an involuntary drop (out of range, peripheral power-off) — either way, cached handles are invalidated.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "discover-services",
|
||||||
|
name: "Discover",
|
||||||
|
fig: "05",
|
||||||
|
caps: ["Peripheral", "Characteristic"],
|
||||||
|
verb: "Enumerate the peripheral's GATT services and characteristics once the link is Connected, promoting it to Ready.",
|
||||||
|
state: [
|
||||||
|
["peripheral.link", "LinkState"],
|
||||||
|
["peripheral.characteristics", "set Characteristic"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "discoverServices(p, cs)",
|
||||||
|
pre: "p.link = Connected",
|
||||||
|
eff: "p.link ← Ready; p.characteristics ← cs",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"some p.characteristics ⟹ p.link = Ready — GATT handles exist only after service discovery completes",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Stale handle reuse — reconnect and reuse cached handles without re-discovering; reads/writes target invalid handles and return GATT errors (invariant #5 exists to prevent this)",
|
||||||
|
],
|
||||||
|
perf: [],
|
||||||
|
notes: [
|
||||||
|
"Ready is the only state in which characteristic handles are valid; a drop clears them, so handles must be re-discovered after every reconnect.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "exchange",
|
||||||
|
name: "Exchange",
|
||||||
|
fig: "06",
|
||||||
|
caps: ["Peripheral", "Characteristic"],
|
||||||
|
verb: "Read, write, and subscribe to characteristic values over a Ready link, against discovered characteristics only.",
|
||||||
|
state: [
|
||||||
|
["peripheral.characteristics", "set Characteristic"],
|
||||||
|
["peripheral.subscriptions", "set Characteristic"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "subscribe(p, ch)",
|
||||||
|
pre: "p.link = Ready; ch ∈ p.characteristics",
|
||||||
|
eff: "p.subscriptions ← p.subscriptions ∪ {ch}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "unsubscribe(p, ch)",
|
||||||
|
pre: "p.link = Ready",
|
||||||
|
eff: "p.subscriptions ← p.subscriptions ∖ {ch}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "read/write(p, ch)",
|
||||||
|
pre: "p.link = Ready; ch ∈ p.characteristics",
|
||||||
|
eff: "one-shot GATT op; no link/subscription change",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"p.subscriptions ⊆ p.characteristics — cannot subscribe to a characteristic that was never discovered",
|
||||||
|
"reads/writes are permitted only against a Ready peripheral's discovered characteristics",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Encryption mismatch — an encrypted characteristic is accessed without a completed bond; the op fails with a GATT insufficient-authentication error until pairing completes",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"Notification latency — subscribed-characteristic updates delivered within the negotiated connection interval (7.5 ms – 4 s)",
|
||||||
|
"MTU negotiation — completed before the first large read/write, or payloads truncate to the 23-byte default",
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"read/write/subscribe leave the link in Ready — a self-loop. They are the recurring work of a connected session.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "validate",
|
||||||
|
name: "Validate",
|
||||||
|
fig: "07",
|
||||||
|
caps: ["BLE"],
|
||||||
|
verb: "Treat every peripheral payload as untrusted attacker-controllable input; bounds-check before parsing and never forward raw device identity.",
|
||||||
|
state: [["peripheral.characteristics", "set Characteristic"]],
|
||||||
|
behaviors: [],
|
||||||
|
invariants: [
|
||||||
|
"the peripheral is an untrusted external device — its payloads are validated on receipt, never parsed as trusted",
|
||||||
|
"raw MAC, unparsed characteristic bytes, and the peripheral display name never cross to the app's own backend — only derived, validated domain values do",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Untrusted payload — a malformed characteristic value from a rogue or buggy peripheral crashes or overflows the parser unless its length is validated on receipt",
|
||||||
|
],
|
||||||
|
perf: [],
|
||||||
|
notes: [
|
||||||
|
"Telemetry defence in depth: peripheral name (PII), raw MAC (a tracking vector), and characteristic values (health/biometric data) must never appear in analytics events — only coarse counters and standard-service UUIDs.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
coreInvariants: [
|
||||||
|
"scan = Scanning ⟹ permission.status = Granted ∧ adapter = PoweredOn — scanning requires both consent and a live radio (two independent gates)",
|
||||||
|
"adapter = PoweredOff ⟹ scan = ScanIdle ∧ every discovered peripheral is Disconnected — a dead radio is fully dormant",
|
||||||
|
"some p.characteristics ⟹ p.link = Ready — GATT characteristic handles exist only after service discovery completes",
|
||||||
|
"p.subscriptions ⊆ p.characteristics — cannot subscribe to a characteristic that was never discovered",
|
||||||
|
"p.link = Disconnected ⟹ no p.characteristics ∧ no p.subscriptions — a dropped link invalidates every cached handle and subscription",
|
||||||
|
],
|
||||||
|
|
||||||
|
composition: {
|
||||||
|
caps: [
|
||||||
|
{ id: "core", label: "Central", sub: "adapter, scan, discovered", core: true },
|
||||||
|
{ id: "permission", label: "Permission", sub: "status (consent)" },
|
||||||
|
{ id: "peripheral", label: "Peripheral", sub: "link, characteristics" },
|
||||||
|
{ id: "gatt", label: "Characteristic", sub: "GATT value handle" },
|
||||||
|
],
|
||||||
|
funcs: ["Permission", "Power", "Scan", "Connect", "Discover", "Exchange", "Validate"],
|
||||||
|
links: [
|
||||||
|
["permission", "Permission"],
|
||||||
|
["core", "Permission"],
|
||||||
|
["core", "Power"],
|
||||||
|
["core", "Scan"],
|
||||||
|
["permission", "Scan"],
|
||||||
|
["peripheral", "Scan"],
|
||||||
|
["core", "Connect"],
|
||||||
|
["peripheral", "Connect"],
|
||||||
|
["peripheral", "Discover"],
|
||||||
|
["gatt", "Discover"],
|
||||||
|
["peripheral", "Exchange"],
|
||||||
|
["gatt", "Exchange"],
|
||||||
|
["core", "Validate"],
|
||||||
|
["gatt", "Validate"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
stateMachines: [BLE_ADAPTER_MACHINE, BLE_LINK_MACHINE],
|
||||||
|
|
||||||
|
related: [
|
||||||
|
{ name: "Permission", relation: "composes · gates startScan" },
|
||||||
|
{ name: "Geolocation", relation: "sibling · permission-gated" },
|
||||||
|
{ name: "PushNotification", relation: "sibling · permission-gated" },
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -58,7 +58,37 @@ export interface SmNode {
|
|||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SmKind = "succeed" | "fail" | "refresh" | "loadMore" | "navigate" | "changeScope"
|
export type SmKind =
|
||||||
|
| "succeed"
|
||||||
|
| "fail"
|
||||||
|
| "refresh"
|
||||||
|
| "loadMore"
|
||||||
|
| "navigate"
|
||||||
|
| "changeScope"
|
||||||
|
// RememberMe persistence lifecycle
|
||||||
|
| "persist"
|
||||||
|
| "store"
|
||||||
|
| "storeFail"
|
||||||
|
| "revoke"
|
||||||
|
| "expire"
|
||||||
|
| "clear"
|
||||||
|
// PasswordAuth session lifecycle
|
||||||
|
| "submit"
|
||||||
|
| "lock"
|
||||||
|
| "signout"
|
||||||
|
| "reset"
|
||||||
|
| "unlock"
|
||||||
|
// BLE adapter + scan lifecycle (central)
|
||||||
|
| "powerOn"
|
||||||
|
| "powerOff"
|
||||||
|
| "startScan"
|
||||||
|
| "stopScan"
|
||||||
|
| "discover"
|
||||||
|
// BLE per-peripheral link lifecycle
|
||||||
|
| "connect"
|
||||||
|
| "discoverServices"
|
||||||
|
| "exchange"
|
||||||
|
| "disconnect"
|
||||||
|
|
||||||
export interface SmEdge {
|
export interface SmEdge {
|
||||||
from: string
|
from: string
|
||||||
@@ -71,6 +101,13 @@ export interface SmEdge {
|
|||||||
export interface StateMachine {
|
export interface StateMachine {
|
||||||
nodes: SmNode[]
|
nodes: SmNode[]
|
||||||
edges: SmEdge[]
|
edges: SmEdge[]
|
||||||
|
// id of the initial state (the [*] arrow points here). Defaults to "loading"
|
||||||
|
// for the LoadState-based machines; RememberMe starts at "notpersisted".
|
||||||
|
initId?: string
|
||||||
|
// Panel caption + field label shown above the diagram. Default to the
|
||||||
|
// composed-LoadState wording; capabilities with their own lifecycle override.
|
||||||
|
caption?: string
|
||||||
|
field?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SM_COLORS: Record<SmKind | "init", string> = {
|
export const SM_COLORS: Record<SmKind | "init", string> = {
|
||||||
@@ -80,12 +117,44 @@ export const SM_COLORS: Record<SmKind | "init", string> = {
|
|||||||
loadMore: "#8fd0ff",
|
loadMore: "#8fd0ff",
|
||||||
navigate: "#b79cff",
|
navigate: "#b79cff",
|
||||||
changeScope: "#67d0c0",
|
changeScope: "#67d0c0",
|
||||||
|
// RememberMe: green mint / red store-fail / magenta revoke / amber expiry / purple reset
|
||||||
|
persist: "#8fd0ff",
|
||||||
|
store: "#7fdca0",
|
||||||
|
storeFail: "#ff8f8f",
|
||||||
|
revoke: "#ff5c8a",
|
||||||
|
expire: "#ffb84d",
|
||||||
|
clear: "#b79cff",
|
||||||
|
// PasswordAuth: blue submit / magenta lockout / purple sign-out / teal recovery
|
||||||
|
submit: "#8fd0ff",
|
||||||
|
lock: "#ff5c8a",
|
||||||
|
signout: "#b79cff",
|
||||||
|
reset: "#67d0c0",
|
||||||
|
unlock: "#4fb3a6",
|
||||||
|
// BLE adapter+scan: green power-on / magenta power-off / blue scan / purple stop / teal discover
|
||||||
|
powerOn: "#7fdca0",
|
||||||
|
powerOff: "#ff5c8a",
|
||||||
|
startScan: "#8fd0ff",
|
||||||
|
stopScan: "#b79cff",
|
||||||
|
discover: "#67d0c0",
|
||||||
|
// BLE link: blue connect / amber service-discovery / teal exchange / magenta drop
|
||||||
|
connect: "#8fd0ff",
|
||||||
|
discoverServices: "#ffb84d",
|
||||||
|
exchange: "#67d0c0",
|
||||||
|
disconnect: "#ff5c8a",
|
||||||
init: "#4f7099",
|
init: "#4f7099",
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Blueprint ──────────────────────────────────────────────────────────────
|
// ── Blueprint ──────────────────────────────────────────────────────────────
|
||||||
export type BlueprintRole = "feature" | "capability"
|
export type BlueprintRole = "feature" | "capability"
|
||||||
|
|
||||||
|
// A reference from one blueprint to another (host, base, sibling, extension).
|
||||||
|
// `name` is the referenced blueprint's display name; the Viewer resolves it to a
|
||||||
|
// live link when that blueprint is registered, otherwise renders it as plain text.
|
||||||
|
export interface RelatedRef {
|
||||||
|
name: string
|
||||||
|
relation: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface Blueprint {
|
export interface Blueprint {
|
||||||
slug: string
|
slug: string
|
||||||
name: string
|
name: string
|
||||||
@@ -97,5 +166,9 @@ export interface Blueprint {
|
|||||||
functions: BlueprintFunction[]
|
functions: BlueprintFunction[]
|
||||||
coreInvariants: string[]
|
coreInvariants: string[]
|
||||||
composition?: Composition
|
composition?: Composition
|
||||||
stateMachine?: StateMachine
|
// Zero or more lifecycles. Most blueprints have one (or reuse LOADSTATE_MACHINE);
|
||||||
|
// some — like BLE, which is a central with an adapter/scan lifecycle *and* a
|
||||||
|
// per-peripheral link lifecycle — render several, each in its own panel.
|
||||||
|
stateMachines?: StateMachine[]
|
||||||
|
related?: RelatedRef[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,15 @@ import type { Blueprint } from "./blueprint"
|
|||||||
import { list } from "./listBlueprint"
|
import { list } from "./listBlueprint"
|
||||||
import { grid } from "./gridBlueprint"
|
import { grid } from "./gridBlueprint"
|
||||||
import { calendar } from "./calendarBlueprint"
|
import { calendar } from "./calendarBlueprint"
|
||||||
|
import { rememberMe } from "./rememberMeBlueprint"
|
||||||
|
import { passwordAuth } from "./passwordAuthBlueprint"
|
||||||
|
import { ble } from "./bleBlueprint"
|
||||||
import ListSpecimen from "@/specimens/ListSpecimen.vue"
|
import ListSpecimen from "@/specimens/ListSpecimen.vue"
|
||||||
import GridSpecimen from "@/specimens/GridSpecimen.vue"
|
import GridSpecimen from "@/specimens/GridSpecimen.vue"
|
||||||
import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue"
|
import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue"
|
||||||
|
import RememberMeSpecimen from "@/specimens/RememberMeSpecimen.vue"
|
||||||
|
import PasswordAuthSpecimen from "@/specimens/PasswordAuthSpecimen.vue"
|
||||||
|
import BleSpecimen from "@/specimens/BleSpecimen.vue"
|
||||||
|
|
||||||
export interface RegistryEntry {
|
export interface RegistryEntry {
|
||||||
blueprint: Blueprint
|
blueprint: Blueprint
|
||||||
@@ -20,6 +26,9 @@ export const REGISTRY: Record<string, RegistryEntry> = {
|
|||||||
list: { blueprint: list, specimen: ListSpecimen },
|
list: { blueprint: list, specimen: ListSpecimen },
|
||||||
grid: { blueprint: grid, specimen: GridSpecimen },
|
grid: { blueprint: grid, specimen: GridSpecimen },
|
||||||
calendar: { blueprint: calendar, specimen: CalendarSpecimen },
|
calendar: { blueprint: calendar, specimen: CalendarSpecimen },
|
||||||
|
ble: { blueprint: ble, specimen: BleSpecimen },
|
||||||
|
"password-auth": { blueprint: passwordAuth, specimen: PasswordAuthSpecimen },
|
||||||
|
"remember-me": { blueprint: rememberMe, specimen: RememberMeSpecimen },
|
||||||
}
|
}
|
||||||
|
|
||||||
export const BLUEPRINTS: Blueprint[] = Object.values(REGISTRY).map((e) => e.blueprint)
|
export const BLUEPRINTS: Blueprint[] = Object.values(REGISTRY).map((e) => e.blueprint)
|
||||||
@@ -27,3 +36,15 @@ export const BLUEPRINTS: Blueprint[] = Object.values(REGISTRY).map((e) => e.blue
|
|||||||
export function entryFor(slug: string): RegistryEntry | undefined {
|
export function entryFor(slug: string): RegistryEntry | undefined {
|
||||||
return REGISTRY[slug]
|
return REGISTRY[slug]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve a blueprint's display name (as used in `extendsName` / `related`) to its
|
||||||
|
// slug, so cross-references between blueprints render as live links. Returns
|
||||||
|
// undefined when the referenced blueprint is not illustrated yet — the reference
|
||||||
|
// then renders as plain text.
|
||||||
|
const slugByName: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(REGISTRY).map(([slug, e]) => [e.blueprint.name, slug]),
|
||||||
|
)
|
||||||
|
|
||||||
|
export function slugForName(name: string | undefined): string | undefined {
|
||||||
|
return name ? slugByName[name] : undefined
|
||||||
|
}
|
||||||
|
|||||||
@@ -204,5 +204,5 @@ export const calendar: Blueprint = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
stateMachine: CALENDAR_MACHINE,
|
stateMachines: [CALENDAR_MACHINE],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,5 +202,5 @@ export const grid: Blueprint = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
stateMachine: LOADSTATE_MACHINE,
|
stateMachines: [LOADSTATE_MACHINE],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,5 +226,5 @@ export const list: Blueprint = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
stateMachine: LOADSTATE_MACHINE,
|
stateMachines: [LOADSTATE_MACHINE],
|
||||||
}
|
}
|
||||||
|
|||||||
348
src/data/passwordAuthBlueprint.ts
Normal file
348
src/data/passwordAuthBlueprint.ts
Normal file
@@ -0,0 +1,348 @@
|
|||||||
|
// The PasswordAuth blueprint, as data. Transcribed by hand from the ontology
|
||||||
|
// `password-auth` README + password-auth.als. A *feature* that extends Form: the
|
||||||
|
// user submits a credential, the backend verifies it, and a bounded session is
|
||||||
|
// established — with uniform failures (no username enumeration), lockout, and
|
||||||
|
// silent refresh. It is the host that RememberMe / MFA / Captcha compose onto.
|
||||||
|
|
||||||
|
import type { Blueprint, StateMachine } from "./blueprint"
|
||||||
|
|
||||||
|
// The full AuthState lifecycle. Idle → Submitting fans out to Authenticated /
|
||||||
|
// Error / LockedOut; Authenticated ⇄ Refreshing; and reset() / unlock() / signOut()
|
||||||
|
// all return to Idle. Node coordinates are hand-placed for a clean layout.
|
||||||
|
const PASSWORD_AUTH_MACHINE: StateMachine = {
|
||||||
|
initId: "idle",
|
||||||
|
caption: "Session lifecycle",
|
||||||
|
field: "state : AuthState",
|
||||||
|
nodes: [
|
||||||
|
{ id: "idle", x: 30, y: 140, w: 90, h: 44, label: "Idle" },
|
||||||
|
{ id: "submitting", x: 190, y: 140, w: 120, h: 44, label: "Submitting" },
|
||||||
|
{ id: "authenticated", x: 400, y: 140, w: 140, h: 44, label: "Authenticated" },
|
||||||
|
{ id: "refreshing", x: 630, y: 44, w: 130, h: 44, label: "Refreshing" },
|
||||||
|
{ id: "lockedout", x: 175, y: 252, w: 120, h: 44, label: "LockedOut" },
|
||||||
|
{ id: "error", x: 395, y: 252, w: 100, h: 44, label: "Error" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ from: "idle", to: "submitting", label: "signIn(c)", kind: "submit", off: -34 },
|
||||||
|
{ from: "submitting", to: "authenticated", label: "succeed(id)", kind: "succeed", off: -34 },
|
||||||
|
{ from: "submitting", to: "lockedout", label: "lockOut()", kind: "lock", off: -20 },
|
||||||
|
{ from: "submitting", to: "error", label: "fail()", kind: "fail", off: -24 },
|
||||||
|
{ from: "authenticated", to: "refreshing", label: "refresh()", kind: "refresh", off: -26 },
|
||||||
|
{ from: "refreshing", to: "authenticated", label: "refreshed(id)", kind: "succeed", off: -26 },
|
||||||
|
{ from: "refreshing", to: "error", label: "refreshFailed()", kind: "fail", off: -30 },
|
||||||
|
{ from: "authenticated", to: "idle", label: "signOut()", kind: "signout", off: 120 },
|
||||||
|
{ from: "error", to: "idle", label: "reset()", kind: "reset", off: -40 },
|
||||||
|
{ from: "lockedout", to: "idle", label: "unlock()", kind: "unlock", off: 30 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const passwordAuth: Blueprint = {
|
||||||
|
slug: "password-auth",
|
||||||
|
name: "PasswordAuth",
|
||||||
|
tagline:
|
||||||
|
"Submit a credential, verify it against the backend, and hold a bounded session — with uniform failures, lockout, and silent refresh.",
|
||||||
|
role: "feature",
|
||||||
|
extendsName: "Form",
|
||||||
|
|
||||||
|
sig: {
|
||||||
|
header: "PasswordAuth ◁ Form",
|
||||||
|
fields: [
|
||||||
|
{ name: "state", type: "AuthState", note: "lifecycle position" },
|
||||||
|
{ name: "identity", type: "lone Identity", note: "present iff Authenticated or Refreshing" },
|
||||||
|
{
|
||||||
|
name: "pendingCredential",
|
||||||
|
type: "lone Credential",
|
||||||
|
note: "present only during Submitting",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
types: [
|
||||||
|
"AuthState = Idle | Submitting | Authenticated | Refreshing | LockedOut | Error",
|
||||||
|
"Identity { subject : Subject, realm : Realm } -- subject unique per realm",
|
||||||
|
"Credential { username : Username, secret : Secret } -- 8 ≤ len(secret) ≤ 128",
|
||||||
|
"identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing}",
|
||||||
|
"pendingCredential ≠ ⊥ ⟺ state = Submitting",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
functions: [
|
||||||
|
{
|
||||||
|
id: "capture",
|
||||||
|
name: "Capture",
|
||||||
|
fig: "01",
|
||||||
|
caps: ["PasswordAuth", "Form"],
|
||||||
|
verb: "Collect username and secret from the form; mask the secret and mount both fields together so the OS credential manager can pair them.",
|
||||||
|
state: [
|
||||||
|
["username", "Username"],
|
||||||
|
["secret", "Secret"],
|
||||||
|
],
|
||||||
|
behaviors: [],
|
||||||
|
invariants: [
|
||||||
|
"len(secret) ∈ [8, 128] — enforced before signIn(c) fires; the upper bound is surfaced to the user, never silently accepted (bcrypt truncates at 72 bytes)",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Form auto-submit — browser autofill triggers signIn without user intent; the unintended submission locks the user out faster on stale credentials",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"Form responsiveness — input fields remain accessible during Submitting; the submit button is disabled to prevent double-fire",
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"Username and password are mounted together on the submitted form so the credential manager can pair them on save.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "submit",
|
||||||
|
name: "Submit",
|
||||||
|
fig: "02",
|
||||||
|
caps: ["PasswordAuth"],
|
||||||
|
verb: "Transmit the credential over an authenticated channel (TLS) on a user gesture; never log or persist the secret.",
|
||||||
|
state: [
|
||||||
|
["state", "AuthState"],
|
||||||
|
["pendingCredential", "lone Credential"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "signIn(c)",
|
||||||
|
pre: "state = Idle",
|
||||||
|
eff: "state ← Submitting, pendingCredential ← c",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"pendingCredential ≠ ⊥ ⟺ state = Submitting — the credential is held only across the verification call",
|
||||||
|
"the plaintext secret is never persisted, logged, or held past Submitting",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Plaintext leak — secret logged, persisted, or kept in app memory past Submitting; exposed to crash dumps, analytics, or an attacker with disk read",
|
||||||
|
"Form auto-submit — autofill fires signIn without a user gesture",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"Verification — signIn to Authenticated or Error completes within 2 s on a stable network",
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"The signIn(c) submission must be triggered by a user gesture, never auto-fired on autofill completion.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "verify",
|
||||||
|
name: "Verify",
|
||||||
|
fig: "03",
|
||||||
|
caps: ["PasswordAuth"],
|
||||||
|
verb: "Backend checks the credential against the credential store; returns an Identity or a uniform failure that never distinguishes unknown-user from wrong-password.",
|
||||||
|
state: [
|
||||||
|
["state", "AuthState"],
|
||||||
|
["identity", "lone Identity"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "succeed(id)",
|
||||||
|
pre: "state = Submitting, verification passed",
|
||||||
|
eff: "state ← Authenticated, identity ← id, pendingCredential ← ⊥",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "fail()",
|
||||||
|
pre: "state = Submitting, verification failed",
|
||||||
|
eff: "state ← Error, pendingCredential ← ⊥",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"succeed(id) requires backend verification — the relying party never trusts client-side validation alone",
|
||||||
|
"fail() does not distinguish 'wrong password' from 'unknown username' — messages stay uniform",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Username enumeration — fail() reason distinguishes 'no such user' from 'wrong password'; an attacker maps valid usernames before a targeted attack",
|
||||||
|
"Timing attack — verification time differs for valid vs invalid usernames; the attacker infers validity from response latency",
|
||||||
|
"Credential reuse — the backend accepts a known-breached password, so a password compromised elsewhere grants access here",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"Constant-time response — failure responses take the same time regardless of username validity",
|
||||||
|
"Failure visibility — Error surfaces with a uniform message, never revealing whether the username exists",
|
||||||
|
],
|
||||||
|
notes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "limit",
|
||||||
|
name: "Limit",
|
||||||
|
fig: "04",
|
||||||
|
caps: ["PasswordAuth"],
|
||||||
|
verb: "Track failed attempts per user / per IP; transition to LockedOut past the threshold.",
|
||||||
|
state: [["state", "AuthState"]],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "lockOut()",
|
||||||
|
pre: "state = Submitting, attempt threshold exceeded",
|
||||||
|
eff: "state ← LockedOut, pendingCredential ← ⊥",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"state = LockedOut is reached only from Submitting via lockOut() — never user-triggerable directly",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Brute force — no attempt counting / no lockOut threshold; the attacker tries unbounded password guesses",
|
||||||
|
"Lockout bypass — unlock() callable without elapsed cooldown; the rate limit is defeated and brute force resumes",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"Lockout feedback — LockedOut surfaces to the user within 1 s of trigger, with the cooldown duration shown",
|
||||||
|
],
|
||||||
|
notes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "establish",
|
||||||
|
name: "Establish",
|
||||||
|
fig: "05",
|
||||||
|
caps: ["PasswordAuth", "Identity"],
|
||||||
|
verb: "Persist the session token; expose subject and realm to the consumer.",
|
||||||
|
state: [["identity", "lone Identity"]],
|
||||||
|
behaviors: [],
|
||||||
|
invariants: [
|
||||||
|
"identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing} — no ghost identity outside an active session",
|
||||||
|
"the consumer sees subject and realm; the plaintext secret never crosses the client-persistence boundary",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Replay (token theft) — the session token leaks (XSS, network capture); the attacker impersonates the user until the token expires",
|
||||||
|
],
|
||||||
|
perf: [],
|
||||||
|
notes: [
|
||||||
|
"Client persistence may store Identity.subject / Identity.realm and the composed session credential — never the plaintext secret in any form.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "refresh",
|
||||||
|
name: "Refresh",
|
||||||
|
fig: "06",
|
||||||
|
caps: ["PasswordAuth"],
|
||||||
|
verb: "Renew the session token silently before expiry; preserve subject and realm.",
|
||||||
|
state: [
|
||||||
|
["state", "AuthState"],
|
||||||
|
["identity", "lone Identity"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "refresh()",
|
||||||
|
pre: "state = Authenticated, session token refreshable",
|
||||||
|
eff: "state ← Refreshing",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "refreshed(id)",
|
||||||
|
pre: "state = Refreshing, id.subject = identity.subject, id.realm = identity.realm",
|
||||||
|
eff: "state ← Authenticated, identity ← id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "refreshFailed()",
|
||||||
|
pre: "state = Refreshing",
|
||||||
|
eff: "state ← Error, identity ← ⊥",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"refreshed(id) preserves both subject and realm — refresh never silently swaps user or tenant",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Subject swap on refresh — refreshed(id) accepts an id with a different subject or realm; the attacker's refresh response steals the session",
|
||||||
|
"Stale session — the session is used past expiry without refresh; API calls 401 and the user is surprise-logged-out",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"Silent refresh — completes within 200 ms and before any in-flight API call observes a 401",
|
||||||
|
],
|
||||||
|
notes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "recover",
|
||||||
|
name: "Recover",
|
||||||
|
fig: "07",
|
||||||
|
caps: ["PasswordAuth"],
|
||||||
|
verb: "Surface Error distinctly from LockedOut; expose reset() and the cooldown-driven unlock().",
|
||||||
|
state: [["state", "AuthState"]],
|
||||||
|
behaviors: [
|
||||||
|
{ sig: "reset()", pre: "state = Error", eff: "state ← Idle" },
|
||||||
|
{
|
||||||
|
sig: "unlock()",
|
||||||
|
pre: "state = LockedOut, cooldown elapsed",
|
||||||
|
eff: "state ← Idle",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"unlock() is reachable only from LockedOut and requires elapsed cooldown — not user-triggerable",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Forgotten password loop — no recovery path from LockedOut or an unknown username; the user is permanently locked out with no self-service unblock",
|
||||||
|
],
|
||||||
|
perf: [],
|
||||||
|
notes: [
|
||||||
|
"Recovery from a forgotten password is a sibling blueprint (PasswordReset), not part of this contract.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "terminate",
|
||||||
|
name: "Terminate",
|
||||||
|
fig: "08",
|
||||||
|
caps: ["PasswordAuth"],
|
||||||
|
verb: "Clear the local session and invalidate the session token at the backend.",
|
||||||
|
state: [
|
||||||
|
["state", "AuthState"],
|
||||||
|
["identity", "lone Identity"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "signOut()",
|
||||||
|
pre: "state = Authenticated",
|
||||||
|
eff: "state ← Idle, identity ← ⊥",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: ["signOut() clears identity — no active session remains locally"],
|
||||||
|
failures: [
|
||||||
|
"Stale session — a logged-out session token still valid at the backend can be replayed",
|
||||||
|
],
|
||||||
|
perf: [],
|
||||||
|
notes: [
|
||||||
|
"A logout is not a credential operation — the secret never crosses the logout boundary; only the session credential is sent so the server can revoke.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
coreInvariants: [
|
||||||
|
"identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing} — no ghost identity outside an active session",
|
||||||
|
"pendingCredential ≠ ⊥ ⟺ state = Submitting — credentials are held only across the verification call",
|
||||||
|
"the plaintext secret is never persisted, logged, or held past Submitting",
|
||||||
|
"refreshed(id) preserves both subject and realm — refresh never swaps user or tenant",
|
||||||
|
"succeed(id) requires backend verification — the relying party never trusts client-side validation alone",
|
||||||
|
"unlock() is reachable only from LockedOut and requires elapsed cooldown — not user-triggerable",
|
||||||
|
"len(secret) ∈ [8, 128] is enforced before signIn(c) fires",
|
||||||
|
],
|
||||||
|
|
||||||
|
composition: {
|
||||||
|
caps: [
|
||||||
|
{ id: "core", label: "PasswordAuth : Form", sub: "state, identity", core: true },
|
||||||
|
{ id: "form", label: "Form", sub: "fields, filled, invalid" },
|
||||||
|
{ id: "cred", label: "Credential", sub: "username, secret" },
|
||||||
|
{ id: "identity", label: "Identity", sub: "subject, realm" },
|
||||||
|
],
|
||||||
|
funcs: ["Capture", "Submit", "Verify", "Limit", "Establish", "Refresh", "Recover", "Terminate"],
|
||||||
|
links: [
|
||||||
|
["form", "Capture"],
|
||||||
|
["cred", "Capture"],
|
||||||
|
["cred", "Submit"],
|
||||||
|
["core", "Submit"],
|
||||||
|
["core", "Verify"],
|
||||||
|
["identity", "Verify"],
|
||||||
|
["core", "Limit"],
|
||||||
|
["core", "Establish"],
|
||||||
|
["identity", "Establish"],
|
||||||
|
["core", "Refresh"],
|
||||||
|
["identity", "Refresh"],
|
||||||
|
["core", "Recover"],
|
||||||
|
["form", "Recover"],
|
||||||
|
["core", "Terminate"],
|
||||||
|
["identity", "Terminate"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
stateMachines: [PASSWORD_AUTH_MACHINE],
|
||||||
|
|
||||||
|
related: [
|
||||||
|
{ name: "Form", relation: "extends · base" },
|
||||||
|
{ name: "RememberMe", relation: "composable extension" },
|
||||||
|
{ name: "MFA", relation: "composable extension" },
|
||||||
|
{ name: "Captcha", relation: "composable extension" },
|
||||||
|
{ name: "StatelessSession", relation: "composable extension" },
|
||||||
|
{ name: "PasswordReset", relation: "sibling · recovery" },
|
||||||
|
{ name: "SSO", relation: "sibling · delegated" },
|
||||||
|
{ name: "Passkey", relation: "sibling · passwordless" },
|
||||||
|
],
|
||||||
|
}
|
||||||
312
src/data/rememberMeBlueprint.ts
Normal file
312
src/data/rememberMeBlueprint.ts
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
// The RememberMe blueprint, as data. Transcribed by hand from the ontology
|
||||||
|
// `remember-me` README + remember-me.als. Unlike List/Grid/Calendar (visual
|
||||||
|
// *feature* layouts), RememberMe is a *capability* that composes onto a
|
||||||
|
// PasswordAuth host: a consent-gated persistent token that survives browser
|
||||||
|
// close and revives the host into `Refreshing` — never directly `Authenticated`.
|
||||||
|
// It owns its own lifecycle (NotPersisted → Persisting → Persisted → Revoked),
|
||||||
|
// so it defines its own state machine rather than sharing LOADSTATE_MACHINE.
|
||||||
|
|
||||||
|
import type { Blueprint, StateMachine } from "./blueprint"
|
||||||
|
|
||||||
|
// NotPersisted (initial) → Persisting → Persisted, with the three "return to
|
||||||
|
// NotPersisted" edges (storeFailed / expired / clear) arcing below the row and
|
||||||
|
// revoke() branching Persisted → Revoked. Node coordinates are hand-placed.
|
||||||
|
const REMEMBER_ME_MACHINE: StateMachine = {
|
||||||
|
initId: "notpersisted",
|
||||||
|
caption: "Persistence lifecycle",
|
||||||
|
field: "state : RememberMeState",
|
||||||
|
nodes: [
|
||||||
|
{ id: "notpersisted", x: 40, y: 150, w: 150, h: 44, label: "NotPersisted" },
|
||||||
|
{ id: "persisting", x: 300, y: 150, w: 120, h: 44, label: "Persisting" },
|
||||||
|
{ id: "persisted", x: 560, y: 150, w: 120, h: 44, label: "Persisted" },
|
||||||
|
{ id: "revoked", x: 760, y: 150, w: 120, h: 44, label: "Revoked" },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
// forward path (arcs up, labels above the row)
|
||||||
|
{ from: "notpersisted", to: "persisting", label: "startPersist()", kind: "persist", off: -90 },
|
||||||
|
{ from: "persisting", to: "persisted", label: "tokenStored(t)", kind: "store", off: -90 },
|
||||||
|
{ from: "persisted", to: "revoked", label: "revoke()", kind: "revoke", off: -90 },
|
||||||
|
// returns to NotPersisted (arc below the row; deeper = wider span)
|
||||||
|
{ from: "persisting", to: "notpersisted", label: "storeFailed()", kind: "storeFail", off: -70 },
|
||||||
|
{ from: "persisted", to: "notpersisted", label: "expired()", kind: "expire", off: -120 },
|
||||||
|
{ from: "revoked", to: "notpersisted", label: "clear()", kind: "clear", off: -172 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export const rememberMe: Blueprint = {
|
||||||
|
slug: "remember-me",
|
||||||
|
name: "RememberMe",
|
||||||
|
tagline:
|
||||||
|
"A consent-gated persistent token that survives browser close and revives a signed-out host into Refreshing — never directly into Authenticated.",
|
||||||
|
role: "capability",
|
||||||
|
extendsName: "PasswordAuth",
|
||||||
|
|
||||||
|
sig: {
|
||||||
|
header: "RememberMeBinding ◁ PasswordAuth",
|
||||||
|
fields: [
|
||||||
|
{ name: "host", type: "one PasswordAuth", note: "the host session being persisted" },
|
||||||
|
{ name: "state", type: "RememberMeState", note: "persistence lifecycle position" },
|
||||||
|
{ name: "optIn", type: "one Bool", note: "captured at sign-in; gates persistence" },
|
||||||
|
{ name: "token", type: "lone PersistentToken", note: "present iff state = Persisted" },
|
||||||
|
],
|
||||||
|
types: [
|
||||||
|
"RememberMeState = NotPersisted | Persisting | Persisted | Revoked",
|
||||||
|
"PersistentToken { subject : Subject, issuedAt, expiresAt : Time } -- absolute TTL",
|
||||||
|
"token ≠ ⊥ ⟺ state = Persisted",
|
||||||
|
"state ∈ {Persisting, Persisted} ⟹ optIn = True (consent-gated)",
|
||||||
|
"token.subject = host.identity.subject at the moment of tokenStored",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
functions: [
|
||||||
|
{
|
||||||
|
id: "capture",
|
||||||
|
name: "Capture",
|
||||||
|
fig: "01",
|
||||||
|
caps: ["RememberMe"],
|
||||||
|
verb: "Read the user's opt-in choice at sign-in (checkbox / toggle) and record optIn.",
|
||||||
|
state: [["optIn", "one Bool"]],
|
||||||
|
behaviors: [],
|
||||||
|
invariants: ["optIn defaults to False — persistence is opt-in, never opt-out"],
|
||||||
|
failures: [],
|
||||||
|
perf: [],
|
||||||
|
notes: ["optIn is captured once at sign-in; it is not re-prompted on every launch."],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trigger",
|
||||||
|
name: "Trigger",
|
||||||
|
fig: "02",
|
||||||
|
caps: ["RememberMe", "PasswordAuth"],
|
||||||
|
verb: "Observe host.state transitions; fire startPersist() only when the host reaches Authenticated with opt-in.",
|
||||||
|
state: [
|
||||||
|
["state", "RememberMeState"],
|
||||||
|
["host.state", "AuthState"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "startPersist()",
|
||||||
|
pre: "host.state = Authenticated, optIn = True, state = NotPersisted",
|
||||||
|
eff: "state ← Persisting",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: ["state ∈ {Persisting, Persisted} ⟹ optIn = True — persistence is consent-gated"],
|
||||||
|
failures: [
|
||||||
|
"Lost opt-in — startPersist() fires without optIn = True; the user gets surprise persistence when they wanted a one-shot session",
|
||||||
|
],
|
||||||
|
perf: [],
|
||||||
|
notes: [
|
||||||
|
"startPersist() fires on the host's transition into Authenticated — not on Idle, not on Refreshing.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "issue",
|
||||||
|
name: "Issue",
|
||||||
|
fig: "03",
|
||||||
|
caps: ["PersistentToken", "PasswordAuth"],
|
||||||
|
verb: "Backend mints a long-lived, subject-bound PersistentToken with an absolute expiresAt.",
|
||||||
|
state: [["token", "lone PersistentToken"]],
|
||||||
|
behaviors: [],
|
||||||
|
invariants: [
|
||||||
|
"token.subject = host.identity.subject at issuance — no cross-account binding",
|
||||||
|
"expiresAt is absolute — set once at issuance, never extended by use",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Cross-account binding — token.subject differs from the verified host identity; the token later impersonates a different user on revival",
|
||||||
|
"Sliding expiry — expiresAt deferred whenever the token is used; the TTL is defeated and the token effectively never expires",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"Token issuance — Persisting → Persisted completes within 200 ms after the host reaches Authenticated",
|
||||||
|
"Token TTL — persistent tokens expire within 30 days of issuance to bound the theft window",
|
||||||
|
],
|
||||||
|
notes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "store",
|
||||||
|
name: "Store",
|
||||||
|
fig: "04",
|
||||||
|
caps: ["SecureStorage", "PersistentToken"],
|
||||||
|
verb: "Persist the token bytes to platform-secure storage; surface storeFailed() if storage is unavailable.",
|
||||||
|
state: [
|
||||||
|
["state", "RememberMeState"],
|
||||||
|
["token", "lone PersistentToken"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "tokenStored(t)",
|
||||||
|
pre: "state = Persisting, t.subject = host.identity.subject",
|
||||||
|
eff: "state ← Persisted, token ← t",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sig: "storeFailed()",
|
||||||
|
pre: "state = Persisting, secure storage unavailable",
|
||||||
|
eff: "state ← NotPersisted, token ← ⊥",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"token ≠ ⊥ ⟺ state = Persisted — bytes exist only after tokenStored; Persisting is the in-flight window",
|
||||||
|
"the token lives in platform-secure storage (Keychain, Credential Manager, HttpOnly cookie) — never in plain localStorage",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Insecure storage — token kept in localStorage / plain disk; recoverable by any script or filesystem reader",
|
||||||
|
"Secret exfil on log — logging middleware captures the token bytes in flight; a long-lived credential leaks to log retention",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"Storage failure — storeFailed() surfaces to the user within 1 s so they know persistence didn't take",
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"During Persisting the request is in flight and no token has been minted — the token appears only on tokenStored.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "revive",
|
||||||
|
name: "Revive",
|
||||||
|
fig: "05",
|
||||||
|
caps: ["RememberMe", "PasswordAuth"],
|
||||||
|
verb: "At cold start with a Persisted token, hand it to host.refresh() — driving the host Idle → Refreshing, never writing Authenticated.",
|
||||||
|
state: [["host.state", "AuthState"]],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "revive()",
|
||||||
|
pre: "cold start, state = Persisted, host.state = Idle",
|
||||||
|
eff: "host.refresh() — host transitions Idle → Refreshing",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"the persistent token cannot grant host.state = Authenticated directly — only host.refreshed(id) (backend-confirmed) reaches Authenticated",
|
||||||
|
"revive() is a side-effect, not a binding transition — the host's refreshed(id) re-establishes the session",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Direct-to-Authenticated — revive bypasses host.refresh() and writes Authenticated; the backend never re-validates and revoked tokens still work",
|
||||||
|
"Stale token at launch — revive() uses a token past its expiresAt; the backend rejects it and the user gets a confusing failure",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"Cold-start revival — revive() drives host.state = Refreshing within 100 ms of launch (no flash of Idle UI)",
|
||||||
|
"Refresh round-trip — host refresh → refreshed(id) completes within 1 s on a stable network",
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
"The consumer never sees Authenticated until the backend confirms — the Refreshing → Authenticated transition is what proves the user is still allowed access.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "revoke",
|
||||||
|
name: "Revoke",
|
||||||
|
fig: "06",
|
||||||
|
caps: ["RememberMe", "PasswordAuth"],
|
||||||
|
verb: "On host sign-out, a backend revocation signal, or suspicious activity, clear the token and move to Revoked.",
|
||||||
|
state: [
|
||||||
|
["state", "RememberMeState"],
|
||||||
|
["token", "lone PersistentToken"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "revoke()",
|
||||||
|
pre: "state = Persisted; host signed out / token revoked / suspicious activity",
|
||||||
|
eff: "state ← Revoked, token ← ⊥",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"revoke() clears the token atomically — no window where state = Revoked ∧ token ≠ ⊥",
|
||||||
|
"host.signOut() cascades to revoke() — local sign-out always invalidates the persistent token",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"No revocation cascade — signOut does not fire revoke(); a stolen device still authenticates after the user 'signed out'",
|
||||||
|
"Token theft — a leaked token (XSS, malware, shared device) silently signs in as the user until expiry or revocation",
|
||||||
|
"Multi-device confusion — one device's revoke() doesn't propagate to others; the user signs out on their phone but the browser stays authenticated for hours",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"Revocation propagation — revoke() clears local token bytes within 50 ms of the trigger",
|
||||||
|
],
|
||||||
|
notes: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "expire",
|
||||||
|
name: "Expire",
|
||||||
|
fig: "07",
|
||||||
|
caps: ["RememberMe", "PersistentToken"],
|
||||||
|
verb: "Drop the token when the wall clock crosses expiresAt; return to NotPersisted with no revocation noise.",
|
||||||
|
state: [
|
||||||
|
["state", "RememberMeState"],
|
||||||
|
["token", "lone PersistentToken"],
|
||||||
|
],
|
||||||
|
behaviors: [
|
||||||
|
{
|
||||||
|
sig: "expired()",
|
||||||
|
pre: "state = Persisted, now ≥ token.expiresAt",
|
||||||
|
eff: "state ← NotPersisted, token ← ⊥",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
invariants: [
|
||||||
|
"expired() is reachable only from Persisted and only when the wall clock crosses expiresAt — TTL is absolute, not extended by use",
|
||||||
|
],
|
||||||
|
failures: [
|
||||||
|
"Sliding expiry — expired() deferred whenever the token is used; the token effectively never expires and defeats the TTL",
|
||||||
|
],
|
||||||
|
perf: [
|
||||||
|
"Expiry visibility — expired() is silent; the UI shows the host's normal Idle state, no false alarm to the user",
|
||||||
|
],
|
||||||
|
notes: ["Expiry differs from revocation: it is silent and routine, not a security event."],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "clear",
|
||||||
|
name: "Clear",
|
||||||
|
fig: "08",
|
||||||
|
caps: ["RememberMe"],
|
||||||
|
verb: "Move from Revoked to NotPersisted once the consumer has acknowledged the revocation.",
|
||||||
|
state: [["state", "RememberMeState"]],
|
||||||
|
behaviors: [{ sig: "clear()", pre: "state = Revoked", eff: "state ← NotPersisted" }],
|
||||||
|
invariants: [
|
||||||
|
"no token exists in Revoked or NotPersisted — clear() only advances the lifecycle position",
|
||||||
|
],
|
||||||
|
failures: [],
|
||||||
|
perf: [],
|
||||||
|
notes: [
|
||||||
|
"clear() closes the loop: Revoked → NotPersisted, ready to persist again on the next opted-in sign-in.",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
coreInvariants: [
|
||||||
|
"token ≠ ⊥ ⟺ state = Persisted — token bytes exist only after tokenStored",
|
||||||
|
"state ∈ {Persisting, Persisted} ⟹ optIn = True — persistence is opt-in only",
|
||||||
|
"token.subject = host.identity.subject at issuance — no cross-account binding",
|
||||||
|
"a persistent token cannot reach Authenticated directly — it only feeds host.refresh(); only host.refreshed(id) (backend-confirmed) authenticates",
|
||||||
|
"revoke() clears the token atomically — no window where state = Revoked ∧ token ≠ ⊥",
|
||||||
|
"the token lives in platform-secure storage — never in plain localStorage",
|
||||||
|
"expired() is reachable only from Persisted, only when the wall clock crosses expiresAt — TTL is absolute",
|
||||||
|
],
|
||||||
|
|
||||||
|
composition: {
|
||||||
|
caps: [
|
||||||
|
{ id: "core", label: "RememberMe : Binding", sub: "state, optIn, token", core: true },
|
||||||
|
{ id: "host", label: "PasswordAuth", sub: "host.state, identity" },
|
||||||
|
{ id: "token", label: "PersistentToken", sub: "subject, expiresAt" },
|
||||||
|
{ id: "storage", label: "SecureStorage", sub: "Keychain · HttpOnly" },
|
||||||
|
],
|
||||||
|
funcs: ["Capture", "Trigger", "Issue", "Store", "Revive", "Revoke", "Expire", "Clear"],
|
||||||
|
links: [
|
||||||
|
["core", "Capture"],
|
||||||
|
["core", "Trigger"],
|
||||||
|
["host", "Trigger"],
|
||||||
|
["token", "Issue"],
|
||||||
|
["host", "Issue"],
|
||||||
|
["storage", "Store"],
|
||||||
|
["token", "Store"],
|
||||||
|
["core", "Revive"],
|
||||||
|
["host", "Revive"],
|
||||||
|
["core", "Revoke"],
|
||||||
|
["host", "Revoke"],
|
||||||
|
["token", "Expire"],
|
||||||
|
["core", "Expire"],
|
||||||
|
["core", "Clear"],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
stateMachines: [REMEMBER_ME_MACHINE],
|
||||||
|
|
||||||
|
related: [
|
||||||
|
{ name: "PasswordAuth", relation: "host · requires" },
|
||||||
|
{ name: "StatelessSession", relation: "sibling capability" },
|
||||||
|
{ name: "MFA", relation: "sibling capability" },
|
||||||
|
{ name: "Captcha", relation: "sibling capability" },
|
||||||
|
],
|
||||||
|
}
|
||||||
481
src/specimens/BleSpecimen.vue
Normal file
481
src/specimens/BleSpecimen.vue
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{ activeId: string }>()
|
||||||
|
|
||||||
|
// BLE has a real central-side UI, so the specimen mocks its two screens — the
|
||||||
|
// "Nearby Devices" scanner and a connected peripheral's GATT view — and moves
|
||||||
|
// the highlight per function, the same annotate-one-surface approach List/Grid/
|
||||||
|
// Calendar use. Below the device, a state bar shows the central's own machinery:
|
||||||
|
// the doubly-gated (permission + adapter) scan and the per-peripheral link.
|
||||||
|
|
||||||
|
type Screen = "scanning" | "connected"
|
||||||
|
type Region = "permission" | "power" | "scan" | "connect" | "services" | "exchange" | "validate"
|
||||||
|
|
||||||
|
interface Frame {
|
||||||
|
screen: Screen
|
||||||
|
hl?: Region
|
||||||
|
tag?: string
|
||||||
|
note?: string
|
||||||
|
perm: string
|
||||||
|
adapter: string
|
||||||
|
scan: string
|
||||||
|
link?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = computed<Frame>(() => {
|
||||||
|
switch (props.activeId) {
|
||||||
|
case "permission":
|
||||||
|
return {
|
||||||
|
screen: "scanning",
|
||||||
|
hl: "permission",
|
||||||
|
tag: "◈ PERMISSION",
|
||||||
|
perm: "NotAsked → Granted",
|
||||||
|
adapter: "PoweredOn",
|
||||||
|
scan: "ScanIdle",
|
||||||
|
note: "requestPermission() → OS dialog → grant(). scan = Scanning requires permission = Granted — the first of two gates. deny() also forces scan ← ScanIdle.",
|
||||||
|
}
|
||||||
|
case "power":
|
||||||
|
return {
|
||||||
|
screen: "scanning",
|
||||||
|
hl: "power",
|
||||||
|
tag: "◈ POWER",
|
||||||
|
perm: "Granted",
|
||||||
|
adapter: "PoweredOff → PoweredOn",
|
||||||
|
scan: "ScanIdle",
|
||||||
|
note: "The radio is the second scan gate. powerOff() (user toggles Bluetooth off) forces scan ← ScanIdle and clears discovered — every link drops.",
|
||||||
|
}
|
||||||
|
case "scan":
|
||||||
|
return {
|
||||||
|
screen: "scanning",
|
||||||
|
hl: "scan",
|
||||||
|
tag: "◈ SCAN",
|
||||||
|
perm: "Granted",
|
||||||
|
adapter: "PoweredOn",
|
||||||
|
scan: "Scanning",
|
||||||
|
note: "startScan() [Granted + PoweredOn] → Scanning; discover(p) accumulates advertisers into the discovered set. Pair with stopScan(), or a continuous radio scan drains the battery.",
|
||||||
|
}
|
||||||
|
case "connect":
|
||||||
|
return {
|
||||||
|
screen: "connected",
|
||||||
|
hl: "connect",
|
||||||
|
tag: "◈ CONNECT",
|
||||||
|
perm: "Granted",
|
||||||
|
adapter: "PoweredOn",
|
||||||
|
scan: "ScanIdle",
|
||||||
|
link: "Disconnected → Connecting → Connected",
|
||||||
|
note: "connect(p) → Connecting; established(p) → Connected, or connectFail() back to Disconnected. disconnect() / drop returns to Disconnected and invalidates every cached handle.",
|
||||||
|
}
|
||||||
|
case "discover-services":
|
||||||
|
return {
|
||||||
|
screen: "connected",
|
||||||
|
hl: "services",
|
||||||
|
tag: "◈ DISCOVER",
|
||||||
|
perm: "Granted",
|
||||||
|
adapter: "PoweredOn",
|
||||||
|
scan: "ScanIdle",
|
||||||
|
link: "Connected → Ready",
|
||||||
|
note: "discoverServices(p) enumerates GATT services/characteristics → link ← Ready. Handles exist only in Ready; a drop clears them, so they must be re-discovered after every reconnect.",
|
||||||
|
}
|
||||||
|
case "exchange":
|
||||||
|
return {
|
||||||
|
screen: "connected",
|
||||||
|
hl: "exchange",
|
||||||
|
tag: "◈ EXCHANGE",
|
||||||
|
perm: "Granted",
|
||||||
|
adapter: "PoweredOn",
|
||||||
|
scan: "ScanIdle",
|
||||||
|
link: "Ready",
|
||||||
|
note: "On a Ready link: subscribe(ch) for live notifications, or a one-shot read/write — against discovered characteristics only. subscriptions ⊆ characteristics.",
|
||||||
|
}
|
||||||
|
case "validate":
|
||||||
|
return {
|
||||||
|
screen: "connected",
|
||||||
|
hl: "validate",
|
||||||
|
tag: "◈ VALIDATE",
|
||||||
|
perm: "Granted",
|
||||||
|
adapter: "PoweredOn",
|
||||||
|
scan: "ScanIdle",
|
||||||
|
link: "Ready",
|
||||||
|
note: "The peripheral is untrusted: bounds-check every payload before parsing. Raw MAC, unparsed bytes, and the device name never reach analytics or your backend — only derived, validated values do.",
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
screen: "scanning",
|
||||||
|
perm: "Granted",
|
||||||
|
adapter: "PoweredOn",
|
||||||
|
scan: "Scanning",
|
||||||
|
note: "A central: doubly-gated scanning (permission + radio) discovers peripherals; connect to one, discover its GATT characteristics, then read / write / subscribe over a Ready link that may drop at any time.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const peripherals = [
|
||||||
|
{ name: "Polar H10", addr: "A1:2F", rssi: "-54 dBm" },
|
||||||
|
{ name: "Mi Scale 2", addr: "9C:8E", rssi: "-71 dBm" },
|
||||||
|
]
|
||||||
|
|
||||||
|
const isHl = (r: Region) => frame.value.hl === r
|
||||||
|
const tagOf = (r: Region) => (frame.value.hl === r ? frame.value.tag : undefined)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="specimen">
|
||||||
|
<p v-if="frame.note" class="note">{{ frame.note }}</p>
|
||||||
|
|
||||||
|
<div class="device">
|
||||||
|
<div class="dev-bar">
|
||||||
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||||
|
<span class="dev-title">MyApp · Bluetooth</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SCANNING screen -->
|
||||||
|
<div v-if="frame.screen === 'scanning'" class="screen">
|
||||||
|
<div class="statusrow">
|
||||||
|
<span
|
||||||
|
class="chip on"
|
||||||
|
:class="isHl('power') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('power')"
|
||||||
|
>
|
||||||
|
📶 Radio · On
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="chip on"
|
||||||
|
:class="isHl('permission') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('permission')"
|
||||||
|
>
|
||||||
|
🔓 Permission · Granted
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="listbox" :class="isHl('scan') ? ['hl', 'tag-b'] : []" :data-tag="tagOf('scan')">
|
||||||
|
<div class="list-head">
|
||||||
|
<span>Nearby Devices</span>
|
||||||
|
<span class="scanning"><span class="spin">⟳</span> scanning</span>
|
||||||
|
</div>
|
||||||
|
<div v-for="p in peripherals" :key="p.addr" class="dev-row">
|
||||||
|
<span class="d-name">{{ p.name }}</span>
|
||||||
|
<span class="d-addr">{{ p.addr }}</span>
|
||||||
|
<span class="d-rssi">{{ p.rssi }}</span>
|
||||||
|
<span class="connect-btn">connect</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CONNECTED screen -->
|
||||||
|
<div v-else class="screen">
|
||||||
|
<div
|
||||||
|
class="conn-head"
|
||||||
|
:class="isHl('connect') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('connect')"
|
||||||
|
>
|
||||||
|
<span class="d-name">Polar H10</span>
|
||||||
|
<span class="conn-status">◉ connected</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="charbox"
|
||||||
|
:class="isHl('services') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('services')"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="char-row"
|
||||||
|
:class="isHl('exchange') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('exchange')"
|
||||||
|
>
|
||||||
|
<span class="c-name">Heart Rate</span>
|
||||||
|
<span class="c-uuid">0x2A37</span>
|
||||||
|
<span class="c-val">72 bpm</span>
|
||||||
|
<span class="c-live"><span class="spin">⟳</span> live</span>
|
||||||
|
</div>
|
||||||
|
<div class="char-row">
|
||||||
|
<span class="c-name">Battery</span>
|
||||||
|
<span class="c-uuid">0x2A19</span>
|
||||||
|
<span class="c-val">88 %</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="wire"
|
||||||
|
:class="isHl('validate') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('validate')"
|
||||||
|
>
|
||||||
|
rx 0x2A37 ← <span class="bytes">06 48</span> ⟶ 72 bpm
|
||||||
|
<span class="checked">len-checked</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="btn ghost">disconnect</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- the central's own machinery -->
|
||||||
|
<div class="machinery">
|
||||||
|
<div class="statebar">
|
||||||
|
<span class="badge">permission = {{ frame.perm }}</span>
|
||||||
|
<span class="badge">adapter = {{ frame.adapter }}</span>
|
||||||
|
<span class="badge">scan = {{ frame.scan }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="frame.link" class="statebar">
|
||||||
|
<span class="badge link">link = {{ frame.link }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.specimen {
|
||||||
|
--ink: var(--color-base-content);
|
||||||
|
--ink-faint: #4f7099;
|
||||||
|
--ink-dim: #7ea6cd;
|
||||||
|
--amber: var(--color-accent);
|
||||||
|
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||||
|
--cyan: var(--color-secondary);
|
||||||
|
--green: #7fdca0;
|
||||||
|
--red: var(--color-error);
|
||||||
|
--paper: var(--color-base-100);
|
||||||
|
--line: rgba(200, 226, 255, 0.14);
|
||||||
|
--line-strong: rgba(200, 226, 255, 0.3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* device */
|
||||||
|
.device {
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
.dev-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.dev-bar .dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ink-faint);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.dev-title {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.screen {
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* scanning */
|
||||||
|
.statusrow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.chip {
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--ink-dim);
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.chip.on {
|
||||||
|
border-color: color-mix(in srgb, var(--green) 55%, transparent);
|
||||||
|
color: var(--green);
|
||||||
|
}
|
||||||
|
.listbox {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.list-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 7px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.scanning {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.spin {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
.dev-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.dev-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.d-name {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.d-addr {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.d-rssi {
|
||||||
|
color: var(--ink-dim);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.connect-btn {
|
||||||
|
border: 1px solid var(--cyan);
|
||||||
|
background: color-mix(in srgb, var(--cyan) 16%, transparent);
|
||||||
|
color: var(--cyan);
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* connected */
|
||||||
|
.conn-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.conn-head .d-name {
|
||||||
|
color: var(--ink);
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.conn-status {
|
||||||
|
color: var(--green);
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.charbox {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.char-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr auto auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.char-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
.c-name {
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
.c-uuid {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.c-val {
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.c-live {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.wire {
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-dim);
|
||||||
|
}
|
||||||
|
.wire .bytes {
|
||||||
|
color: var(--amber);
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
.wire .checked {
|
||||||
|
color: var(--green);
|
||||||
|
font-size: 10px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 8px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.btn.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* machinery */
|
||||||
|
.machinery {
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 14px auto 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.statebar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--cyan);
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.badge.link {
|
||||||
|
color: var(--green);
|
||||||
|
border-color: color-mix(in srgb, var(--green) 45%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* callouts — same convention as the other specimens */
|
||||||
|
.hl {
|
||||||
|
outline: 1.5px dashed var(--amber);
|
||||||
|
outline-offset: 3px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.hl[data-tag]::after {
|
||||||
|
content: attr(data-tag);
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
left: 8px;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border: 1px solid var(--amber);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
line-height: 1.5;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.hl.tag-b[data-tag]::after {
|
||||||
|
top: auto;
|
||||||
|
bottom: -10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -261,7 +261,8 @@ function barStyle(e: MonthEvent) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.specimen {
|
.specimen {
|
||||||
--ink: var(--color-base-content);
|
--ink: var(--color-base-content);
|
||||||
--ink-faint: #4f7099;
|
--ink-faint: #4f7099; /* decorative only: borders, stripes, fills */
|
||||||
|
--ink-dim: #7ea6cd; /* dim text — meets WCAG AA on navy paper */
|
||||||
--amber: var(--color-accent);
|
--amber: var(--color-accent);
|
||||||
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||||
--cyan: var(--color-secondary);
|
--cyan: var(--color-secondary);
|
||||||
@@ -272,7 +273,7 @@ function barStyle(e: MonthEvent) {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.note {
|
.note {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
@@ -311,7 +312,7 @@ function barStyle(e: MonthEvent) {
|
|||||||
.scope-btn {
|
.scope-btn {
|
||||||
padding: 4px 11px;
|
padding: 4px 11px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
border-left: 1px solid var(--line);
|
border-left: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
.scope-btn:first-child {
|
.scope-btn:first-child {
|
||||||
@@ -338,7 +339,7 @@ function barStyle(e: MonthEvent) {
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
.badge-note {
|
.badge-note {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* month grid */
|
/* month grid */
|
||||||
@@ -383,7 +384,7 @@ function barStyle(e: MonthEvent) {
|
|||||||
color: color-mix(in srgb, var(--ink) 70%, transparent);
|
color: color-mix(in srgb, var(--ink) 70%, transparent);
|
||||||
}
|
}
|
||||||
.daycell.adjacent .dnum {
|
.daycell.adjacent .dnum {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
/* event bars, positioned over the day cells of their week */
|
/* event bars, positioned over the day cells of their week */
|
||||||
@@ -465,7 +466,7 @@ function barStyle(e: MonthEvent) {
|
|||||||
.wk-hour {
|
.wk-hour {
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
border-right: 1px solid var(--line);
|
border-right: 1px solid var(--line);
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -263,7 +263,8 @@ const gridStyle = computed(() => ({ gridTemplateColumns: `repeat(${frame.value.c
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.specimen {
|
.specimen {
|
||||||
--ink: var(--color-base-content);
|
--ink: var(--color-base-content);
|
||||||
--ink-faint: #4f7099;
|
--ink-faint: #4f7099; /* decorative only: borders, stripes, fills */
|
||||||
|
--ink-dim: #7ea6cd; /* dim text — meets WCAG AA on navy paper */
|
||||||
--amber: var(--color-accent);
|
--amber: var(--color-accent);
|
||||||
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||||
--cyan: var(--color-secondary);
|
--cyan: var(--color-secondary);
|
||||||
@@ -274,7 +275,7 @@ const gridStyle = computed(() => ({ gridTemplateColumns: `repeat(${frame.value.c
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.note {
|
.note {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
@@ -288,7 +289,7 @@ const gridStyle = computed(() => ({ gridTemplateColumns: `repeat(${frame.value.c
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.badge-note {
|
.badge-note {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
}
|
}
|
||||||
.search {
|
.search {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -298,7 +299,7 @@ const gridStyle = computed(() => ({ gridTemplateColumns: `repeat(${frame.value.c
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
.ph {
|
.ph {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
}
|
}
|
||||||
.q {
|
.q {
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
@@ -452,7 +453,7 @@ const gridStyle = computed(() => ({ gridTemplateColumns: `repeat(${frame.value.c
|
|||||||
.empty-state {
|
.empty-state {
|
||||||
padding: 16px 12px;
|
padding: 16px 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
}
|
}
|
||||||
.empty-state .big {
|
.empty-state .big {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
|
|||||||
@@ -314,7 +314,8 @@ const frame = computed<Frame>(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.specimen {
|
.specimen {
|
||||||
--ink: var(--color-base-content);
|
--ink: var(--color-base-content);
|
||||||
--ink-faint: #4f7099;
|
--ink-faint: #4f7099; /* decorative only: borders, stripes, fills */
|
||||||
|
--ink-dim: #7ea6cd; /* dim text — meets WCAG AA on navy paper */
|
||||||
--amber: var(--color-accent);
|
--amber: var(--color-accent);
|
||||||
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||||
--cyan: var(--color-secondary);
|
--cyan: var(--color-secondary);
|
||||||
@@ -325,7 +326,7 @@ const frame = computed<Frame>(() => {
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
.note {
|
.note {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin: 0 0 10px;
|
margin: 0 0 10px;
|
||||||
@@ -339,7 +340,7 @@ const frame = computed<Frame>(() => {
|
|||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.badge-note {
|
.badge-note {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
}
|
}
|
||||||
.search {
|
.search {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -349,7 +350,7 @@ const frame = computed<Frame>(() => {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
.ph {
|
.ph {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
}
|
}
|
||||||
.q {
|
.q {
|
||||||
color: var(--amber);
|
color: var(--amber);
|
||||||
@@ -412,7 +413,7 @@ const frame = computed<Frame>(() => {
|
|||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
.txt .s {
|
.txt .s {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.chev {
|
.chev {
|
||||||
@@ -474,7 +475,7 @@ const frame = computed<Frame>(() => {
|
|||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
.swipehint {
|
.swipehint {
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
padding-right: 8px;
|
padding-right: 8px;
|
||||||
@@ -545,7 +546,7 @@ const frame = computed<Frame>(() => {
|
|||||||
.empty-state {
|
.empty-state {
|
||||||
padding: 20px 12px;
|
padding: 20px 12px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: var(--ink-faint);
|
color: var(--ink-dim);
|
||||||
}
|
}
|
||||||
.empty-state .big {
|
.empty-state .big {
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
|
|||||||
430
src/specimens/PasswordAuthSpecimen.vue
Normal file
430
src/specimens/PasswordAuthSpecimen.vue
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{ activeId: string }>()
|
||||||
|
|
||||||
|
// PasswordAuth is a Form feature, so the specimen is a real sign-in card shown in
|
||||||
|
// its lifecycle states — Idle / Submitting / Error / LockedOut / Authenticated /
|
||||||
|
// Refreshing — and each function highlights the region it owns.
|
||||||
|
|
||||||
|
type Screen = "form" | "authed"
|
||||||
|
type Region =
|
||||||
|
| "fields"
|
||||||
|
| "button"
|
||||||
|
| "verify"
|
||||||
|
| "attempts"
|
||||||
|
| "identity"
|
||||||
|
| "refresh"
|
||||||
|
| "error"
|
||||||
|
| "signout"
|
||||||
|
|
||||||
|
interface Frame {
|
||||||
|
screen: Screen
|
||||||
|
stateBadge: string
|
||||||
|
error?: string
|
||||||
|
locked?: string
|
||||||
|
submitting?: boolean
|
||||||
|
attempts?: number
|
||||||
|
identity?: boolean
|
||||||
|
refreshing?: boolean
|
||||||
|
hl?: Region
|
||||||
|
tag?: string
|
||||||
|
note?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = computed<Frame>(() => {
|
||||||
|
switch (props.activeId) {
|
||||||
|
case "capture":
|
||||||
|
return {
|
||||||
|
screen: "form",
|
||||||
|
stateBadge: "state = Idle",
|
||||||
|
hl: "fields",
|
||||||
|
tag: "◈ CAPTURE",
|
||||||
|
note: "Collect username + secret; mask the secret and mount both fields together for the OS credential manager. 8 ≤ len(secret) ≤ 128.",
|
||||||
|
}
|
||||||
|
case "submit":
|
||||||
|
return {
|
||||||
|
screen: "form",
|
||||||
|
stateBadge: "state = Idle → Submitting",
|
||||||
|
submitting: true,
|
||||||
|
hl: "button",
|
||||||
|
tag: "◈ SUBMIT",
|
||||||
|
note: "signIn(c) fires on a user gesture → Submitting. The secret crosses over TLS and is never logged or persisted.",
|
||||||
|
}
|
||||||
|
case "verify":
|
||||||
|
return {
|
||||||
|
screen: "form",
|
||||||
|
stateBadge: "state = Submitting",
|
||||||
|
submitting: true,
|
||||||
|
hl: "verify",
|
||||||
|
tag: "◈ VERIFY",
|
||||||
|
note: "Backend checks the credential → succeed(id) or a uniform fail(). No message distinguishes unknown-user from wrong-password.",
|
||||||
|
}
|
||||||
|
case "limit":
|
||||||
|
return {
|
||||||
|
screen: "form",
|
||||||
|
stateBadge: "state = Submitting → LockedOut",
|
||||||
|
locked: "Too many attempts — try again in 4:59",
|
||||||
|
attempts: 3,
|
||||||
|
hl: "attempts",
|
||||||
|
tag: "◈ LIMIT",
|
||||||
|
note: "Past the failed-attempt threshold → lockOut() → LockedOut. Never user-triggerable; the cooldown is shown.",
|
||||||
|
}
|
||||||
|
case "establish":
|
||||||
|
return {
|
||||||
|
screen: "authed",
|
||||||
|
stateBadge: "state = Authenticated",
|
||||||
|
identity: true,
|
||||||
|
hl: "identity",
|
||||||
|
tag: "◈ ESTABLISH",
|
||||||
|
note: "succeed(id) establishes the session; the consumer sees subject + realm. The plaintext secret never crosses the persistence boundary.",
|
||||||
|
}
|
||||||
|
case "refresh":
|
||||||
|
return {
|
||||||
|
screen: "authed",
|
||||||
|
stateBadge: "state = Authenticated → Refreshing",
|
||||||
|
identity: true,
|
||||||
|
refreshing: true,
|
||||||
|
hl: "refresh",
|
||||||
|
tag: "◈ REFRESH",
|
||||||
|
note: "Silent refresh renews the token within 200 ms, preserving subject + realm — before any in-flight call observes a 401.",
|
||||||
|
}
|
||||||
|
case "recover":
|
||||||
|
return {
|
||||||
|
screen: "form",
|
||||||
|
stateBadge: "state = Error",
|
||||||
|
error: "Incorrect email or password",
|
||||||
|
hl: "error",
|
||||||
|
tag: "◈ RECOVER",
|
||||||
|
note: "Error surfaces with a uniform message. reset() clears it; unlock() returns from LockedOut after cooldown — never revealing whether the username exists.",
|
||||||
|
}
|
||||||
|
case "terminate":
|
||||||
|
return {
|
||||||
|
screen: "authed",
|
||||||
|
stateBadge: "state = Authenticated → Idle",
|
||||||
|
identity: true,
|
||||||
|
hl: "signout",
|
||||||
|
tag: "◈ TERMINATE",
|
||||||
|
note: "signOut() clears the local session and invalidates the token at the backend. The secret never crosses the logout boundary.",
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
screen: "form",
|
||||||
|
stateBadge: "state = Idle",
|
||||||
|
note: "Submit a credential → verify against the backend → establish a bounded session. Failures stay uniform; lockout gates brute force; refresh renews silently.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isHl = (r: Region) => frame.value.hl === r
|
||||||
|
const tagOf = (r: Region) => (frame.value.hl === r ? frame.value.tag : undefined)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="specimen">
|
||||||
|
<p v-if="frame.note" class="note">{{ frame.note }}</p>
|
||||||
|
|
||||||
|
<div class="device">
|
||||||
|
<div class="dev-bar">
|
||||||
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||||
|
<span class="dev-title">MyApp</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- sign-in form -->
|
||||||
|
<div v-if="frame.screen === 'form'" class="screen">
|
||||||
|
<div
|
||||||
|
v-if="frame.error"
|
||||||
|
class="banner error"
|
||||||
|
:class="isHl('error') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('error')"
|
||||||
|
>
|
||||||
|
✗ {{ frame.error }}
|
||||||
|
</div>
|
||||||
|
<div v-if="frame.locked" class="banner locked">🔒 {{ frame.locked }}</div>
|
||||||
|
|
||||||
|
<div class="s-title">Sign in to MyApp</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fields"
|
||||||
|
:class="isHl('fields') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('fields')"
|
||||||
|
>
|
||||||
|
<div class="field">user@example.com</div>
|
||||||
|
<div class="field secret">•••••••••</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="frame.attempts"
|
||||||
|
class="attempts"
|
||||||
|
:class="isHl('attempts') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('attempts')"
|
||||||
|
>
|
||||||
|
<span v-for="n in 3" :key="n" class="pip" :class="{ used: n <= frame.attempts }">✕</span>
|
||||||
|
<span class="attempts-note">{{ frame.attempts }} / 3 attempts</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="btn"
|
||||||
|
:class="[{ busy: frame.submitting }, isHl('button') ? ['hl', 'tag-b'] : []]"
|
||||||
|
:data-tag="tagOf('button')"
|
||||||
|
>
|
||||||
|
<template v-if="frame.submitting"><span class="spin">⟳</span> Signing in…</template>
|
||||||
|
<template v-else>Sign in <span class="arrow">→</span></template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="frame.submitting"
|
||||||
|
class="verifyline"
|
||||||
|
:class="isHl('verify') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('verify')"
|
||||||
|
>
|
||||||
|
→ verifying credential with backend…
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- signed-in -->
|
||||||
|
<div v-else class="screen">
|
||||||
|
<div class="si-title">You're signed in</div>
|
||||||
|
<div
|
||||||
|
class="idchip"
|
||||||
|
:class="isHl('identity') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('identity')"
|
||||||
|
>
|
||||||
|
subject = u_84f · realm = myapp
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="frame.refreshing"
|
||||||
|
class="refresh-badge"
|
||||||
|
:class="isHl('refresh') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('refresh')"
|
||||||
|
>
|
||||||
|
<span class="spin">⟳</span> refreshing session…
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="btn ghost"
|
||||||
|
:class="isHl('signout') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('signout')"
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="machinery">
|
||||||
|
<div class="statebar">
|
||||||
|
<span class="badge">{{ frame.stateBadge }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.specimen {
|
||||||
|
--ink: var(--color-base-content);
|
||||||
|
--ink-faint: #4f7099;
|
||||||
|
--amber: var(--color-accent);
|
||||||
|
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||||
|
--cyan: var(--color-secondary);
|
||||||
|
--red: var(--color-error);
|
||||||
|
--paper: var(--color-base-100);
|
||||||
|
--line: rgba(200, 226, 255, 0.14);
|
||||||
|
--line-strong: rgba(200, 226, 255, 0.3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* device */
|
||||||
|
.device {
|
||||||
|
max-width: 340px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
.dev-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.dev-bar .dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ink-faint);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.dev-title {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.screen {
|
||||||
|
padding: 20px 22px 22px;
|
||||||
|
}
|
||||||
|
.s-title,
|
||||||
|
.si-title {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* fields */
|
||||||
|
.fields {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: color-mix(in srgb, var(--ink) 75%, transparent);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.field.secret {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* attempts */
|
||||||
|
.attempts {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.pip {
|
||||||
|
color: var(--line-strong);
|
||||||
|
}
|
||||||
|
.pip.used {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
.attempts-note {
|
||||||
|
margin-left: 4px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* button */
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--cyan);
|
||||||
|
background: color-mix(in srgb, var(--cyan) 16%, transparent);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 8px;
|
||||||
|
margin-top: 12px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.btn .arrow {
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.btn.busy {
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
.btn.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.verifyline {
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* banners */
|
||||||
|
.banner {
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px solid;
|
||||||
|
}
|
||||||
|
.banner.error {
|
||||||
|
border-color: var(--red);
|
||||||
|
background: color-mix(in srgb, var(--red) 14%, transparent);
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
.banner.locked {
|
||||||
|
border-color: var(--amber);
|
||||||
|
background: var(--amber-dim);
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* signed-in */
|
||||||
|
.idchip {
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.refresh-badge {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* machinery */
|
||||||
|
.machinery {
|
||||||
|
max-width: 340px;
|
||||||
|
margin: 14px auto 0;
|
||||||
|
}
|
||||||
|
.statebar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--cyan);
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* callouts — same convention as the other specimens */
|
||||||
|
.hl {
|
||||||
|
outline: 1.5px dashed var(--amber);
|
||||||
|
outline-offset: 3px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.hl[data-tag]::after {
|
||||||
|
content: attr(data-tag);
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
left: 8px;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border: 1px solid var(--amber);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
line-height: 1.5;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.hl.tag-b[data-tag]::after {
|
||||||
|
top: auto;
|
||||||
|
bottom: -10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
466
src/specimens/RememberMeSpecimen.vue
Normal file
466
src/specimens/RememberMeSpecimen.vue
Normal file
@@ -0,0 +1,466 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
const props = defineProps<{ activeId: string }>()
|
||||||
|
|
||||||
|
// RememberMe has almost no distinctive UI — only the opt-in checkbox at sign-in
|
||||||
|
// and the cold-start "welcoming you back" screen. So the specimen shows those two
|
||||||
|
// screens plus the underlying machinery the capability actually owns (the secure
|
||||||
|
// storage vault + the binding/host state), and each function highlights the
|
||||||
|
// region it drives — the same annotate-one-surface approach the Calendar uses.
|
||||||
|
|
||||||
|
type Screen = "signin" | "welcome" | "signedin"
|
||||||
|
type Region = "checkbox" | "button" | "host" | "token" | "vault" | "spinner" | "banner"
|
||||||
|
type Vault = "empty" | "minting" | "stored" | "cleared"
|
||||||
|
|
||||||
|
interface Frame {
|
||||||
|
screen: Screen
|
||||||
|
optIn: boolean
|
||||||
|
vault: Vault
|
||||||
|
stateBadge: string
|
||||||
|
hostBadge: string
|
||||||
|
hl?: Region
|
||||||
|
tag?: string
|
||||||
|
note?: string
|
||||||
|
banner?: { text: string; kind: "revoke" }
|
||||||
|
}
|
||||||
|
|
||||||
|
const frame = computed<Frame>(() => {
|
||||||
|
switch (props.activeId) {
|
||||||
|
case "capture":
|
||||||
|
return {
|
||||||
|
screen: "signin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "empty",
|
||||||
|
stateBadge: "state = NotPersisted",
|
||||||
|
hostBadge: "host.state = Idle",
|
||||||
|
hl: "checkbox",
|
||||||
|
tag: "◈ CAPTURE",
|
||||||
|
note: "optIn = True is captured at sign-in — persistence is opt-in, never default.",
|
||||||
|
}
|
||||||
|
case "trigger":
|
||||||
|
return {
|
||||||
|
screen: "signedin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "empty",
|
||||||
|
stateBadge: "state = NotPersisted → Persisting",
|
||||||
|
hostBadge: "host.state = Idle → Authenticated",
|
||||||
|
hl: "host",
|
||||||
|
tag: "◈ TRIGGER",
|
||||||
|
note: "Host reaches Authenticated with optIn = True → startPersist() → state ← Persisting.",
|
||||||
|
}
|
||||||
|
case "issue":
|
||||||
|
return {
|
||||||
|
screen: "signedin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "minting",
|
||||||
|
stateBadge: "state = Persisting",
|
||||||
|
hostBadge: "host.state = Authenticated",
|
||||||
|
hl: "token",
|
||||||
|
tag: "◈ ISSUE",
|
||||||
|
note: "Backend mints PersistentToken { subject = host.identity.subject, expiresAt = now + 30d }.",
|
||||||
|
}
|
||||||
|
case "store":
|
||||||
|
return {
|
||||||
|
screen: "signedin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "stored",
|
||||||
|
stateBadge: "state = Persisting → Persisted",
|
||||||
|
hostBadge: "host.state = Authenticated",
|
||||||
|
hl: "vault",
|
||||||
|
tag: "◈ STORE",
|
||||||
|
note: "tokenStored(t) writes the bytes to platform-secure storage → state ← Persisted. Never localStorage.",
|
||||||
|
}
|
||||||
|
case "revive":
|
||||||
|
return {
|
||||||
|
screen: "welcome",
|
||||||
|
optIn: true,
|
||||||
|
vault: "stored",
|
||||||
|
stateBadge: "state = Persisted",
|
||||||
|
hostBadge: "host.state = Idle → Refreshing",
|
||||||
|
hl: "spinner",
|
||||||
|
tag: "◈ REVIVE",
|
||||||
|
note: "Cold start with a Persisted token → host.refresh() → Refreshing. The token never writes Authenticated directly.",
|
||||||
|
}
|
||||||
|
case "revoke":
|
||||||
|
return {
|
||||||
|
screen: "signedin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "cleared",
|
||||||
|
stateBadge: "state = Persisted → Revoked",
|
||||||
|
hostBadge: "host.state = Authenticated → Idle",
|
||||||
|
hl: "banner",
|
||||||
|
tag: "◈ REVOKE",
|
||||||
|
banner: { text: "Session revoked — token invalidated", kind: "revoke" },
|
||||||
|
note: "signOut / backend signal / suspicious activity → revoke() clears the token atomically → Revoked.",
|
||||||
|
}
|
||||||
|
case "expire":
|
||||||
|
return {
|
||||||
|
screen: "signin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "cleared",
|
||||||
|
stateBadge: "state = Persisted → NotPersisted",
|
||||||
|
hostBadge: "host.state = Idle",
|
||||||
|
hl: "vault",
|
||||||
|
tag: "◈ EXPIRE",
|
||||||
|
note: "now ≥ expiresAt → expired() silently drops the token; the UI shows the normal Idle sign-in. TTL is absolute.",
|
||||||
|
}
|
||||||
|
case "clear":
|
||||||
|
return {
|
||||||
|
screen: "signin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "empty",
|
||||||
|
stateBadge: "state = Revoked → NotPersisted",
|
||||||
|
hostBadge: "host.state = Idle",
|
||||||
|
hl: "vault",
|
||||||
|
tag: "◈ CLEAR",
|
||||||
|
note: "Consumer acknowledged the revocation → clear() → NotPersisted; ready to persist again on the next opted-in sign-in.",
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
screen: "signin",
|
||||||
|
optIn: true,
|
||||||
|
vault: "empty",
|
||||||
|
stateBadge: "state = NotPersisted",
|
||||||
|
hostBadge: "host.state = Idle",
|
||||||
|
note: "The persistence lifecycle: opt in at sign-in → mint + store a token → revive the host on cold start. It only feeds host.refresh() — it never authenticates directly.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const isHl = (r: Region) => frame.value.hl === r
|
||||||
|
const tagOf = (r: Region) => (frame.value.hl === r ? frame.value.tag : undefined)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="specimen">
|
||||||
|
<p v-if="frame.note" class="note">{{ frame.note }}</p>
|
||||||
|
|
||||||
|
<!-- the device the user sees -->
|
||||||
|
<div class="device">
|
||||||
|
<div class="dev-bar">
|
||||||
|
<span class="dot" /><span class="dot" /><span class="dot" />
|
||||||
|
<span class="dev-title">MyApp</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="screen">
|
||||||
|
<div
|
||||||
|
v-if="frame.banner"
|
||||||
|
class="banner"
|
||||||
|
:class="[frame.banner.kind, isHl('banner') ? ['hl', 'tag-b'] : []]"
|
||||||
|
:data-tag="tagOf('banner')"
|
||||||
|
>
|
||||||
|
⚠ {{ frame.banner.text }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- sign-in (opt-in) -->
|
||||||
|
<template v-if="frame.screen === 'signin'">
|
||||||
|
<div class="s-title">Sign in to MyApp</div>
|
||||||
|
<div class="field">user@example.com</div>
|
||||||
|
<div class="field secret">•••••••••</div>
|
||||||
|
<div
|
||||||
|
class="optrow"
|
||||||
|
:class="isHl('checkbox') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('checkbox')"
|
||||||
|
>
|
||||||
|
<span class="box">{{ frame.optIn ? "☑" : "☐" }}</span>
|
||||||
|
<span>Keep me signed in on this device</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="btn"
|
||||||
|
:class="isHl('button') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('button')"
|
||||||
|
>
|
||||||
|
Sign in <span class="arrow">→</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- cold-start revival -->
|
||||||
|
<template v-else-if="frame.screen === 'welcome'">
|
||||||
|
<div class="welcome">
|
||||||
|
<div class="w-title">Welcoming you back</div>
|
||||||
|
<div
|
||||||
|
class="spinner"
|
||||||
|
:class="isHl('spinner') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('spinner')"
|
||||||
|
>
|
||||||
|
<span class="spin">⟳</span> …
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- signed in -->
|
||||||
|
<template v-else>
|
||||||
|
<div class="si-title">You're signed in</div>
|
||||||
|
<div class="idchip">u_84f · user@example.com</div>
|
||||||
|
<div class="btn ghost">Sign out</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- the machinery the capability owns -->
|
||||||
|
<div class="machinery">
|
||||||
|
<div
|
||||||
|
class="vault"
|
||||||
|
:class="isHl('vault') || isHl('token') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('vault') ?? tagOf('token')"
|
||||||
|
>
|
||||||
|
<div class="v-label">🔒 Secure storage · Keychain / HttpOnly</div>
|
||||||
|
<div class="v-body">
|
||||||
|
<span v-if="frame.vault === 'empty'" class="v-empty">— no token —</span>
|
||||||
|
<span v-else-if="frame.vault === 'minting'" class="tok minting">⬡ token · minting…</span>
|
||||||
|
<span v-else-if="frame.vault === 'stored'" class="tok">⬡ sub=u_84f · exp +30d</span>
|
||||||
|
<span v-else class="v-cleared">✗ token cleared</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="statebar">
|
||||||
|
<span class="badge">{{ frame.stateBadge }}</span>
|
||||||
|
<span
|
||||||
|
class="badge host"
|
||||||
|
:class="isHl('host') ? ['hl', 'tag-b'] : []"
|
||||||
|
:data-tag="tagOf('host')"
|
||||||
|
>
|
||||||
|
{{ frame.hostBadge }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.specimen {
|
||||||
|
--ink: var(--color-base-content);
|
||||||
|
--ink-faint: #4f7099;
|
||||||
|
--amber: var(--color-accent);
|
||||||
|
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||||
|
--cyan: var(--color-secondary);
|
||||||
|
--green: #7fdca0;
|
||||||
|
--red: var(--color-error);
|
||||||
|
--paper: var(--color-base-100);
|
||||||
|
--line: rgba(200, 226, 255, 0.14);
|
||||||
|
--line-strong: rgba(200, 226, 255, 0.3);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.note {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: italic;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* device */
|
||||||
|
.device {
|
||||||
|
max-width: 340px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
background: var(--paper);
|
||||||
|
}
|
||||||
|
.dev-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
.dev-bar .dot {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--ink-faint);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.dev-title {
|
||||||
|
margin-left: 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.screen {
|
||||||
|
padding: 20px 22px 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* sign-in */
|
||||||
|
.s-title {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink);
|
||||||
|
margin-bottom: 14px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.field {
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
padding: 8px 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
color: color-mix(in srgb, var(--ink) 75%, transparent);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.field.secret {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
letter-spacing: 0.15em;
|
||||||
|
}
|
||||||
|
.optrow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: color-mix(in srgb, var(--ink) 85%, transparent);
|
||||||
|
}
|
||||||
|
.optrow .box {
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid var(--cyan);
|
||||||
|
background: color-mix(in srgb, var(--cyan) 16%, transparent);
|
||||||
|
color: var(--ink);
|
||||||
|
padding: 8px;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.btn .arrow {
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
.btn.ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--line-strong);
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* welcome / revive */
|
||||||
|
.welcome {
|
||||||
|
text-align: center;
|
||||||
|
padding: 22px 0 26px;
|
||||||
|
}
|
||||||
|
.w-title {
|
||||||
|
color: var(--ink);
|
||||||
|
margin-bottom: 18px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.spinner {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.spinner .spin {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* signed in */
|
||||||
|
.si-title {
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ink);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.idchip {
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
padding: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--cyan);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* banner */
|
||||||
|
.banner {
|
||||||
|
border: 1px solid var(--red);
|
||||||
|
background: color-mix(in srgb, var(--red) 14%, transparent);
|
||||||
|
color: var(--red);
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 11px;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* machinery */
|
||||||
|
.machinery {
|
||||||
|
max-width: 340px;
|
||||||
|
margin: 14px auto 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.vault {
|
||||||
|
border: 1px dashed var(--line-strong);
|
||||||
|
padding: 8px 10px;
|
||||||
|
}
|
||||||
|
.v-label {
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-faint);
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.v-body {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.v-empty {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
.tok {
|
||||||
|
display: inline-block;
|
||||||
|
border: 1px solid var(--green);
|
||||||
|
background: color-mix(in srgb, var(--green) 14%, transparent);
|
||||||
|
color: var(--green);
|
||||||
|
padding: 2px 8px;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.tok.minting {
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: var(--amber);
|
||||||
|
background: var(--amber-dim);
|
||||||
|
color: var(--amber);
|
||||||
|
}
|
||||||
|
.v-cleared {
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
|
.statebar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
border: 1px solid var(--line-strong);
|
||||||
|
color: var(--cyan);
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
.badge.host {
|
||||||
|
color: var(--ink-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* callouts — same convention as the other specimens */
|
||||||
|
.hl {
|
||||||
|
outline: 1.5px dashed var(--amber);
|
||||||
|
outline-offset: 3px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.hl[data-tag]::after {
|
||||||
|
content: attr(data-tag);
|
||||||
|
position: absolute;
|
||||||
|
top: -10px;
|
||||||
|
left: 8px;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--amber);
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border: 1px solid var(--amber);
|
||||||
|
white-space: nowrap;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
line-height: 1.5;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
.hl.tag-b[data-tag]::after {
|
||||||
|
top: auto;
|
||||||
|
bottom: -10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
@import to precede all other rules. If it comes second, the production build
|
@import to precede all other rules. If it comes second, the production build
|
||||||
warns ("@import must precede all rules…") and browsers silently DROP the
|
warns ("@import must precede all rules…") and browsers silently DROP the
|
||||||
font import, so the custom font never loads. */
|
font import, so the custom font never loads. */
|
||||||
@import url("https://api.fonts.coollabs.io/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap");
|
@import url("https://api.fonts.coollabs.io/css2?family=Iosevka+Charon+Mono:wght@400;500;600;700&display=swap");
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
@plugin "daisyui";
|
@plugin "daisyui";
|
||||||
|
|
||||||
@@ -50,8 +50,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
--font-sans: "Iosevka Charon Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||||
--font-mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
--font-mono: "Iosevka Charon Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Blueprint grid paper, applied globally. */
|
/* Blueprint grid paper, applied globally. */
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ import { BLUEPRINTS } from "@/data/blueprints"
|
|||||||
.c-meta {
|
.c-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
color: #4f7099;
|
color: #7ea6cd;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.c-open {
|
.c-open {
|
||||||
|
|||||||
Reference in New Issue
Block a user