Compare commits

..

12 Commits

Author SHA1 Message Date
Julien Calixte
792b4f1b17 feat: add BLE blueprint illustration
A central with two lifecycles: an adapter/scan machine (doubly gated by
Permission + a powered-on radio) and a per-peripheral link machine, with
a bespoke specimen mocking the scanner and connected-device screens.
2026-07-02 23:52:13 +02:00
Julien Calixte
903e5465d5 refactor(state-machine): support multiple lifecycles per blueprint
Blueprint.stateMachine becomes stateMachines[], the viewer renders each
machine in its own panel, and StateMachine draws self-loops (a === b) for
recurring transitions that don't change state. Existing blueprints move
to the array form unchanged.
2026-07-02 23:52:03 +02:00
Julien Calixte
7a60e57f46 style(skills): align SKILL.md table separators 2026-07-02 23:37:16 +02:00
Julien Calixte
1ba85e3235 feat: cross-link related blueprints on the detail page
The title-block "Extends" now links to the parent blueprint when it is
illustrated, and a new "Related blueprints" section lists hosts, siblings,
and extensions. References resolve to live links via the registry and fall
back to plain text otherwise, so PasswordAuth and RememberMe now interlink.
2026-07-02 23:36:15 +02:00
Julien Calixte
4d2db834ac feat: add PasswordAuth blueprint illustration
The primary-auth feature RememberMe composes onto. Transcribed from the
ontology password-auth README + .als: the full AuthState lifecycle
(Idle / Submitting / Authenticated / Refreshing / LockedOut / Error), its
8 functions, and a sign-in specimen shown across those states. Adds the
auth transition kinds and wraps the state-machine legend to a second row
so this richer machine's kinds fit without clipping.
2026-07-02 23:36:08 +02:00
Julien Calixte
5dff87a90d chore(skills): add illustrate-blueprint skill
Guided workflow for turning a blueprint-ontology blueprint into an
interactive illustration: read the source contract, author the typed data
module and bespoke specimen, register the pair, and verify the build.
2026-07-02 23:29:14 +02:00
Julien Calixte
1b446c54df feat(theme): use Iosevka Charon Mono as the app typeface 2026-07-02 23:27:15 +02:00
Julien Calixte
062826e126 feat: add RememberMe blueprint illustration
First capability blueprint in the gallery. Transcribed from the ontology
remember-me README + .als: the persistence lifecycle
(NotPersisted -> Persisting -> Persisted -> Revoked), its 8 functions,
and a specimen annotating the opt-in sign-in and cold-start revive
screens plus the secure-storage vault it owns.
2026-07-02 23:18:23 +02:00
Julien Calixte
f6b184406d feat(state-machine): let blueprints define init, caption, and kinds
RememberMe is the first blueprint whose lifecycle is not the composed
LoadState: it starts at NotPersisted (not `loading`), has its own panel
caption, and its own transition kinds. Make the init node, the panel
caption/field, and the SmKind palette data-driven, with defaults that
leave List/Grid/Calendar unchanged.
2026-07-02 23:18:14 +02:00
Julien Calixte
9c8ba9a752 fix(a11y): raise faint ink text to meet WCAG AA contrast
Dim text tokens (base-content/40-50, #4f7099) sat at 3.0-4.1:1 on the
navy paper, below the 4.5:1 minimum. Brighten text uses to #7ea6cd
(~6:1) and split the overloaded --ink-faint into --ink-dim so borders,
stripes and diagram edges keep their intended faintness.
2026-07-02 23:05:53 +02:00
Julien Calixte
6264c9959b feat: add Calendar blueprint illustration
Transcribe the calendar blueprint from blueprint-ontology (README +
calendar.als): Navigate, Render, Load, Scope functions, its own
Idle/Loading/Refreshing/Error lifecycle, and a bespoke month/week
specimen. Composition follows the formal model (Async + Pullable).
2026-07-02 18:55:42 +02:00
Julien Calixte
8e7a903fe4 refactor(state-machine): derive legend from the machine's edges
Add navigate/changeScope transition kinds and build the legend from the
transitions a machine actually has, so each blueprint shows only its own
kinds — needed for Calendar, whose lifecycle has no loadMore().
2026-07-02 18:55:34 +02:00
22 changed files with 3678 additions and 60 deletions

View 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.

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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>

View File

@@ -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 {

View File

@@ -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)
@@ -71,20 +91,87 @@ const edges = computed(() =>
}), }),
) )
const legendItems = (() => { // Legend is derived from the transitions the machine actually has, so each
const items: [string, SmKind][] = [ // blueprint shows exactly its own transition kinds — no phantom loadMore() on
["succeed()", "succeed"], // Calendar, no missing navigate()/changeScope().
["fail()", "fail"], const KIND_LABELS: Record<SmKind, string> = {
["refresh()", "refresh"], succeed: "succeed()",
["loadMore()", "loadMore"], fail: "fail()",
] refresh: "refresh()",
loadMore: "loadMore()",
navigate: "navigate(δ)",
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[] = [
"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 present = new Set(props.machine.edges.map((e) => e.kind))
const maxX = 880
let lxp = 70 let lxp = 70
return items.map(([t, k]) => { let row = 0
const item = { t, color: SM_COLORS[k], x1: lxp, x2: lxp + 18, tx: lxp + 24, y: 8, ty: 12 } return KIND_ORDER.filter((k) => present.has(k)).map((k) => {
lxp += 24 + t.length * 6.6 + 26 const t = KIND_LABELS[k]
const width = 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
}) })
})() })
</script> </script>
<template> <template>

