Compare commits

..

5 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
13 changed files with 1901 additions and 40 deletions

View File

@@ -26,7 +26,7 @@ map, and the state machine — from a blueprint's **typed data**. You author two
things per blueprint and register them: things per blueprint and register them:
| File | What it is | | 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/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/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 | | `src/data/blueprints.ts` | one line registering the `{ blueprint, specimen }` pair under its slug |
@@ -45,7 +45,7 @@ the router or the Gallery.
Study the closest existing pair before you start — copy its shape, don't reinvent: Study the closest existing pair before you start — copy its shape, don't reinvent:
| Kind | Data module | Specimen | Notes | | 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/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** (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 | | **Feature** (temporal) | `src/data/calendarBlueprint.ts` | `src/specimens/CalendarSpecimen.vue` | annotates one surface per function |
@@ -68,7 +68,7 @@ The README anatomy is fixed: **Signature → UI snapshot → State machine → B
model**. Map it to the `Blueprint` type like this: model**. Map it to the `Blueprint` type like this:
| README section | `Blueprint` field | | README section | `Blueprint` field |
| --- | --- | | -------------------------------- | ------------------------------------------------------------------------------- |
| `# Name<Item>` + intro one-liner | `name`, `tagline` | | `# Name<Item>` + intro one-liner | `name`, `tagline` |
| Signature / Standard composition | `sig` (`header`, `fields`, `types`), `role`, `extendsName`, `composesCount` | | Signature / Standard composition | `sig` (`header`, `fields`, `types`), `role`, `extendsName`, `composesCount` |
| Relations | `related[]` (`{ name, relation }`) | | Relations | `related[]` (`{ name, relation }`) |

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>
@@ -156,15 +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>{{ blueprint.stateMachine?.caption ?? "Composed lifecycle" }}</span <span>{{ m.caption ?? "Composed lifecycle" }}</span
><span class="id">{{ blueprint.stateMachine?.field ?? "loadState : LoadState" }}</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>
@@ -373,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 {
@@ -456,6 +492,54 @@ 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;

View File

@@ -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)
@@ -87,29 +107,68 @@ const KIND_LABELS: Record<SmKind, string> = {
revoke: "revoke()", revoke: "revoke()",
expire: "expired()", expire: "expired()",
clear: "clear()", 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[] = [ const KIND_ORDER: SmKind[] = [
"submit",
"succeed", "succeed",
"fail", "fail",
"refresh", "refresh",
"loadMore", "loadMore",
"navigate", "navigate",
"changeScope", "changeScope",
"lock",
"signout",
"reset",
"unlock",
"persist", "persist",
"store", "store",
"storeFail", "storeFail",
"revoke", "revoke",
"expire", "expire",
"clear", "clear",
"powerOn",
"powerOff",
"startScan",
"stopScan",
"discover",
"connect",
"discoverServices",
"exchange",
"disconnect",
] ]
// Lay the legend out left-to-right, wrapping to a new row when it would overflow
// the viewBox. Machines with few kinds (List, Grid, Calendar) stay on one row
// exactly as before; richer machines (PasswordAuth) wrap instead of clipping.
const legendItems = computed(() => { const legendItems = computed(() => {
const present = new Set(props.machine.edges.map((e) => e.kind)) const present = new Set(props.machine.edges.map((e) => e.kind))
const maxX = 880
let lxp = 70 let lxp = 70
let row = 0
return KIND_ORDER.filter((k) => present.has(k)).map((k) => { return KIND_ORDER.filter((k) => present.has(k)).map((k) => {
const t = KIND_LABELS[k] const t = KIND_LABELS[k]
const item = { t, color: SM_COLORS[k], x1: lxp, x2: lxp + 18, tx: lxp + 24, y: 8, ty: 12 } const width = 24 + t.length * 6.6 + 26
lxp += 24 + t.length * 6.6 + 26 if (lxp > 70 && lxp + width > maxX) {
row += 1
lxp = 70
}
const y = 8 + row * 16
const item = { t, color: SM_COLORS[k], x1: lxp, x2: lxp + 18, tx: lxp + 24, y, ty: y + 4 }
lxp += width
return item return item
}) })
}) })

388
src/data/bleBlueprint.ts Normal file
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

@@ -72,6 +72,23 @@ export type SmKind =
| "revoke" | "revoke"
| "expire" | "expire"
| "clear" | "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
@@ -107,12 +124,37 @@ export const SM_COLORS: Record<SmKind | "init", string> = {
revoke: "#ff5c8a", revoke: "#ff5c8a",
expire: "#ffb84d", expire: "#ffb84d",
clear: "#b79cff", 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
@@ -124,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

@@ -8,10 +8,14 @@ import { list } from "./listBlueprint"
import { grid } from "./gridBlueprint" import { grid } from "./gridBlueprint"
import { calendar } from "./calendarBlueprint" import { calendar } from "./calendarBlueprint"
import { rememberMe } from "./rememberMeBlueprint" import { rememberMe } from "./rememberMeBlueprint"
import { passwordAuth } from "./passwordAuthBlueprint"
import { ble } from "./bleBlueprint"
import ListSpecimen from "@/specimens/ListSpecimen.vue" import ListSpecimen from "@/specimens/ListSpecimen.vue"
import GridSpecimen from "@/specimens/GridSpecimen.vue" import GridSpecimen from "@/specimens/GridSpecimen.vue"
import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue" import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue"
import RememberMeSpecimen from "@/specimens/RememberMeSpecimen.vue" import 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
@@ -22,6 +26,8 @@ export const REGISTRY: Record<string, RegistryEntry> = {
list: { blueprint: list, specimen: ListSpecimen }, list: { blueprint: list, specimen: ListSpecimen },
grid: { blueprint: grid, specimen: GridSpecimen }, grid: { blueprint: grid, specimen: GridSpecimen },
calendar: { blueprint: calendar, specimen: CalendarSpecimen }, calendar: { blueprint: calendar, specimen: CalendarSpecimen },
ble: { blueprint: ble, specimen: BleSpecimen },
"password-auth": { blueprint: passwordAuth, specimen: PasswordAuthSpecimen },
"remember-me": { blueprint: rememberMe, specimen: RememberMeSpecimen }, "remember-me": { blueprint: rememberMe, specimen: RememberMeSpecimen },
} }
@@ -30,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

@@ -204,5 +204,5 @@ export const calendar: Blueprint = {
], ],
}, },
stateMachine: CALENDAR_MACHINE, 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

@@ -301,5 +301,12 @@ export const rememberMe: Blueprint = {
], ],
}, },
stateMachine: REMEMBER_ME_MACHINE, 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,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>