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.
This commit is contained in:
Julien Calixte
2026-07-02 23:36:08 +02:00
parent 5dff87a90d
commit 4d2db834ac
5 changed files with 837 additions and 2 deletions

View File

@@ -87,14 +87,24 @@ const KIND_LABELS: Record<SmKind, string> = {
revoke: "revoke()", revoke: "revoke()",
expire: "expired()", expire: "expired()",
clear: "clear()", clear: "clear()",
submit: "signIn(c)",
lock: "lockOut()",
signout: "signOut()",
reset: "reset()",
unlock: "unlock()",
} }
const KIND_ORDER: SmKind[] = [ const KIND_ORDER: SmKind[] = [
"submit",
"succeed", "succeed",
"fail", "fail",
"refresh", "refresh",
"loadMore", "loadMore",
"navigate", "navigate",
"changeScope", "changeScope",
"lock",
"signout",
"reset",
"unlock",
"persist", "persist",
"store", "store",
"storeFail", "storeFail",
@@ -103,13 +113,24 @@ const KIND_ORDER: SmKind[] = [
"clear", "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 legendItems = computed(() => {
const present = new Set(props.machine.edges.map((e) => e.kind)) const present = new Set(props.machine.edges.map((e) => e.kind))
const maxX = 880
let lxp = 70 let lxp = 70
let row = 0
return KIND_ORDER.filter((k) => present.has(k)).map((k) => { return KIND_ORDER.filter((k) => present.has(k)).map((k) => {
const t = KIND_LABELS[k] const t = KIND_LABELS[k]
const item = { t, color: SM_COLORS[k], x1: lxp, x2: lxp + 18, tx: lxp + 24, y: 8, ty: 12 } const width = 24 + t.length * 6.6 + 26
lxp += 24 + t.length * 6.6 + 26 if (lxp > 70 && lxp + width > maxX) {
row += 1
lxp = 70
}
const y = 8 + row * 16
const item = { t, color: SM_COLORS[k], x1: lxp, x2: lxp + 18, tx: lxp + 24, y, ty: y + 4 }
lxp += width
return item return item
}) })
}) })

View File

@@ -72,6 +72,12 @@ export type SmKind =
| "revoke" | "revoke"
| "expire" | "expire"
| "clear" | "clear"
// PasswordAuth session lifecycle
| "submit"
| "lock"
| "signout"
| "reset"
| "unlock"
export interface SmEdge { export interface SmEdge {
from: string from: string
@@ -107,12 +113,26 @@ export const SM_COLORS: Record<SmKind | "init", string> = {
revoke: "#ff5c8a", revoke: "#ff5c8a",
expire: "#ffb84d", expire: "#ffb84d",
clear: "#b79cff", clear: "#b79cff",
// PasswordAuth: blue submit / magenta lockout / purple sign-out / teal recovery
submit: "#8fd0ff",
lock: "#ff5c8a",
signout: "#b79cff",
reset: "#67d0c0",
unlock: "#4fb3a6",
init: "#4f7099", init: "#4f7099",
} }
// ── Blueprint ────────────────────────────────────────────────────────────── // ── Blueprint ──────────────────────────────────────────────────────────────
export type BlueprintRole = "feature" | "capability" export type BlueprintRole = "feature" | "capability"
// A reference from one blueprint to another (host, base, sibling, extension).
// `name` is the referenced blueprint's display name; the Viewer resolves it to a
// live link when that blueprint is registered, otherwise renders it as plain text.
export interface RelatedRef {
name: string
relation: string
}
export interface Blueprint { export interface Blueprint {
slug: string slug: string
name: string name: string
@@ -125,4 +145,5 @@ export interface Blueprint {
coreInvariants: string[] coreInvariants: string[]
composition?: Composition composition?: Composition
stateMachine?: StateMachine stateMachine?: StateMachine
related?: RelatedRef[]
} }

View File

@@ -8,10 +8,12 @@ import { list } from "./listBlueprint"
import { grid } from "./gridBlueprint" import { grid } from "./gridBlueprint"
import { calendar } from "./calendarBlueprint" import { calendar } from "./calendarBlueprint"
import { rememberMe } from "./rememberMeBlueprint" import { rememberMe } from "./rememberMeBlueprint"
import { passwordAuth } from "./passwordAuthBlueprint"
import ListSpecimen from "@/specimens/ListSpecimen.vue" import ListSpecimen from "@/specimens/ListSpecimen.vue"
import GridSpecimen from "@/specimens/GridSpecimen.vue" import GridSpecimen from "@/specimens/GridSpecimen.vue"
import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue" import CalendarSpecimen from "@/specimens/CalendarSpecimen.vue"
import RememberMeSpecimen from "@/specimens/RememberMeSpecimen.vue" import RememberMeSpecimen from "@/specimens/RememberMeSpecimen.vue"
import PasswordAuthSpecimen from "@/specimens/PasswordAuthSpecimen.vue"
export interface RegistryEntry { export interface RegistryEntry {
blueprint: Blueprint blueprint: Blueprint
@@ -22,6 +24,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 },
"password-auth": { blueprint: passwordAuth, specimen: PasswordAuthSpecimen },
"remember-me": { blueprint: rememberMe, specimen: RememberMeSpecimen }, "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 { export function entryFor(slug: string): RegistryEntry | undefined {
return REGISTRY[slug] return REGISTRY[slug]
} }
// Resolve a blueprint's display name (as used in `extendsName` / `related`) to its
// slug, so cross-references between blueprints render as live links. Returns
// undefined when the referenced blueprint is not illustrated yet — the reference
// then renders as plain text.
const slugByName: Record<string, string> = Object.fromEntries(
Object.entries(REGISTRY).map(([slug, e]) => [e.blueprint.name, slug]),
)
export function slugForName(name: string | undefined): string | undefined {
return name ? slugByName[name] : undefined
}

View File

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

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