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