388
src/data/bleBlueprint.ts Normal file
View 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 ~23 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" },
],
}

View File

@@ -58,7 +58,37 @@ export interface SmNode {
label: string label: string
} }
export type SmKind = "succeed" | "fail" | "refresh" | "loadMore" 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> = {
@@ -78,12 +115,46 @@ export const SM_COLORS: Record<SmKind | "init", string> = {
fail: "#ff8f8f", fail: "#ff8f8f",
refresh: "#ffb84d", refresh: "#ffb84d",
loadMore: "#8fd0ff", loadMore: "#8fd0ff",
navigate: "#b79cff",
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
@@ -95,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[]
} }

View File

@@ -6,8 +6,16 @@ import type { Component } from "vue"
import type { Blueprint } from "./blueprint" 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 { 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 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
@@ -17,6 +25,10 @@ export interface RegistryEntry {
export const REGISTRY: Record<string, RegistryEntry> = { 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 },
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)
@@ -24,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
}

View File

@@ -0,0 +1,208 @@
// The Calendar blueprint, as data. Lifted from the blueprint-ontology `calendar`
// README + calendar.als. Calendar has its OWN lifecycle (no LoadingMore; adds
// navigate/changeScope transitions), so it defines its own state machine rather
// than sharing LOADSTATE_MACHINE with List/Grid. `visible` is a viewport
// projection Calendar owns — not Filterable — so only Async + Pullable are
// represented as composed capabilities, matching what the formal model models.
import type { Blueprint, StateMachine } from "./blueprint"
// Idle ⇄ Loading (navigate/changeScope out, succeed in), plus refresh via
// Refreshing and the two failure edges. Node coordinates are hand-placed.
const CALENDAR_MACHINE: StateMachine = {
nodes: [
{ id: "loading", x: 70, y: 150, w: 110, h: 40, label: "Loading" },
{ id: "idle", x: 300, y: 150, w: 96, h: 40, label: "Idle" },
{ id: "refreshing", x: 540, y: 56, w: 130, h: 40, label: "Refreshing" },
{ id: "error", x: 770, y: 150, w: 96, h: 40, label: "Error" },
],
edges: [
{ from: "loading", to: "idle", label: "succeed()", kind: "succeed", off: 24 },
{ from: "loading", to: "error", label: "fail()", kind: "fail", off: 150 },
{ from: "idle", to: "loading", label: "navigate(δ)", kind: "navigate", off: 20 },
{ from: "idle", to: "loading", label: "changeScope(s)", kind: "changeScope", off: 56 },
{ from: "idle", to: "refreshing", label: "refresh()", kind: "refresh", off: -16 },
{ from: "refreshing", to: "idle", label: "succeed()", kind: "succeed", off: -16 },
{ from: "refreshing", to: "error", label: "fail()", kind: "fail", off: 14 },
{ from: "error", to: "refreshing", label: "refresh()", kind: "refresh", off: 70 },
],
}
export const calendar: Blueprint = {
slug: "calendar",
name: "Calendar<Event>",
tagline: "A navigable temporal grid of time-bounded events within a scoped viewport.",
role: "feature",
extendsName: "Set<Event>",
composesCount: 2,
sig: {
header: "Calendar<Event> + Async + Pullable",
fields: [
{
name: "members",
type: "set CalendarEvent",
note: "loaded events for the current viewport",
},
{ name: "scope", type: "Scope", note: "viewport granularity" },
{ name: "focusDate", type: "Date", note: "anchor date of the viewport" },
{ name: "loadState", type: "LoadState", note: "Async · Pullable" },
{
name: "visible",
type: "set CalendarEvent",
note: "derived: events anchored in the viewport",
},
],
types: [
"Scope = Day | Week | Month",
"LoadState = Idle | Loading | Refreshing | Error",
"CalendarEvent extends Item { startAt, endAt : DateTime } -- endAt ≥ startAt",
"viewportStart = startOf(focusDate, scope) viewportEnd = endOf(focusDate, scope)",
"visible = { e ∈ members | e.startAt ∈ [viewportStart, viewportEnd) } (derived)",
],
},
functions: [
{
id: "navigate",
name: "Navigate",
fig: "01",
caps: ["Calendar"],
verb: "Advance or retreat focusDate by one scope unit; trigger a fetch for the new viewport range.",
state: [
["focusDate", "Date"],
["loadState", "LoadState"],
],
behaviors: [
{
sig: "navigate(delta)",
pre: "loadState = Idle",
eff: "focusDate ← focusDate ± 1 scope unit, loadState ← Loading",
},
],
invariants: ["scope ∈ {Day, Week, Month} — viewport always has a defined granularity"],
failures: [
"navigate during load — navigate() called while loadState = Loading; pre-condition fails, the call must be ignored or queued, never double-fetched",
],
perf: [
"new viewport renders within 300 ms — pre-fetch adjacent viewports while loadState = Idle",
],
notes: [
"navigate(+1) advances by one scope unit (e.g. next week when scope = Week); navigate(-1) retreats.",
],
},
{
id: "render",
name: "Render",
fig: "02",
caps: ["Calendar", "Async"],
verb: "Position events in their day / time-slot cells; clip multi-day events at the viewport boundary.",
state: [
["members", "set CalendarEvent"],
["visible", "set CalendarEvent"],
],
behaviors: [],
invariants: [
"#1 visible ⊆ members — visible never exceeds loaded events",
"#2 ∀ e ∈ visible : e.startAt ∈ [viewportStart, viewportEnd) — only events anchored in the viewport are shown",
"#4 ∀ e ∈ members : e.endAt ≥ e.startAt — no negative-duration events",
],
failures: [
"multi-day overflow — event.endAt > viewportEnd; the event must be clipped at viewportEnd in the display, the underlying data not truncated",
"timezone mismatch — server returns UTC, client displays local time; events land in the wrong day or hour, so every DateTime must carry a timezone",
],
perf: [
"60 fps minimum for time-axis scrolling in Day and Week scopes (hour-slot rows must be virtualized)",
],
notes: ["visible is always derived — it is never set directly."],
},
{
id: "load",
name: "Load",
fig: "03",
caps: ["Async", "Pullable"],
verb: "Fetch events for the current viewport range; reload on every navigate and changeScope; support refresh.",
state: [
["loadState", "LoadState"],
["members", "set CalendarEvent"],
],
behaviors: [
{
sig: "refresh()",
pre: "loadState = Idle",
eff: "loadState ← Refreshing; re-fetch events for the current viewport range",
},
{
sig: "succeed(ev)",
pre: "loadState ∈ {Loading, Refreshing}",
eff: "members ← ev, loadState ← Idle",
},
{ sig: "fail()", pre: "loadState ∈ {Loading, Refreshing}", eff: "loadState ← Error" },
],
invariants: [
"#6 members = ∅ is a valid state (empty calendar, not an error)",
"#3 events are identity-unique within members",
],
failures: [
"empty vs error conflation — server returns 0 events with a 4xx status; the empty viewport is treated as an error and the user sees the wrong UI",
"stale events after reload — succeed(ev) does not replace members atomically; old and new events coexist and identity-uniqueness is violated",
],
perf: [
"pre-fetch adjacent viewports while loadState = Idle so navigate() renders within 300 ms",
],
notes: [],
},
{
id: "scope",
name: "Scope",
fig: "04",
caps: ["Calendar"],
verb: "Switch between Day, Week, and Month granularities without losing focusDate.",
state: [
["scope", "Scope"],
["loadState", "LoadState"],
],
behaviors: [
{
sig: "changeScope(s)",
pre: "loadState = Idle, s ≠ scope",
eff: "scope ← s, loadState ← Loading; fetch events for the new [viewportStart, viewportEnd)",
},
],
invariants: [
"#5 scope ∈ {Day, Week, Month} — viewport always has a defined granularity",
"changeScope preserves focusDate",
],
failures: [],
perf: ["layout recalculates within one frame (≤ 16 ms) for already-loaded events"],
notes: [],
},
],
coreInvariants: [
"visible ⊆ members — only loaded events can be shown",
"∀ e ∈ visible : e.startAt ∈ [viewportStart, viewportEnd) — only viewport-anchored events are shown",
"∀ e ∈ members : e.endAt ≥ e.startAt — no negative-duration events",
"events are identity-unique within members",
"members = ∅ is a valid state (empty calendar, not an error)",
],
composition: {
caps: [
{ id: "core", label: "Calendar : Set", sub: "members, scope, focusDate", core: true },
{ id: "async", label: "Async", sub: "loadState" },
{ id: "pullable", label: "Pullable", sub: "refresh" },
],
funcs: ["Navigate", "Render", "Load", "Scope"],
links: [
["core", "Navigate"],
["core", "Render"],
["core", "Scope"],
["async", "Render"],
["async", "Load"],
["pullable", "Load"],
],
},
stateMachines: [CALENDAR_MACHINE],
}

