Compare commits

...

2 Commits

Author SHA1 Message Date
Julien Calixte
1ba85e3235 feat: cross-link related blueprints on the detail page
The title-block "Extends" now links to the parent blueprint when it is
illustrated, and a new "Related blueprints" section lists hosts, siblings,
and extensions. References resolve to live links via the registry and fall
back to plain text otherwise, so PasswordAuth and RememberMe now interlink.
2026-07-02 23:36:15 +02:00
Julien Calixte
4d2db834ac feat: add PasswordAuth blueprint illustration
The primary-auth feature RememberMe composes onto. Transcribed from the
ontology password-auth README + .als: the full AuthState lifecycle
(Idle / Submitting / Authenticated / Refreshing / LockedOut / Error), its
8 functions, and a sign-in specimen shown across those states. Adds the
auth transition kinds and wraps the state-machine legend to a second row
so this richer machine's kinds fit without clipping.
2026-07-02 23:36:08 +02:00
7 changed files with 918 additions and 3 deletions

View File

@@ -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>
@@ -168,6 +176,23 @@ const funcCount = computed(() => props.blueprint.functions.length)
</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>
<footer class="credit">
<div>
<span class="ck">SOURCE</span> blueprint-ontology / blueprints / {{ blueprint.slug }} ·
@@ -456,6 +481,54 @@ 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;

View File

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

View File

