Compare commits

...

2 Commits

Author SHA1 Message Date
Julien Calixte
062826e126 feat: add RememberMe blueprint illustration
First capability blueprint in the gallery. Transcribed from the ontology
remember-me README + .als: the persistence lifecycle
(NotPersisted -> Persisting -> Persisted -> Revoked), its 8 functions,
and a specimen annotating the opt-in sign-in and cold-start revive
screens plus the secure-storage vault it owns.
2026-07-02 23:18:23 +02:00
Julien Calixte
f6b184406d feat(state-machine): let blueprints define init, caption, and kinds
RememberMe is the first blueprint whose lifecycle is not the composed
LoadState: it starts at NotPersisted (not `loading`), has its own panel
caption, and its own transition kinds. Make the init node, the panel
caption/field, and the SmKind palette data-driven, with defaults that
leave List/Grid/Calendar unchanged.
2026-07-02 23:18:14 +02:00
6 changed files with 827 additions and 6 deletions

View File

@@ -161,7 +161,8 @@ const funcCount = computed(() => props.blueprint.functions.length)
<h2 class="section">Lifecycle state machine</h2> <h2 class="section">Lifecycle state machine</h2>
<div class="panel"> <div class="panel">
<div class="cap"> <div class="cap">
<span>Composed lifecycle</span><span class="id">loadState : LoadState</span> <span>{{ blueprint.stateMachine?.caption ?? "Composed lifecycle" }}</span
><span class="id">{{ blueprint.stateMachine?.field ?? "loadState : LoadState" }}</span>
</div> </div>
<div class="body svg-wrap"><StateMachine :machine="blueprint.stateMachine" /></div> <div class="body svg-wrap"><StateMachine :machine="blueprint.stateMachine" /></div>
</div> </div>

View File