View File

@@ -202,5 +202,5 @@ export const grid: Blueprint = {
], ],
}, },
stateMachine: LOADSTATE_MACHINE, stateMachines: [LOADSTATE_MACHINE],
} }

View File

@@ -226,5 +226,5 @@ export const list: Blueprint = {
], ],
}, },
stateMachine: LOADSTATE_MACHINE, stateMachines: [LOADSTATE_MACHINE],
} }

View 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" },
],
}

View 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" },
],
}

View 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>

View File

@@ -0,0 +1,527 @@
<script setup lang="ts">
import { computed } from "vue"
const props = defineProps<{ activeId: string }>()
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
// A month event bar, placed by (week row, start weekday, day span).
interface MonthEvent {
key: string
label: string
week: number
start: number // 0 = Mon … 6 = Sun
span: number // days
clip?: boolean // clipped at the viewport boundary
skeleton?: boolean
hl?: boolean
hlTag?: string
}
// A week-view event block, placed on the time axis.
interface WeekEvent {
key: string
label: string
day: number // 0 = Mon … 4 = Fri
hour: number // 0-based slot from the first row
hl?: boolean
}
// Shared header/chrome fields — both scopes have a navigable title + scope toggle.
interface BaseFrame {
title: string
scopeActive: "Day" | "Week" | "Month"
navHl?: boolean
scopeHl?: boolean
note?: string
}
interface MonthFrame extends BaseFrame {
scope: "month"
gridHl?: boolean
gridTag?: string
badge?: string
badgeNote?: string
events: MonthEvent[]
}
interface WeekFrame extends BaseFrame {
scope: "week"
hours: string[]
events: WeekEvent[]
}
type Frame = MonthFrame | WeekFrame
// April 2026, laid out as in the ontology README (Mon-first weeks).
// Leading 30 (Mar) and trailing 1-4 (May) are adjacent-month days.
const WEEKS: { day: number; adjacent?: boolean }[][] = [
[30, 1, 2, 3, 4, 5, 6].map((d, i) => ({ day: d, adjacent: i === 0 })),
[7, 8, 9, 10, 11, 12, 13].map((d) => ({ day: d })),
[14, 15, 16, 17, 18, 19, 20].map((d) => ({ day: d })),
[21, 22, 23, 24, 25, 26, 27].map((d) => ({ day: d })),
[28, 29, 30, 1, 2, 3, 4].map((d, i) => ({ day: d, adjacent: i >= 3 })),
]
// Events used across the month frames.
const meeting = (extra: Partial<MonthEvent> = {}): MonthEvent => ({
key: "meeting",
label: "Meeting",
week: 0,
start: 2,
span: 2,
...extra,
})
const sprint = (extra: Partial<MonthEvent> = {}): MonthEvent => ({
key: "sprint",
label: "Sprint review",
week: 1,
start: 0,
span: 1,
...extra,
})
const review = (extra: Partial<MonthEvent> = {}): MonthEvent => ({
key: "review",
label: "Design review",
week: 3,
start: 1,
span: 3,
...extra,
})
const frame = computed<Frame>(() => {
switch (props.activeId) {
case "full":
return {
scope: "month",
title: "April 2026",
scopeActive: "Month",
navHl: true,
scopeHl: true,
gridHl: true,
gridTag: "◈ RENDER",
events: [meeting(), sprint(), review()],
}
case "navigate":
return {
scope: "month",
title: "April 2026",
scopeActive: "Month",
navHl: true,
badge: "loadState = Loading",
badgeNote: "navigate(+1) → fetching May 2026…",
note: "navigate(+1) advances focusDate by one scope unit (a month here) → loadState ← Loading, viewport re-fetched.",
events: [meeting(), sprint(), review()],
}
case "render":
return {
scope: "month",
title: "April 2026",
scopeActive: "Month",
gridHl: true,
gridTag: "◈ RENDER",
note: "visible = { e ∈ members | e.startAt ∈ [viewportStart, viewportEnd) } — the trailing event is clipped at viewportEnd; its data is not truncated.",
events: [
meeting({ hl: true, hlTag: "spans WedThu" }),
sprint(),
review(),
{ key: "overflow", label: "Offsite ▸", week: 4, start: 4, span: 3, clip: true },
],
}
case "load":
return {
scope: "month",
title: "April 2026",
scopeActive: "Month",
badge: "loadState = Refreshing",
badgeNote: "re-fetching viewport range…",
note: "Skeleton bars mirror real event bars so the transition to Idle causes no layout shift. members = ∅ is a valid result, not an error.",
events: [
meeting({ skeleton: true }),
sprint({ skeleton: true }),
review({ skeleton: true }),
],
}
case "scope":
return {
scope: "week",
title: "Week of Apr 21",
scopeActive: "Week",
scopeHl: true,
note: "changeScope(Week) keeps focusDate (Apr 21) and re-fetches the new [viewportStart, viewportEnd) — layout switches to a time axis.",
hours: ["09:00", "10:00", "11:00", "12:00"],
events: [
{ key: "standup", label: "Stand-up", day: 0, hour: 0 },
{ key: "oneone", label: "1:1", day: 4, hour: 0 },
{ key: "design", label: "Design review", day: 2, hour: 2, hl: true },
],
}
default:
return {
scope: "month",
title: "April 2026",
scopeActive: "Month",
events: [meeting(), sprint(), review()],
}
}
})
const isMonth = computed(() => frame.value.scope === "month")
function barStyle(e: MonthEvent) {
return {
left: `calc(${(e.start / 7) * 100}% + 3px)`,
width: `calc(${(e.span / 7) * 100}% - 6px)`,
}
}
</script>
<template>
<div class="specimen">
<!-- header: scope toggle + navigation -->
<div class="cal-head">
<div
class="nav"
:class="frame.navHl ? ['hl', 'tag-b'] : []"
:data-tag="frame.navHl ? ' NAVIGATE' : undefined"
>
<span class="arrow"></span>
<span class="title">{{ frame.title }}</span>
<span class="arrow"></span>
</div>
<div
class="scopes"
:class="frame.scopeHl ? ['hl', 'tag-b'] : []"
:data-tag="frame.scopeHl ? ' SCOPE' : undefined"
>
<span
v-for="s in ['Day', 'Week', 'Month']"
:key="s"
class="scope-btn"
:class="{ on: frame.scopeActive === s }"
>{{ s }}</span
>
</div>
</div>
<div v-if="frame.scope === 'month' && frame.badge" class="spec-toolbar">
<span class="badge">{{ frame.badge }}</span>
<span v-if="frame.badgeNote" class="badge-note">{{ frame.badgeNote }}</span>
</div>
<p v-if="frame.note" class="note">{{ frame.note }}</p>
<!-- MONTH grid -->
<div
v-if="isMonth && frame.scope === 'month'"
class="monthbox"
:class="frame.gridHl ? ['hl', 'tag-b'] : []"
:data-tag="frame.gridTag"
>
<div class="wd-row">
<div v-for="d in WEEKDAYS" :key="d" class="wd">{{ d }}</div>
</div>
<div v-for="(week, wi) in WEEKS" :key="wi" class="week">
<div class="daycells">
<div v-for="(c, ci) in week" :key="ci" class="daycell" :class="{ adjacent: c.adjacent }">
<span class="dnum">{{ c.day }}</span>
</div>
</div>
<div
v-for="e in frame.events.filter((x) => x.week === wi)"
:key="e.key"
class="ev"
:class="{ skeleton: e.skeleton, clip: e.clip, hl: e.hl }"
:style="barStyle(e)"
:data-tag="e.hlTag"
>
<span v-if="!e.skeleton" class="ev-label">{{ e.label }}</span>
</div>
</div>
</div>
<!-- WEEK time-axis grid -->
<div v-else-if="frame.scope === 'week'" class="weekbox">
<div class="wk-head">
<div class="wk-corner" />
<div v-for="d in WEEKDAYS.slice(0, 5)" :key="d" class="wk-day">{{ d }}</div>
</div>
<div v-for="(h, hi) in frame.hours" :key="h" class="wk-row">
<div class="wk-hour">{{ h }}</div>
<div v-for="di in 5" :key="di" class="wk-cell">
<div
v-for="e in frame.events.filter((x) => x.day === di - 1 && x.hour === hi)"
:key="e.key"
class="wk-ev"
:class="{ hl: e.hl }"
>
{{ e.label }}
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.specimen {
--ink: var(--color-base-content);
--ink-faint: #4f7099; /* decorative only: borders, stripes, fills */
--ink-dim: #7ea6cd; /* dim text — meets WCAG AA on navy paper */
--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-dim);
font-size: 11px;
font-style: italic;
margin: 0 0 10px;
}
/* header */
.cal-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.nav {
display: flex;
align-items: center;
gap: 12px;
border: 1px solid var(--line);
background: var(--paper);
padding: 5px 12px;
}
.nav .arrow {
color: var(--cyan);
font-size: 16px;
line-height: 1;
}
.nav .title {
color: var(--ink);
letter-spacing: 0.04em;
}
.scopes {
display: flex;
border: 1px solid var(--line);
}
.scope-btn {
padding: 4px 11px;
font-size: 11px;
color: var(--ink-dim);
border-left: 1px solid var(--line);
}
.scope-btn:first-child {
border-left: none;
}
.scope-btn.on {
color: var(--amber);
background: var(--amber-dim);
}
/* toolbar */
.spec-toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
font-size: 12px;
}
.badge {
border: 1px solid var(--line-strong);
color: var(--cyan);
padding: 1px 7px;
font-size: 10px;
letter-spacing: 0.08em;
}
.badge-note {
color: var(--ink-dim);
}
/* month grid */
.monthbox {
border: 1px solid var(--line);
background: var(--paper);
}
.wd-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-bottom: 1px solid var(--line);
}
.wd {
padding: 5px 6px;
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: color-mix(in srgb, var(--ink) 55%, transparent);
text-align: right;
}
.week {
position: relative;
border-bottom: 1px solid var(--line);
}
.week:last-child {
border-bottom: none;
}
.daycells {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
.daycell {
min-height: 46px;
border-left: 1px dashed var(--line);
padding: 4px 6px;
}
.daycell:first-child {
border-left: none;
}
.dnum {
font-size: 11px;
color: color-mix(in srgb, var(--ink) 70%, transparent);
}
.daycell.adjacent .dnum {
color: var(--ink-dim);
opacity: 0.55;
}
/* event bars, positioned over the day cells of their week */
.ev {
position: absolute;
top: 22px;
height: 18px;
display: flex;
align-items: center;
padding: 0 6px;
border: 1px solid var(--cyan);
background: color-mix(in srgb, var(--cyan) 16%, transparent);
font-size: 10px;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
}
.ev-label {
overflow: hidden;
text-overflow: ellipsis;
}
.ev.clip {
border-right-style: dashed;
border-color: var(--amber);
background: var(--amber-dim);
color: var(--amber);
-webkit-mask-image: linear-gradient(90deg, #000 78%, transparent);
mask-image: linear-gradient(90deg, #000 78%, transparent);
}
.ev.skeleton {
border-style: solid;
border-color: var(--line-strong);
background: rgba(255, 255, 255, 0.02);
}
.ev.skeleton::after {
content: "";
width: 62%;
height: 8px;
background: repeating-linear-gradient(
90deg,
var(--ink-faint),
var(--ink-faint) 6px,
transparent 6px,
transparent 12px
);
opacity: 0.5;
}
/* week / time-axis grid */
.weekbox {
border: 1px solid var(--line);
background: var(--paper);
}
.wk-head,
.wk-row {
display: grid;
grid-template-columns: 48px repeat(5, 1fr);
}
.wk-head {
border-bottom: 1px solid var(--line);
}
.wk-corner {
border-right: 1px solid var(--line);
}
.wk-day {
padding: 5px 6px;
font-size: 10px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: color-mix(in srgb, var(--ink) 55%, transparent);
text-align: center;
}
.wk-row {
border-bottom: 1px solid var(--line);
}
.wk-row:last-child {
border-bottom: none;
}
.wk-hour {
padding: 6px;
font-size: 10px;
color: var(--ink-dim);
border-right: 1px solid var(--line);
text-align: right;
}
.wk-cell {
min-height: 30px;
border-left: 1px dashed var(--line);
padding: 3px;
}
.wk-cell:first-of-type {
border-left: none;
}
.wk-ev {
border: 1px solid var(--cyan);
background: color-mix(in srgb, var(--cyan) 16%, transparent);
color: var(--ink);
font-size: 10px;
padding: 2px 5px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* callouts */
.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;
}
.ev.hl {
z-index: 4;
}
.wk-ev.hl {
outline-color: var(--amber);
border-color: var(--amber);
background: var(--amber-dim);
color: var(--amber);
}
</style>

View File

@@ -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;

View File

@@ -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;

View 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>

View 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>

View File

@@ -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. */

View File

@@ -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 {