@@ -72,6 +72,12 @@ export type SmKind =
| "revoke"
| "expire"
| "clear"
// PasswordAuth session lifecycle
| "submit"
| "lock"
| "signout"
| "reset"
| "unlock"
export interface SmEdge {
from: string
@@ -107,12 +113,26 @@ export const SM_COLORS: Record<SmKind | "init", string> = {
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",
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
@@ -125,4 +145,5 @@ export interface Blueprint {
coreInvariants: string[]
composition?: Composition
stateMachine?: StateMachine
related?: RelatedRef[]
}

View File

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

View File

@@ -0,0 +1,348 @@
// The PasswordAuth blueprint, as data. Transcribed by hand from the ontology
// `password-auth` README + password-auth.als. A *feature* that extends Form: the
// user submits a credential, the backend verifies it, and a bounded session is
// established — with uniform failures (no username enumeration), lockout, and
// silent refresh. It is the host that RememberMe / MFA / Captcha compose onto.
import type { Blueprint, StateMachine } from "./blueprint"
// The full AuthState lifecycle. Idle → Submitting fans out to Authenticated /
// Error / LockedOut; Authenticated ⇄ Refreshing; and reset() / unlock() / signOut()
// all return to Idle. Node coordinates are hand-placed for a clean layout.
const PASSWORD_AUTH_MACHINE: StateMachine = {
initId: "idle",
caption: "Session lifecycle",
field: "state : AuthState",
nodes: [
{ id: "idle", x: 30, y: 140, w: 90, h: 44, label: "Idle" },
{ id: "submitting", x: 190, y: 140, w: 120, h: 44, label: "Submitting" },
{ id: "authenticated", x: 400, y: 140, w: 140, h: 44, label: "Authenticated" },
{ id: "refreshing", x: 630, y: 44, w: 130, h: 44, label: "Refreshing" },
{ id: "lockedout", x: 175, y: 252, w: 120, h: 44, label: "LockedOut" },
{ id: "error", x: 395, y: 252, w: 100, h: 44, label: "Error" },
],
edges: [
{ from: "idle", to: "submitting", label: "signIn(c)", kind: "submit", off: -34 },
{ from: "submitting", to: "authenticated", label: "succeed(id)", kind: "succeed", off: -34 },
{ from: "submitting", to: "lockedout", label: "lockOut()", kind: "lock", off: -20 },
{ from: "submitting", to: "error", label: "fail()", kind: "fail", off: -24 },
{ from: "authenticated", to: "refreshing", label: "refresh()", kind: "refresh", off: -26 },
{ from: "refreshing", to: "authenticated", label: "refreshed(id)", kind: "succeed", off: -26 },
{ from: "refreshing", to: "error", label: "refreshFailed()", kind: "fail", off: -30 },
{ from: "authenticated", to: "idle", label: "signOut()", kind: "signout", off: 120 },
{ from: "error", to: "idle", label: "reset()", kind: "reset", off: -40 },
{ from: "lockedout", to: "idle", label: "unlock()", kind: "unlock", off: 30 },
],
}
export const passwordAuth: Blueprint = {
slug: "password-auth",
name: "PasswordAuth",
tagline:
"Submit a credential, verify it against the backend, and hold a bounded session — with uniform failures, lockout, and silent refresh.",
role: "feature",
extendsName: "Form",
sig: {
header: "PasswordAuth ◁ Form",
fields: [
{ name: "state", type: "AuthState", note: "lifecycle position" },
{ name: "identity", type: "lone Identity", note: "present iff Authenticated or Refreshing" },
{
name: "pendingCredential",
type: "lone Credential",
note: "present only during Submitting",
},
],
types: [
"AuthState = Idle | Submitting | Authenticated | Refreshing | LockedOut | Error",
"Identity { subject : Subject, realm : Realm } -- subject unique per realm",
"Credential { username : Username, secret : Secret } -- 8 ≤ len(secret) ≤ 128",
"identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing}",
"pendingCredential ≠ ⊥ ⟺ state = Submitting",
],
},
functions: [
{
id: "capture",
name: "Capture",
fig: "01",
caps: ["PasswordAuth", "Form"],
verb: "Collect username and secret from the form; mask the secret and mount both fields together so the OS credential manager can pair them.",
state: [
["username", "Username"],
["secret", "Secret"],
],
behaviors: [],
invariants: [
"len(secret) ∈ [8, 128] — enforced before signIn(c) fires; the upper bound is surfaced to the user, never silently accepted (bcrypt truncates at 72 bytes)",
],
failures: [
"Form auto-submit — browser autofill triggers signIn without user intent; the unintended submission locks the user out faster on stale credentials",
],
perf: [
"Form responsiveness — input fields remain accessible during Submitting; the submit button is disabled to prevent double-fire",
],
notes: [
"Username and password are mounted together on the submitted form so the credential manager can pair them on save.",
],
},
{
id: "submit",
name: "Submit",
fig: "02",
caps: ["PasswordAuth"],
verb: "Transmit the credential over an authenticated channel (TLS) on a user gesture; never log or persist the secret.",
state: [
["state", "AuthState"],
["pendingCredential", "lone Credential"],
],
behaviors: [
{
sig: "signIn(c)",
pre: "state = Idle",
eff: "state ← Submitting, pendingCredential ← c",
},
],
invariants: [
"pendingCredential ≠ ⊥ ⟺ state = Submitting — the credential is held only across the verification call",
"the plaintext secret is never persisted, logged, or held past Submitting",
],
failures: [
"Plaintext leak — secret logged, persisted, or kept in app memory past Submitting; exposed to crash dumps, analytics, or an attacker with disk read",
"Form auto-submit — autofill fires signIn without a user gesture",
],
perf: [
"Verification — signIn to Authenticated or Error completes within 2 s on a stable network",
],
notes: [
"The signIn(c) submission must be triggered by a user gesture, never auto-fired on autofill completion.",
],
},
{
id: "verify",
name: "Verify",
fig: "03",
caps: ["PasswordAuth"],
verb: "Backend checks the credential against the credential store; returns an Identity or a uniform failure that never distinguishes unknown-user from wrong-password.",
state: [
["state", "AuthState"],
["identity", "lone Identity"],
],
behaviors: [
{
sig: "succeed(id)",
pre: "state = Submitting, verification passed",
eff: "state ← Authenticated, identity ← id, pendingCredential ← ⊥",
},
{
sig: "fail()",
pre: "state = Submitting, verification failed",
eff: "state ← Error, pendingCredential ← ⊥",
},
],
invariants: [
"succeed(id) requires backend verification — the relying party never trusts client-side validation alone",
"fail() does not distinguish 'wrong password' from 'unknown username' — messages stay uniform",
],
failures: [
"Username enumeration — fail() reason distinguishes 'no such user' from 'wrong password'; an attacker maps valid usernames before a targeted attack",
"Timing attack — verification time differs for valid vs invalid usernames; the attacker infers validity from response latency",
"Credential reuse — the backend accepts a known-breached password, so a password compromised elsewhere grants access here",
],
perf: [
"Constant-time response — failure responses take the same time regardless of username validity",
"Failure visibility — Error surfaces with a uniform message, never revealing whether the username exists",
],
notes: [],
},
{
id: "limit",
name: "Limit",
fig: "04",
caps: ["PasswordAuth"],
verb: "Track failed attempts per user / per IP; transition to LockedOut past the threshold.",
state: [["state", "AuthState"]],
behaviors: [
{
sig: "lockOut()",
pre: "state = Submitting, attempt threshold exceeded",
eff: "state ← LockedOut, pendingCredential ← ⊥",
},
],
invariants: [
"state = LockedOut is reached only from Submitting via lockOut() — never user-triggerable directly",
],
failures: [
"Brute force — no attempt counting / no lockOut threshold; the attacker tries unbounded password guesses",
"Lockout bypass — unlock() callable without elapsed cooldown; the rate limit is defeated and brute force resumes",
],
perf: [
"Lockout feedback — LockedOut surfaces to the user within 1 s of trigger, with the cooldown duration shown",
],
notes: [],
},
{
id: "establish",
name: "Establish",
fig: "05",
caps: ["PasswordAuth", "Identity"],
verb: "Persist the session token; expose subject and realm to the consumer.",
state: [["identity", "lone Identity"]],
behaviors: [],
invariants: [
"identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing} — no ghost identity outside an active session",
"the consumer sees subject and realm; the plaintext secret never crosses the client-persistence boundary",
],
failures: [
"Replay (token theft) — the session token leaks (XSS, network capture); the attacker impersonates the user until the token expires",
],
perf: [],
notes: [
"Client persistence may store Identity.subject / Identity.realm and the composed session credential — never the plaintext secret in any form.",
],
},
{
id: "refresh",
name: "Refresh",
fig: "06",
caps: ["PasswordAuth"],
verb: "Renew the session token silently before expiry; preserve subject and realm.",
state: [
["state", "AuthState"],
["identity", "lone Identity"],
],
behaviors: [
{
sig: "refresh()",
pre: "state = Authenticated, session token refreshable",
eff: "state ← Refreshing",
},
{
sig: "refreshed(id)",
pre: "state = Refreshing, id.subject = identity.subject, id.realm = identity.realm",
eff: "state ← Authenticated, identity ← id",
},
{
sig: "refreshFailed()",
pre: "state = Refreshing",
eff: "state ← Error, identity ← ⊥",
},
],
invariants: [
"refreshed(id) preserves both subject and realm — refresh never silently swaps user or tenant",
],
failures: [
"Subject swap on refresh — refreshed(id) accepts an id with a different subject or realm; the attacker's refresh response steals the session",
"Stale session — the session is used past expiry without refresh; API calls 401 and the user is surprise-logged-out",
],
perf: [
"Silent refresh — completes within 200 ms and before any in-flight API call observes a 401",
],
notes: [],
},
{
id: "recover",
name: "Recover",
fig: "07",
caps: ["PasswordAuth"],
verb: "Surface Error distinctly from LockedOut; expose reset() and the cooldown-driven unlock().",
state: [["state", "AuthState"]],
behaviors: [
{ sig: "reset()", pre: "state = Error", eff: "state ← Idle" },
{
sig: "unlock()",
pre: "state = LockedOut, cooldown elapsed",
eff: "state ← Idle",
},
],
invariants: [
"unlock() is reachable only from LockedOut and requires elapsed cooldown — not user-triggerable",
],
failures: [
"Forgotten password loop — no recovery path from LockedOut or an unknown username; the user is permanently locked out with no self-service unblock",
],
perf: [],
notes: [
"Recovery from a forgotten password is a sibling blueprint (PasswordReset), not part of this contract.",
],
},
{
id: "terminate",
name: "Terminate",
fig: "08",
caps: ["PasswordAuth"],
verb: "Clear the local session and invalidate the session token at the backend.",
state: [
["state", "AuthState"],
["identity", "lone Identity"],
],
behaviors: [
{
sig: "signOut()",
pre: "state = Authenticated",
eff: "state ← Idle, identity ← ⊥",
},
],
invariants: ["signOut() clears identity — no active session remains locally"],
failures: [
"Stale session — a logged-out session token still valid at the backend can be replayed",
],
perf: [],
notes: [
"A logout is not a credential operation — the secret never crosses the logout boundary; only the session credential is sent so the server can revoke.",
],
},
],
coreInvariants: [
"identity ≠ ⊥ ⟺ state ∈ {Authenticated, Refreshing} — no ghost identity outside an active session",
"pendingCredential ≠ ⊥ ⟺ state = Submitting — credentials are held only across the verification call",
"the plaintext secret is never persisted, logged, or held past Submitting",
"refreshed(id) preserves both subject and realm — refresh never swaps user or tenant",
"succeed(id) requires backend verification — the relying party never trusts client-side validation alone",
"unlock() is reachable only from LockedOut and requires elapsed cooldown — not user-triggerable",
"len(secret) ∈ [8, 128] is enforced before signIn(c) fires",
],
composition: {
caps: [
{ id: "core", label: "PasswordAuth : Form", sub: "state, identity", core: true },
{ id: "form", label: "Form", sub: "fields, filled, invalid" },
{ id: "cred", label: "Credential", sub: "username, secret" },
{ id: "identity", label: "Identity", sub: "subject, realm" },
],
funcs: ["Capture", "Submit", "Verify", "Limit", "Establish", "Refresh", "Recover", "Terminate"],
links: [
["form", "Capture"],
["cred", "Capture"],
["cred", "Submit"],
["core", "Submit"],
["core", "Verify"],
["identity", "Verify"],
["core", "Limit"],
["core", "Establish"],
["identity", "Establish"],
["core", "Refresh"],
["identity", "Refresh"],
["core", "Recover"],
["form", "Recover"],
["core", "Terminate"],
["identity", "Terminate"],
],
},
stateMachine: PASSWORD_AUTH_MACHINE,
related: [
{ name: "Form", relation: "extends · base" },
{ name: "RememberMe", relation: "composable extension" },
{ name: "MFA", relation: "composable extension" },
{ name: "Captcha", relation: "composable extension" },
{ name: "StatelessSession", relation: "composable extension" },
{ name: "PasswordReset", relation: "sibling · recovery" },
{ name: "SSO", relation: "sibling · delegated" },
{ name: "Passkey", relation: "sibling · passwordless" },
],
}

View File

@@ -302,4 +302,11 @@ export const rememberMe: Blueprint = {
},
stateMachine: REMEMBER_ME_MACHINE,
related: [
{ name: "PasswordAuth", relation: "host · requires" },
{ name: "StatelessSession", relation: "sibling capability" },
{ name: "MFA", relation: "sibling capability" },
{ name: "Captcha", relation: "sibling capability" },
],
}

View File

@@ -0,0 +1,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>