@@ -36,9 +36,9 @@ const markers = (Object.keys(SM_COLORS) as (keyof typeof SM_COLORS)[]).map((k) =
})) }))
const init = computed(() => { const init = computed(() => {
const loading = byId.value.loading const start = byId.value[props.machine.initId ?? "loading"]
const cy = loading.y + loading.h / 2 const cy = start.y + start.h / 2
const e = edgePoint(loading, 26, cy) const e = edgePoint(start, 26, cy)
return { cy, path: `M31 ${cy} L ${e[0]} ${e[1]}` } return { cy, path: `M31 ${cy} L ${e[0]} ${e[1]}` }
}) })
@@ -81,8 +81,27 @@ const KIND_LABELS: Record<SmKind, string> = {
loadMore: "loadMore()", loadMore: "loadMore()",
navigate: "navigate(δ)", navigate: "navigate(δ)",
changeScope: "changeScope(s)", changeScope: "changeScope(s)",
persist: "startPersist()",
store: "tokenStored(t)",
storeFail: "storeFailed()",
revoke: "revoke()",
expire: "expired()",
clear: "clear()",
} }
const KIND_ORDER: SmKind[] = ["succeed", "fail", "refresh", "loadMore", "navigate", "changeScope"] const KIND_ORDER: SmKind[] = [
"succeed",
"fail",
"refresh",
"loadMore",
"navigate",
"changeScope",
"persist",
"store",
"storeFail",
"revoke",
"expire",
"clear",
]
const legendItems = computed(() => { const legendItems = computed(() => {
const present = new Set(props.machine.edges.map((e) => e.kind)) const present = new Set(props.machine.edges.map((e) => e.kind))

View File

@@ -58,7 +58,20 @@ export interface SmNode {
label: string label: string
} }
export type SmKind = "succeed" | "fail" | "refresh" | "loadMore" | "navigate" | "changeScope" export type SmKind =
| "succeed"
| "fail"
| "refresh"
| "loadMore"
| "navigate"
| "changeScope"
// RememberMe persistence lifecycle
| "persist"
| "store"
| "storeFail"
| "revoke"
| "expire"
| "clear"
export interface SmEdge { export interface SmEdge {
from: string from: string
@@ -71,6 +84,13 @@ export interface SmEdge {
export interface StateMachine { export interface StateMachine {
nodes: SmNode[] nodes: SmNode[]
edges: SmEdge[] edges: SmEdge[]
// id of the initial state (the [*] arrow points here). Defaults to "loading"
// for the LoadState-based machines; RememberMe starts at "notpersisted".
initId?: string
// Panel caption + field label shown above the diagram. Default to the
// composed-LoadState wording; capabilities with their own lifecycle override.
caption?: string
field?: string
} }
export const SM_COLORS: Record<SmKind | "init", string> = { export const SM_COLORS: Record<SmKind | "init", string> = {
@@ -80,6 +100,13 @@ export const SM_COLORS: Record<SmKind | "init", string> = {
loadMore: "#8fd0ff", loadMore: "#8fd0ff",
navigate: "#b79cff", navigate: "#b79cff",
changeScope: "#67d0c0", changeScope: "#67d0c0",
// RememberMe: green mint / red store-fail / magenta revoke / amber expiry / purple reset
persist: "#8fd0ff",
store: "#7fdca0",
storeFail: "#ff8f8f",
revoke: "#ff5c8a",
expire: "#ffb84d",
clear: "#b79cff",
init: "#4f7099", init: "#4f7099",
} }

View File

@@ -7,9 +7,11 @@ import type { Blueprint } from "./blueprint"
import { list } from "./listBlueprint" import { list } from "./listBlueprint"
import { grid } from "./gridBlueprint" import { grid } from "./gridBlueprint"
import { calendar } from "./calendarBlueprint" import { calendar } from "./calendarBlueprint"
import { rememberMe } from "./rememberMeBlueprint"
import ListSpecimen from "@/specimens/ListSpecimen.vue" import ListSpecimen from "@/specimens/ListSpecimen.vue"
import GridSpecimen from "@/specimens/GridSpecimen.vue" import GridSpecimen from "@/specimens/GridSpecimen.vue"
import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue" import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue"
import RememberMeSpecimen from "@/specimens/RememberMeSpecimen.vue"
export interface RegistryEntry { export interface RegistryEntry {
blueprint: Blueprint blueprint: Blueprint
@@ -20,6 +22,7 @@ export const REGISTRY: Record<string, RegistryEntry> = {
list: { blueprint: list, specimen: ListSpecimen }, list: { blueprint: list, specimen: ListSpecimen },
grid: { blueprint: grid, specimen: GridSpecimen }, grid: { blueprint: grid, specimen: GridSpecimen },
calendar: { blueprint: calendar, specimen: CalendarSpecimen }, calendar: { blueprint: calendar, specimen: CalendarSpecimen },
"remember-me": { blueprint: rememberMe, specimen: RememberMeSpecimen },
} }
export const BLUEPRINTS: Blueprint[] = Object.values(REGISTRY).map((e) => e.blueprint) export const BLUEPRINTS: Blueprint[] = Object.values(REGISTRY).map((e) => e.blueprint)

View File

@@ -0,0 +1,305 @@
// 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"],
],
},
stateMachine: REMEMBER_ME_MACHINE,
}

View File

@@ -0,0 +1,466 @@
<script setup lang="ts">
import { computed } from "vue"
const props = defineProps<{ activeId: string }>()
// RememberMe has almost no distinctive UI — only the opt-in checkbox at sign-in
// and the cold-start "welcoming you back" screen. So the specimen shows those two
// screens plus the underlying machinery the capability actually owns (the secure
// storage vault + the binding/host state), and each function highlights the
// region it drives — the same annotate-one-surface approach the Calendar uses.
type Screen = "signin" | "welcome" | "signedin"
type Region = "checkbox" | "button" | "host" | "token" | "vault" | "spinner" | "banner"
type Vault = "empty" | "minting" | "stored" | "cleared"
interface Frame {
screen: Screen
optIn: boolean
vault: Vault
stateBadge: string
hostBadge: string
hl?: Region
tag?: string
note?: string
banner?: { text: string; kind: "revoke" }
}
const frame = computed<Frame>(() => {
switch (props.activeId) {
case "capture":
return {
screen: "signin",
optIn: true,
vault: "empty",
stateBadge: "state = NotPersisted",
hostBadge: "host.state = Idle",
hl: "checkbox",
tag: "◈ CAPTURE",
note: "optIn = True is captured at sign-in — persistence is opt-in, never default.",
}
case "trigger":
return {
screen: "signedin",
optIn: true,
vault: "empty",
stateBadge: "state = NotPersisted → Persisting",
hostBadge: "host.state = Idle → Authenticated",
hl: "host",
tag: "◈ TRIGGER",
note: "Host reaches Authenticated with optIn = True → startPersist() → state ← Persisting.",
}
case "issue":
return {
screen: "signedin",
optIn: true,
vault: "minting",
stateBadge: "state = Persisting",
hostBadge: "host.state = Authenticated",
hl: "token",
tag: "◈ ISSUE",
note: "Backend mints PersistentToken { subject = host.identity.subject, expiresAt = now + 30d }.",
}
case "store":
return {
screen: "signedin",
optIn: true,
vault: "stored",
stateBadge: "state = Persisting → Persisted",
hostBadge: "host.state = Authenticated",
hl: "vault",
tag: "◈ STORE",
note: "tokenStored(t) writes the bytes to platform-secure storage → state ← Persisted. Never localStorage.",
}
case "revive":
return {
screen: "welcome",
optIn: true,
vault: "stored",
stateBadge: "state = Persisted",
hostBadge: "host.state = Idle → Refreshing",
hl: "spinner",
tag: "◈ REVIVE",
note: "Cold start with a Persisted token → host.refresh() → Refreshing. The token never writes Authenticated directly.",
}
case "revoke":
return {
screen: "signedin",
optIn: true,
vault: "cleared",
stateBadge: "state = Persisted → Revoked",
hostBadge: "host.state = Authenticated → Idle",
hl: "banner",
tag: "◈ REVOKE",
banner: { text: "Session revoked — token invalidated", kind: "revoke" },
note: "signOut / backend signal / suspicious activity → revoke() clears the token atomically → Revoked.",
}
case "expire":
return {
screen: "signin",
optIn: true,
vault: "cleared",
stateBadge: "state = Persisted → NotPersisted",
hostBadge: "host.state = Idle",
hl: "vault",
tag: "◈ EXPIRE",
note: "now ≥ expiresAt → expired() silently drops the token; the UI shows the normal Idle sign-in. TTL is absolute.",
}
case "clear":
return {
screen: "signin",
optIn: true,
vault: "empty",
stateBadge: "state = Revoked → NotPersisted",
hostBadge: "host.state = Idle",
hl: "vault",
tag: "◈ CLEAR",
note: "Consumer acknowledged the revocation → clear() → NotPersisted; ready to persist again on the next opted-in sign-in.",
}
default:
return {
screen: "signin",
optIn: true,
vault: "empty",
stateBadge: "state = NotPersisted",
hostBadge: "host.state = Idle",
note: "The persistence lifecycle: opt in at sign-in → mint + store a token → revive the host on cold start. It only feeds host.refresh() — it never authenticates directly.",
}
}
})
const isHl = (r: Region) => frame.value.hl === r
const tagOf = (r: Region) => (frame.value.hl === r ? frame.value.tag : undefined)
</script>
<template>
<div class="specimen">
<p v-if="frame.note" class="note">{{ frame.note }}</p>
<!-- the device the user sees -->
<div class="device">
<div class="dev-bar">
<span class="dot" /><span class="dot" /><span class="dot" />
<span class="dev-title">MyApp</span>
</div>
<div class="screen">
<div
v-if="frame.banner"
class="banner"
:class="[frame.banner.kind, isHl('banner') ? ['hl', 'tag-b'] : []]"
:data-tag="tagOf('banner')"
>
{{ frame.banner.text }}
</div>
<!-- sign-in (opt-in) -->
<template v-if="frame.screen === 'signin'">
<div class="s-title">Sign in to MyApp</div>
<div class="field">user@example.com</div>
<div class="field secret"></div>
<div
class="optrow"
:class="isHl('checkbox') ? ['hl', 'tag-b'] : []"
:data-tag="tagOf('checkbox')"
>
<span class="box">{{ frame.optIn ? "☑" : "☐" }}</span>
<span>Keep me signed in on this device</span>
</div>
<div
class="btn"
:class="isHl('button') ? ['hl', 'tag-b'] : []"
:data-tag="tagOf('button')"
>
Sign in <span class="arrow"></span>
</div>
</template>
<!-- cold-start revival -->
<template v-else-if="frame.screen === 'welcome'">
<div class="welcome">
<div class="w-title">Welcoming you back</div>
<div
class="spinner"
:class="isHl('spinner') ? ['hl', 'tag-b'] : []"
:data-tag="tagOf('spinner')"
>
<span class="spin"></span>
</div>
</div>
</template>
<!-- signed in -->
<template v-else>
<div class="si-title">You're signed in</div>
<div class="idchip">u_84f · user@example.com</div>
<div class="btn ghost">Sign out</div>
</template>
</div>
</div>
<!-- the machinery the capability owns -->
<div class="machinery">
<div
class="vault"
:class="isHl('vault') || isHl('token') ? ['hl', 'tag-b'] : []"
:data-tag="tagOf('vault') ?? tagOf('token')"
>
<div class="v-label">🔒 Secure storage · Keychain / HttpOnly</div>
<div class="v-body">
<span v-if="frame.vault === 'empty'" class="v-empty">— no token —</span>
<span v-else-if="frame.vault === 'minting'" class="tok minting">⬡ token · minting…</span>
<span v-else-if="frame.vault === 'stored'" class="tok">⬡ sub=u_84f · exp +30d</span>
<span v-else class="v-cleared">✗ token cleared</span>
</div>
</div>
<div class="statebar">
<span class="badge">{{ frame.stateBadge }}</span>
<span
class="badge host"
:class="isHl('host') ? ['hl', 'tag-b'] : []"
:data-tag="tagOf('host')"
>
{{ frame.hostBadge }}
</span>
</div>
</div>
</div>
</template>
<style scoped>
.specimen {
--ink: var(--color-base-content);
--ink-faint: #4f7099;
--amber: var(--color-accent);
--amber-dim: color-mix(in srgb, var(--color-accent) 14%, transparent);
--cyan: var(--color-secondary);
--green: #7fdca0;
--red: var(--color-error);
--paper: var(--color-base-100);
--line: rgba(200, 226, 255, 0.14);
--line-strong: rgba(200, 226, 255, 0.3);
font-size: 13px;
}
.note {
color: var(--ink-faint);
font-size: 11px;
font-style: italic;
margin: 0 0 12px;
}
/* device */
.device {
max-width: 340px;
margin: 0 auto;
border: 1px solid var(--line-strong);
background: var(--paper);
}
.dev-bar {
display: flex;
align-items: center;
gap: 5px;
padding: 6px 10px;
border-bottom: 1px solid var(--line);
}
.dev-bar .dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ink-faint);
opacity: 0.5;
}
.dev-title {
margin-left: 8px;
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-faint);
}
.screen {
padding: 20px 22px 22px;
}
/* sign-in */
.s-title {
text-align: center;
color: var(--ink);
margin-bottom: 14px;
letter-spacing: 0.03em;
}
.field {
border: 1px solid var(--line);
padding: 8px 10px;
margin-bottom: 8px;
color: color-mix(in srgb, var(--ink) 75%, transparent);
font-size: 12px;
}
.field.secret {
color: var(--ink-faint);
letter-spacing: 0.15em;
}
.optrow {
display: flex;
align-items: center;
gap: 8px;
margin: 12px 0;
font-size: 12px;
color: color-mix(in srgb, var(--ink) 85%, transparent);
}
.optrow .box {
color: var(--amber);
font-size: 14px;
}
.btn {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px solid var(--cyan);
background: color-mix(in srgb, var(--cyan) 16%, transparent);
color: var(--ink);
padding: 8px;
letter-spacing: 0.04em;
}
.btn .arrow {
color: var(--cyan);
}
.btn.ghost {
background: transparent;
border-color: var(--line-strong);
color: var(--ink-faint);
}
/* welcome / revive */
.welcome {
text-align: center;
padding: 22px 0 26px;
}
.w-title {
color: var(--ink);
margin-bottom: 18px;
letter-spacing: 0.03em;
}
.spinner {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--amber);
font-size: 20px;
}
.spinner .spin {
display: inline-block;
}
/* signed in */
.si-title {
text-align: center;
color: var(--ink);
margin-bottom: 12px;
letter-spacing: 0.03em;
}
.idchip {
text-align: center;
border: 1px dashed var(--line-strong);
padding: 8px;
margin-bottom: 12px;
font-size: 12px;
color: var(--cyan);
}
/* banner */
.banner {
border: 1px solid var(--red);
background: color-mix(in srgb, var(--red) 14%, transparent);
color: var(--red);
padding: 6px 10px;
font-size: 11px;
margin-bottom: 14px;
text-align: center;
}
/* machinery */
.machinery {
max-width: 340px;
margin: 14px auto 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.vault {
border: 1px dashed var(--line-strong);
padding: 8px 10px;
}
.v-label {
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--ink-faint);
margin-bottom: 6px;
}
.v-body {
font-size: 12px;
}
.v-empty {
color: var(--ink-faint);
}
.tok {
display: inline-block;
border: 1px solid var(--green);
background: color-mix(in srgb, var(--green) 14%, transparent);
color: var(--green);
padding: 2px 8px;
letter-spacing: 0.03em;
}
.tok.minting {
border-style: dashed;
border-color: var(--amber);
background: var(--amber-dim);
color: var(--amber);
}
.v-cleared {
color: var(--red);
}
.statebar {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.badge {
border: 1px solid var(--line-strong);
color: var(--cyan);
padding: 2px 8px;
font-size: 10px;
letter-spacing: 0.05em;
}
.badge.host {
color: var(--ink-faint);
}
/* callouts — same convention as the other specimens */
.hl {
outline: 1.5px dashed var(--amber);
outline-offset: 3px;
position: relative;
z-index: 2;
}
.hl[data-tag]::after {
content: attr(data-tag);
position: absolute;
top: -10px;
left: 8px;
background: var(--paper);
color: var(--amber);
font-size: 9px;
padding: 0 5px;
border: 1px solid var(--amber);
white-space: nowrap;
letter-spacing: 0.1em;
line-height: 1.5;
z-index: 3;
}
.hl.tag-b[data-tag]::after {
top: auto;
bottom: -10px;
}
</style>