carlos / contra Public

doc: DESIGN.md, written from the built world

The visual system recorded from the shipped artifact rather than from
what anyone meant to build — which is why it comes last and not first. A
rulebook written before the build gets defended against reality instead
of describing it.

It documents the five devices as the system's real content, the measured
WCAG floors verbatim, the --rst-line / --rst-line-strong /
--rst-header-rule distinctions, and the 60rem column that overrides the
framework's 64rem. The named rules are the ones the code already
enforces: One Stock One Ink One Second Colour, Colour Means A Category,
Large-Only, Caps Are For Labels, The Overprint Prints What It Is, The
Display Face Stays Big, One Sheet, No Lift, One Authored Moment.

Two things are recorded as gaps rather than canonized as patterns, so a
future surface inherits the defect knowingly or not at all: the kit PDFs
are still on base-14 faces and outside the world, and the caps furniture
sits on the system UI stack for a legibility reason with its cost stated.

Also the last item from the finish review's second verdict. The tear
caption is two spans now, and the separator is drawn only in the
one-line layout — stacked, the halves are already two lines, so a dot
between them was a mark with nothing to separate, which is exactly how
it came to strand at the end of the first line.

DESIGN.md is the name PROTOCOL.md was renamed out of this morning. It
now holds what the name means everywhere else.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 424582d6d2f3eba278b161c1e1e451aaa7e46e1e parent 828aa1a
5 files changed, +857 −2
  • .impeccable/design.json +339 −0
  • DESIGN.md +507 −0
  • internal/contra/static/contra.css +9 −0
  • internal/contra/templates/signin.html +1 −1
  • internal/contra/templates/vault.html +1 −1

CI cancelled — superseded by a newer push — run details

diff --git a/.impeccable/design.json b/.impeccable/design.json
new file mode 100644
index 0000000..9aeafff
--- /dev/null
+++ b/.impeccable/design.json
@@ -0,0 +1,339 @@
+{
+ "schemaVersion": 2,
+ "generatedAt": "2026-09-05T00:00:00Z",
+ "title": "Design System: CONTRA",
+ "extensions": {
+ "colorMeta": {
+ "stock": {
+ "role": "neutral",
+ "displayName": "Eau-de-Nil Ticket Stock",
+ "canonical": "light-dark(#c8dacf, #131a16)",
+ "note": "The page itself. --rst-bg and --rst-surface are the same value: one sheet, no cards.",
+ "tonalRamp": ["#0d1310", "#16201a", "#243329", "#3b5346", "#5b7d6c", "#87a996", "#c8dacf", "#e6f0ea"]
+ },
+ "stock-tint": {
+ "role": "neutral",
+ "displayName": "Printed Tint Block",
+ "canonical": "light-dark(#b3c9bb, #1d2621)",
+ "note": "The band a ticket uses to group a field. Carries the counterfoil and disabled controls.",
+ "tonalRamp": ["#0c110e", "#141d17", "#213026", "#354c3d", "#527360", "#7c9d88", "#b3c9bb", "#dfeae3"]
+ },
+ "stock-manila": {
+ "role": "tertiary",
+ "displayName": "Manila Stock",
+ "canonical": "light-dark(#ddc78d, #3a3018)",
+ "note": "The category code. Codes exactly one thing: a vault owned by somebody else.",
+ "tonalRamp": ["#221a06", "#33280c", "#4b3c14", "#6a561f", "#8e762f", "#b59a4f", "#ddc78d", "#f2e7c6"]
+ },
+ "ink": {
+ "role": "primary",
+ "displayName": "Cloakroom Ink",
+ "canonical": "light-dark(#121512, #e6efe8)",
+ "note": "The accent IS the ink. Text, links, primary button fill, the 3px title rule.",
+ "tonalRamp": ["#080a08", "#121512", "#20261f", "#333d31", "#4d5b4a", "#7b8b78", "#b4c0b1", "#e6efe8"]
+ },
+ "ink-strong": {
+ "role": "primary",
+ "displayName": "Full Strike",
+ "canonical": "light-dark(#000000, #ffffff)",
+ "note": "Hover state for anything filled with ink.",
+ "tonalRamp": ["#000000", "#1a1a1a", "#333333", "#4d4d4d", "#808080", "#b3b3b3", "#e6e6e6", "#ffffff"]
+ },
+ "ink-soft": {
+ "role": "primary",
+ "displayName": "Ink Wash",
+ "canonical": "light-dark(#bcd0c3, #232e28)",
+ "note": "The wash a ruled (secondary) button takes on hover.",
+ "tonalRamp": ["#0d1210", "#161f1a", "#233028", "#38493f", "#557061", "#82a08f", "#bcd0c3", "#e4eee8"]
+ },
+ "on-ink": {
+ "role": "primary",
+ "displayName": "Label On Ink",
+ "canonical": "light-dark(#eef3ee, #101512)",
+ "note": "The stamped label printed on a solid ink rectangle. 16.37:1 in light.",
+ "tonalRamp": ["#0a0f0c", "#131a15", "#213028", "#35483d", "#546b5e", "#849a8d", "#bfd0c5", "#eef3ee"]
+ },
+ "ink-2": {
+ "role": "secondary",
+ "displayName": "Numbering Vermilion",
+ "canonical": "light-dark(#b3301a, #ff7a5c)",
+ "note": "LARGE ONLY: 24px up, or 18.66px bold. 4.29:1 on stock in light. Also the caret colour in every field.",
+ "tonalRamp": ["#2c0c04", "#4a1409", "#6d1e0e", "#8f2614", "#b3301a", "#d8542f", "#ff7a5c", "#ffbba9"]
+ },
+ "text-muted": {
+ "role": "neutral",
+ "displayName": "Muted Print",
+ "canonical": "light-dark(#3a4741, #a9b8ae)",
+ "note": "Field labels, secondary row text, the status endorsement.",
+ "tonalRamp": ["#0e1210", "#1b221e", "#293430", "#3a4741", "#586a61", "#7e9187", "#a9b8ae", "#dbe4de"]
+ },
+ "text-faint": {
+ "role": "neutral",
+ "displayName": "Faint Print",
+ "canonical": "light-dark(#434f49, #93a399)",
+ "note": "Tear-line caption, placeholders, the footer overprint. Tightest pair in the build at 4.89:1 on the tint block.",
+ "tonalRamp": ["#101413", "#1d2320", "#2c3531", "#434f49", "#5f6f67", "#7e8f86", "#93a399", "#cfdad4"]
+ },
+ "line": {
+ "role": "neutral",
+ "displayName": "Hairline Divider",
+ "canonical": "light-dark(#a3b8ab, #2c3830)",
+ "note": "A divider, not a control border. 1.4.11's 3:1 does not apply to it.",
+ "tonalRamp": ["#0e1310", "#18211c", "#2c3830", "#3f5145", "#5c7565", "#809b8a", "#a3b8ab", "#d6e2da"]
+ },
+ "line-strong": {
+ "role": "neutral",
+ "displayName": "Control Rule",
+ "canonical": "light-dark(#56655c, #7d8d83)",
+ "note": "The control boundary: field underlines, perforation holes, the serial's rule. 4.22:1 light / 5.06:1 dark.",
+ "tonalRamp": ["#111614", "#1e2621", "#2f3a34", "#425049", "#56655c", "#7d8d83", "#a6b4ab", "#d8e0db"]
+ },
+ "tone-negative-fg": {
+ "role": "secondary",
+ "displayName": "Consequence Red",
+ "canonical": "light-dark(#8b2310, #f0a293)",
+ "note": "Small red. The step overprint, the stamp's strong text, the danger fill. Clears 4.5:1 where the numbering ink cannot.",
+ "tonalRamp": ["#2b0a04", "#48120a", "#6a1b0c", "#8b2310", "#b34a33", "#d4715c", "#f0a293", "#fbd6cd"]
+ },
+ "tone-negative-bg": {
+ "role": "secondary",
+ "displayName": "Stamp Tint",
+ "canonical": "light-dark(#e3c2b6, #2a1b18)",
+ "note": "The ground of a notice stamp.",
+ "tonalRamp": ["#1a100e", "#2a1b18", "#3f2a25", "#5d403a", "#8a6157", "#b78b7e", "#e3c2b6", "#f5e5df"]
+ },
+ "tone-positive-fg": {
+ "role": "neutral",
+ "displayName": "Endorsement Green",
+ "canonical": "light-dark(#1d5134, #9fc9ac)",
+ "note": "Status pill only. Held at low chroma; never means 'safe' on its own.",
+ "tonalRamp": ["#08170e", "#0f2c1c", "#173f28", "#1d5134", "#2f7a4f", "#5aa377", "#9fc9ac", "#d6ecdf"]
+ },
+ "tone-positive-bg": {
+ "role": "neutral",
+ "displayName": "Endorsement Tint",
+ "canonical": "light-dark(#b6d2be, #16241b)",
+ "tonalRamp": ["#0a120d", "#16241b", "#22392a", "#356045", "#4f8a63", "#7db089", "#b6d2be", "#e2f0e7"]
+ },
+ "tone-warning-fg": {
+ "role": "neutral",
+ "displayName": "Caution Ochre",
+ "canonical": "light-dark(#57430f, #d6c48a)",
+ "tonalRamp": ["#1b1405", "#2e2308", "#42320b", "#57430f", "#7d6320", "#a88a3f", "#d6c48a", "#efe5c4"]
+ },
+ "tone-warning-bg": {
+ "role": "neutral",
+ "displayName": "Caution Tint",
+ "canonical": "light-dark(#d5cc9c, #24211a)",
+ "tonalRamp": ["#161409", "#24211a", "#38321f", "#544c2d", "#7a7043", "#a79c67", "#d5cc9c", "#efead0"]
+ },
+ "tone-neutral-fg": {
+ "role": "neutral",
+ "displayName": "Rubber Stamp Ink",
+ "canonical": "light-dark(#33403a, #c4d2c9)",
+ "tonalRamp": ["#0d110f", "#1a211d", "#33403a", "#465a51", "#63806f", "#8ea698", "#c4d2c9", "#e8efeb"]
+ },
+ "tone-neutral-bg": {
+ "role": "neutral",
+ "displayName": "Rubber Stamp Tint",
+ "canonical": "light-dark(#b9cec2, #232e28)",
+ "tonalRamp": ["#0c110f", "#161f1a", "#232e28", "#374a40", "#547163", "#82a191", "#b9cec2", "#e3ede7"]
+ }
+ },
+ "typographyMeta": {
+ "display": { "displayName": "Ticket Title", "purpose": "The h1, one per screen, in sentence case over a 3px ink rule. League Gothic, self-hosted WOFF2, OFL 1.1." },
+ "headline": { "displayName": "Section Label", "purpose": "h2. Uppercase, condensed, 2.5rem above and 0.75rem below." },
+ "title": { "displayName": "Sub-Section Label", "purpose": "h3, including the device ladder's rung headings. Uppercase, condensed." },
+ "numbering": { "displayName": "The Numbering", "purpose": "Anything counted: the handoff claim code at clamp(3rem,12vw,5rem), rung counters at 2.6rem. Tabular, in the vermilion, large only." },
+ "body": { "displayName": "Ticket Body", "purpose": "System UI stack at 14px/1.5, prose capped at 68ch." },
+ "label": { "displayName": "Caps Furniture", "purpose": "Field names, buttons, nav, status, disclosure summaries, the footer overprint. Deliberately NOT the display face: League Gothic goes spindly at 11.5px." },
+ "serial": { "displayName": "Machine Serial", "purpose": "Opaque ids, build stamps and handoff codes a person transcribes by hand. Underlined on a rule, never in a grey box; keeps its own case." }
+ },
+ "shadows": [
+ { "name": "pop-edge", "value": "0 1px 0 light-dark(rgba(18,21,18,0.22), rgba(0,0,0,0.55))", "purpose": "The framework's popovers and menus. An edge, not a lift." },
+ { "name": "knob-edge", "value": "0 1px 0 light-dark(rgba(18,21,18,0.30), rgba(0,0,0,0.5))", "purpose": "Switch and slider knobs." },
+ { "name": "lift-edge", "value": "0 1px 0 light-dark(rgba(18,21,18,0.12), rgba(0,0,0,0.35))", "purpose": "The framework's lightest lift, held at one hairline." },
+ { "name": "overlay", "value": "light-dark(rgba(18,21,18,0.55), rgba(0,0,0,0.68))", "purpose": "Modal backdrop. The only real darkening: a modal genuinely is a second sheet laid over the first." }
+ ],
+ "motion": [
+ { "name": "tear", "value": "contra-tear 620ms cubic-bezier(0.16, 1, 0.3, 1) both", "purpose": "The one authored moment in the app: the vault opening, revealed once on unlock via clip-path inset(0 0 100% 0) to inset(0 0 0 0) with opacity. Nothing else animates; there is not one hover transition in contra.css. Removed entirely under prefers-reduced-motion: reduce." }
+ ],
+ "focus": [
+ { "name": "control-ring", "value": "outline: 2px solid var(--rst-accent); outline-offset: 3px", "purpose": "Buttons and fields in CONTRA's own grammar. The ring is the ink, 12.60:1 on stock in light." },
+ { "name": "framework-ring", "value": "outline: 2px solid var(--rst-accent); outline-offset: 2px", "purpose": "The framework's :focus-visible inside any rst- component, contributed at zero specificity via :where()." },
+ { "name": "field-underline-focus", "value": "border-bottom-color: var(--contra-ink-2)", "purpose": "A focused field's 2px rule turns to the numbering vermilion; the caret is vermilion at all times." }
+ ],
+ "breakpoints": [
+ { "name": "shell-fold", "value": "800px", "purpose": "Below this the top bar's tail and the side rail fold behind one <details> disclosure with two reveals." },
+ { "name": "summary-draw", "value": "390px", "purpose": "The width the single disclosure summary is drawn at." },
+ { "name": "column", "value": "60rem", "purpose": "CONTRA's page column, narrowed from the framework's 64rem default." },
+ { "name": "print", "value": "@media print", "purpose": "Strips bar, rail, footer and skip link and removes the column cap: black on white, no chrome." }
+ ],
+ "deviceRules": [
+ { "name": "The Field Box", "selector": "label + input/textarea/select", "rule": "A small letterspaced cap label sits directly on a 2px --rst-line-strong rule with the value on the rule. No boxed text fields anywhere in the app." },
+ { "name": "The Numbering", "selector": "[data-code], [data-rungs], [rst-steps]", "rule": "Anything counted prints tabular in the second colour. --contra-ink-2 is LARGE ONLY (24px up, or 18.66px bold) because it measures 4.29:1 in light; small red is --rst-tone-negative-fg, which is what [rst-steps] uses." },
+ { "name": "The Tear-Line", "selector": "[data-tear]", "rule": "A real perforation drawn as radial-gradient holes at the shared --contra-perf 12px pitch. Exactly one per screen, only on a genuine client/server boundary (sign-in and the vault). Never decoration, never twice." },
+ { "name": "The Stamp", "selector": "[data-notice], [data-kind=\"warning\"]", "rule": "A block on the negative tint with a 3px rule across the top. Never a coloured left border." },
+ { "name": "The Stock Code", "selector": "[data-vault][data-shared]", "rule": "Manila, coding exactly one thing: a vault that belongs to somebody else. Set on the open vault's root by vault.js; the whole sheet changes stock rather than gaining a badge." },
+ { "name": "The Counterfoil", "selector": "[data-counterfoil]", "rule": "The tint block above the tear carrying the server's half of the ticket. The only place in the app that changes colour without changing meaning." },
+ { "name": "The Overprint", "selector": "[data-overprint]", "rule": "Small faint letterspaced caps under a hairline, carrying the build serial. The serial itself keeps its own case." }
+ ],
+ "contrastFloors": {
+ "binding": "WCAG 2.2 AA. Normal text 4.5:1; large text and UI component boundaries 3:1. The complete measured table for both schemes lives in the header of internal/contra/static/theme.css and is recomputed whenever a token changes.",
+ "tightestTextPair": "--rst-text-faint on --rst-surface-2, 4.89:1 in light against a 4.5:1 floor",
+ "exemptions": [
+ "--rst-line is a hairline divider, not a control border; 1.4.11's 3:1 does not apply. Control borders use --rst-line-strong.",
+ "--rst-header-rule is decorative (the ticket's rule under its title) and carries no floor.",
+ "The shadow tokens and the overlay paint translucent layers over unknown grounds, so no ratio applies."
+ ]
+ },
+ "sourceOfTruth": {
+ "theme.css": "CONTRA's own token layer: every --rst-* declared once as light-dark(<light>, <dark>) under a single :root, with :root[data-theme=light|dark] as the whole toggle. Replaces rastrillo's vendored `plain` theme; the divergence is recorded in internal/contratest/vendored_test.go's vendoredIsMine map.",
+ "contra.css": "CONTRA's own grammar: the five devices, the display face, browser-surface theming, and the one authored motion moment. Loaded last of the three, so it wins.",
+ "tokens.css": "VENDORED from the rastrillo framework and pinned byte-identical by a test. Do not edit. It supplies the six spacing steps, the type scale (--rst-fs-base 0.875rem / --rst-fs-sm 0.78125rem / --rst-fs-xs 0.71875rem), the 64rem column default that contra.css narrows to 60rem, and the rst--prefixed component rules."
+ }
+ },
+ "components": [
+ {
+ "name": "Primary Button",
+ "kind": "button",
+ "refersTo": "button-primary",
+ "description": "A solid rectangle of ink with letterspaced caps: what a ticket office rubber-stamps. The default action on every screen.",
+ "html": "<button class=\"ds-btn-primary\">Sign in</button>",
+ "css": ".ds-btn-primary { background: var(--rst-accent, #121512); color: var(--rst-on-accent, #eef3ee); border: 2px solid var(--rst-accent, #121512); border-radius: 0; cursor: pointer; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 0.78125rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; padding: 0.5rem 1.5rem; } .ds-btn-primary:hover { background: var(--rst-accent-strong, #000); border-color: var(--rst-accent-strong, #000); } .ds-btn-primary:focus-visible { outline: 2px solid var(--rst-accent, #121512); outline-offset: 3px; } .ds-btn-primary:disabled { background: var(--rst-surface-2, #b3c9bb); border-color: var(--rst-line-strong, #56655c); color: var(--rst-text-faint, #434f49); cursor: not-allowed; }"
+ },
+ {
+ "name": "Ruled Button",
+ "kind": "button",
+ "refersTo": "button-ruled",
+ "description": "The same rectangle ruled rather than filled. Cancel, edit, delete and any non-primary action.",
+ "html": "<button class=\"ds-btn-ruled\" type=\"button\">Cancel</button>",
+ "css": ".ds-btn-ruled { background: transparent; color: var(--rst-accent, #121512); border: 2px solid var(--rst-accent, #121512); border-radius: 0; cursor: pointer; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 0.78125rem; font-weight: 700; letter-spacing: 0.14em; text-transform: uppercase; padding: 0.5rem 1.5rem; } .ds-btn-ruled:hover { background: var(--rst-accent-soft, #bcd0c3); border-color: var(--rst-accent, #121512); } .ds-btn-ruled:focus-visible { outline: 2px solid var(--rst-accent, #121512); outline-offset: 3px; }"
+ },
+ {
+ "name": "Field Box",
+ "kind": "input",
+ "refersTo": "field-input",
+ "description": "Device 1. A small letterspaced cap label directly on a 2px rule, with the value sitting on the rule. There are no boxed text fields in this app.",
+ "html": "<p class=\"ds-field\"><label class=\"ds-field-label\" for=\"ds-email\">Email address</label><input class=\"ds-field-input\" id=\"ds-email\" type=\"email\" placeholder=\"you@example.com\"></p>",
+ "css": ".ds-field { margin: 0 0 1rem; } .ds-field-label { display: block; color: var(--rst-text-muted, #3a4741); font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 0.71875rem; font-weight: 600; letter-spacing: 0.12em; line-height: 1.2; margin-bottom: 0; text-transform: uppercase; } .ds-field-input { background: transparent; border: 0; border-bottom: 2px solid var(--rst-line-strong, #56655c); border-radius: 0; color: var(--rst-text, #121512); caret-color: var(--contra-ink-2, #b3301a); font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 0.875rem; line-height: 1.2; padding: 0 0 0.1rem; inline-size: 100%; max-inline-size: 32rem; } .ds-field-input::placeholder { color: var(--rst-text-faint, #434f49); } .ds-field-input:focus { border-bottom-color: var(--contra-ink-2, #b3301a); outline: 2px solid var(--rst-accent, #121512); outline-offset: 3px; }"
+ },
+ {
+ "name": "Tear-Line",
+ "kind": "custom",
+ "refersTo": null,
+ "description": "Device 3. A real perforation at a 12px pitch, drawn once per screen and only where a genuine client/server boundary exists. Above it what the server holds; below it what only this browser knows.",
+ "html": "<div class=\"ds-tear\"><span>goes to the server &uarr; &nbsp;&middot;&nbsp; never leaves this page &darr;</span></div>",
+ "css": ".ds-tear { align-items: center; display: flex; gap: 0.75rem; margin: 2.5rem 0 1.5rem; } .ds-tear::before, .ds-tear::after { content: ''; flex: 1; block-size: 9px; background-repeat: repeat-x; background-size: var(--contra-perf, 12px) 100%; background-image: radial-gradient(circle at calc(var(--contra-perf, 12px) / 2) 50%, var(--rst-line-strong, #56655c) 1.7px, transparent 1.8px); } .ds-tear span { color: var(--rst-text-faint, #434f49); flex: none; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 0.71875rem; font-weight: 600; letter-spacing: 0.12em; text-align: center; text-transform: uppercase; }"
+ },
+ {
+ "name": "Notice Stamp",
+ "kind": "card",
+ "refersTo": "notice-stamp",
+ "description": "Device 4. A block on the negative tint under a 3px rule: how a ticket prints a condition of carriage. Never a coloured left border.",
+ "html": "<section class=\"ds-stamp\"><p><strong>There is no reset.</strong> If you lose both your passphrase and your Emergency Kit, nobody can open this vault, including us.</p></section>",
+ "css": ".ds-stamp { background: var(--rst-tone-negative-bg, #e3c2b6); border-top: 3px solid var(--rst-tone-negative-fg, #8b2310); color: var(--rst-text, #121512); margin: 1.5rem 0; max-width: 68ch; padding: 1rem; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 0.875rem; line-height: 1.5; } .ds-stamp > :first-child { margin-block-start: 0; } .ds-stamp > :last-child { margin-block-end: 0; } .ds-stamp strong { color: var(--rst-tone-negative-fg, #8b2310); }"
+ },
+ {
+ "name": "The Numbering",
+ "kind": "custom",
+ "refersTo": "numbering-code",
+ "description": "Device 2. The handoff claim check: eight symbols, ten minutes, printed at the size a cloakroom prints a stub number because a person reads it off one screen and types it into another.",
+ "html": "<p class=\"ds-numbering-wrap\"><span class=\"ds-numbering-label\">Claim code</span><output class=\"ds-numbering\">K7M4-2QXB</output></p>",
+ "css": ".ds-numbering-wrap { margin: 0; } .ds-numbering-label { display: block; color: var(--rst-text-muted, #3a4741); font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 0.71875rem; font-weight: 600; letter-spacing: 0.12em; text-transform: uppercase; } .ds-numbering { color: var(--contra-ink-2, #b3301a); display: block; font-family: 'League Gothic', 'Oswald', 'Archivo Narrow', system-ui, sans-serif; font-size: clamp(3rem, 12vw, 5rem); font-variant-numeric: tabular-nums; letter-spacing: 0.14em; line-height: 1; margin: 0.75rem 0; }"
+ },
+ {
+ "name": "Numbered Rungs",
+ "kind": "custom",
+ "refersTo": null,
+ "description": "The device ladder: ruled rows with machine-printed counters in the second colour, ordered by what works with the least. The last rung always works, and the order carries that promise.",
+ "html": "<ol class=\"ds-rungs\"><li><h3>Use a passkey</h3><p>Fastest, if this browser has one.</p></li><li><h3>Approve from another device</h3><p>Ten minutes, one code.</p></li><li><h3>Type your Emergency Kit</h3><p>Always works.</p></li></ol>",
+ "css": ".ds-rungs { counter-reset: rung; list-style: none; margin: 1.5rem 0; padding: 0; } .ds-rungs > li { border-top: 1px solid var(--rst-line, #a3b8ab); counter-increment: rung; padding: 1rem 0 1rem 3.5rem; position: relative; } .ds-rungs > li::before { color: var(--contra-ink-2, #b3301a); content: counter(rung); font-family: 'League Gothic', 'Oswald', 'Archivo Narrow', system-ui, sans-serif; font-size: 2.6rem; font-variant-numeric: tabular-nums; inset-block-start: 1rem; inset-inline-start: 0; line-height: 0.8; position: absolute; } .ds-rungs h3 { font-family: 'League Gothic', 'Oswald', 'Archivo Narrow', system-ui, sans-serif; font-size: 1.35rem; font-weight: 400; letter-spacing: 0.04em; line-height: 1; margin: 0 0 0.5rem; text-transform: uppercase; } .ds-rungs p { font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 0.875rem; line-height: 1.5; margin: 0; }"
+ },
+ {
+ "name": "Counterfoil",
+ "kind": "card",
+ "refersTo": "counterfoil",
+ "description": "The server's half of the ticket, printed on the tint block above the tear-line. The only place in the app that changes colour without changing meaning, and the meaning is 'this side is theirs'.",
+ "html": "<div class=\"ds-counterfoil\"><h2>Held by this server</h2><p>18 items &middot; epoch 4 &middot; last written 12 March</p></div>",
+ "css": ".ds-counterfoil { background: var(--rst-surface-2, #b3c9bb); padding: 1rem; color: var(--rst-text, #121512); font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 0.875rem; line-height: 1.5; } .ds-counterfoil > :first-child { margin-block-start: 0; } .ds-counterfoil > :last-child { margin-block-end: 0; } .ds-counterfoil h2 { font-family: 'League Gothic', 'Oswald', 'Archivo Narrow', system-ui, sans-serif; font-size: 1.9rem; font-weight: 400; letter-spacing: 0.04em; line-height: 1; margin-bottom: 0.75rem; text-transform: uppercase; }"
+ },
+ {
+ "name": "Ruled List",
+ "kind": "card",
+ "refersTo": null,
+ "description": "The ticket's line items. Ruled rows opened by an ink rule and separated by hairlines; never cards, because a card would be a second sheet of paper.",
+ "html": "<ul class=\"ds-list\"><li><strong>github.com</strong><span>paul@tito.io</span></li><li><strong>Bank</strong><span>Sealed &middot; version 12</span></li></ul>",
+ "css": ".ds-list { border-top: 1px solid var(--rst-accent, #121512); list-style: none; margin: 1rem 0; padding: 0; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 0.875rem; } .ds-list > li { align-items: baseline; border-bottom: 1px solid var(--rst-line, #a3b8ab); display: flex; flex-wrap: wrap; gap: 0.5rem; padding: 0.75rem 0; } .ds-list > li > strong { flex: 1 1 12rem; font-weight: 700; } .ds-list > li > span { color: var(--rst-text-muted, #3a4741); flex: 1 1 12rem; }"
+ },
+ {
+ "name": "Navigation Link",
+ "kind": "nav",
+ "refersTo": "nav-link",
+ "description": "Rail links in the caps voice, undecorated until hover.",
+ "html": "<nav class=\"ds-nav\"><a href=\"#\">Vaults</a><a href=\"#\">Devices</a><a href=\"#\">Emergency kit</a></nav>",
+ "css": ".ds-nav a { color: var(--rst-accent, #121512); display: block; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 0.78125rem; font-weight: 600; letter-spacing: 0.08em; padding: 0.5rem 0; text-decoration: none; text-transform: uppercase; } .ds-nav a:hover { text-decoration: underline; } .ds-nav a:focus-visible { outline: 2px solid var(--rst-accent, #121512); outline-offset: 3px; }"
+ },
+ {
+ "name": "Status Pill",
+ "kind": "chip",
+ "refersTo": "status-pill",
+ "description": "The framework's status pill, kept because a pill in this world is a rubber stamp. The one round thing on the sheet.",
+ "html": "<span class=\"ds-pill\">Sealed</span>",
+ "css": ".ds-pill { align-items: center; background: var(--rst-tone-neutral-bg, #b9cec2); border-radius: 999px; color: var(--rst-tone-neutral-fg, #33403a); display: inline-flex; font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 0.71875rem; font-weight: 600; gap: 0.35rem; line-height: 1.6; padding: 0.1rem 0.6rem; }"
+ },
+ {
+ "name": "Footer Overprint",
+ "kind": "custom",
+ "refersTo": "footer-overprint",
+ "description": "The mark that ends the sheet: small faint letterspaced caps under a hairline, carrying the build serial. The serial itself keeps its own case, because the page exists to report that value accurately.",
+ "html": "<p class=\"ds-overprint\">Build <code>3a8ba10</code>.</p>",
+ "css": ".ds-overprint { border-top: 1px solid var(--rst-line, #a3b8ab); color: var(--rst-text-faint, #434f49); font-family: system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; font-size: 0.71875rem; letter-spacing: 0.12em; margin: 0; padding: 0.75rem 0; text-align: start; text-transform: uppercase; } .ds-overprint code { border-bottom: 1px solid var(--rst-line-strong, #56655c); font-family: ui-monospace, 'SF Mono', 'Cascadia Mono', Menlo, Consolas, monospace; font-size: 0.95em; font-variant-numeric: tabular-nums; letter-spacing: 0.06em; text-transform: none; word-break: break-all; }"
+ }
+ ],
+ "narrative": {
+ "northStar": "Left Luggage",
+ "overview": "A station cloakroom holds your bag, never learns what is in it, and will not release it without the stub. That is the product's argument and it is also the material: coloured ticket stock, black ink, one vermilion spent only on the numbering machine, ruled fields with small letterspaced caps printed above the rule, and one perforation where the ticket tears. The world was not chosen to look like a ticket; six of the product's mechanics — the counterfoil, the never-repeating epoch, the coloured stock that codes a category, the expiring claim check, the perforation between client and server — already are ticket mechanics, so the visual system describes the protocol rather than costuming it.\n\nThe register is institutional, plain, and unapologetic about consequences, which is the same voice PRODUCT.md records for the product. Ticket printing as a station cloakroom would do it: every mark is doing a job. Ticket printing as a fairground would do it — grain, distress, torn-paper shadows, rotated stamps, fake staples, perforated GIFs used as ornament — is refused in contra.css by name, because the moment the material gets texture it turns twee and the institution stops being credible. The category default is refused equally and explicitly: no deep navy, no rounded cards, no lock mark, no green-for-safe.\n\nDensity is that of a printed form rather than a dashboard: one sheet of stock edge to edge, no cards, ruled rows for line items, more space above a heading than below it. Light is the primary scheme, and that is a decision about a scene — a person at a desk in daylight with a printer running, holding two sheets they were told can never be reissued. Dark is the same counter at night and is a complete scheme, not an inversion; every token is declared once as light-dark(<light>, <dark>) under a single :root, and :root[data-theme=light|dark] setting color-scheme is the whole toggle.",
+ "keyCharacteristics": [
+ "Eau-de-nil ticket stock as the ground, emphatically not cream and never white.",
+ "The accent is the ink: links and primary buttons are black, not coloured.",
+ "One second colour (vermilion), spent only on counting and on what cannot be undone.",
+ "Square-cut throughout (0 radius); the status pill is the one round thing, and it is a rubber stamp.",
+ "Flat by conviction: ink on paper has no depth.",
+ "Five named devices — the field box, the numbering, the tear-line, the stamp, the stock code — and every screen is built from them."
+ ],
+ "rules": [
+ { "name": "The One Stock, One Ink, One Second Colour Rule", "body": "A screen has a ground, a black ink, and at most one vermilion mark. If a new element seems to need a fourth colour, it does not need a colour; it needs a rule, a case change, or a position.", "section": "colors" },
+ { "name": "The Colour Means A Category Rule", "body": "Manila codes a vault owned by somebody else, and nothing else, ever. A colour that does not carry a category does not get used.", "section": "colors" },
+ { "name": "The Large-Only Rule", "body": "--contra-ink-2 measures 4.29:1 on the stock in light. It is a large-text colour only: 24px and up, or 18.66px bold. Small red text is --rst-tone-negative-fg, which clears 4.5:1 on its own tint. Never reach for the numbering ink at body size — darkening it until it passes at 14px turns a vermilion into a brick and costs the theme its second colour.", "section": "colors" },
+ { "name": "The Caps Are For Labels Rule", "body": "Capitals name things; they do not speak sentences. h2 and h3 are section labels and are uppercased; h1 keeps sentence case and takes its character from the face and the rule under it, because this app's title is sometimes a whole sentence, and a whole sentence in condensed capitals is a poster nobody reads and a screen reader may spell out.", "section": "typography" },
+ { "name": "The Overprint Prints What It Is Rule", "body": "Uppercasing stops at a serial. A build hash the reader will paste and compare renders in its own case, because a page that exists to report a value must not restyle the value into a lie.", "section": "typography" },
+ { "name": "The Display Face Stays Big Rule", "body": "League Gothic is a poster face. At 11.5px with 0.12em tracking it goes spindly and its condensed forms stop being legible, so none of the caps furniture — labels, buttons, nav, status, the overprint — uses it. That furniture is set in the UI stack and takes its ticket character from case, tracking, weight and the rule it sits on. Recorded cost, accepted in the build: the small caps render in a different typeface per operating system while the display line does not.", "section": "typography" },
+ { "name": "The One Sheet Rule", "body": "There are no cards. A card is a second sheet of paper on a counter that only ever holds one. Grouping is done with a rule, a tint block, or a change of stock.", "section": "layout" },
+ { "name": "The No Lift Rule", "body": "Never add a shadow to make something look raised. If an element must separate from the sheet, give it a rule, a tint, or a perforation. The three shadow tokens are inherited obligations held at their minimum, not an elevation ramp to climb.", "section": "elevation" },
+ { "name": "The One Authored Moment Rule", "body": "The world's native motion is the tear, so there is exactly one animation in the app: the vault opening. The sealed half below the line is revealed once on unlock — clip-path inset(0 0 100% 0) to inset(0 0 0 0) with opacity, 620ms, cubic-bezier(0.16, 1, 0.3, 1) — easing the way paper separates, fast at the start and settling at the end. Nothing else animates; there is not one hover transition in contra.css. The keyframes' end state is the visible one, so an engine that ignores the animation shows the content immediately, and prefers-reduced-motion: reduce removes it outright.", "section": "components" }
+ ],
+ "dos": [
+ "Do print the page on one sheet: the stock edge to edge, the column at 60rem, no border, no card.",
+ "Do build every input as the field box — cap label, 2px rule, value sitting on the rule.",
+ "Do keep the second colour large: 24px up, or 18.66px bold. Small red is the negative foreground.",
+ "Do draw the tear-line only where a real client/server boundary exists, once per screen, at the shared 12px pitch.",
+ "Do group with a rule, a tint block, or a change of stock.",
+ "Do set headings in the display face and keep h1 in sentence case over its 3px ink rule.",
+ "Do recompute the contrast rows in theme.css's header whenever a colour token changes; the table is the record and 4.5:1 / 3:1 are binding.",
+ "Do let code print its own case and its own tabular figures — a serial is transcribed, not styled.",
+ "Do declare any new token once as light-dark(<light>, <dark>) under the single :root, so both schemes stay complete and the toggle keeps working."
+ ],
+ "donts": [
+ "Don't edit internal/contra/static/tokens.css. It is vendored from the rastrillo framework and pinned byte-identical by a test; it contributes the spacing steps, the type scale, the framework component rules and the 64rem column default. CONTRA's overrides go in theme.css (colour, type, shape) and contra.css (grammar and components), which loads last and wins.",
+ "Don't add a card, a rounded corner, or a shadow that reads as a lift. Radius is 0 except the status pill, which is a rubber stamp.",
+ "Don't add texture: no distress, no paper grain, no torn-paper shadows, no rotated stamps, no fake staples, no perforated GIFs as decoration. That is a fairground; this is a station cloakroom.",
+ "Don't reach for the category default — deep navy, rounded cards, a lock mark, green-for-safe.",
+ "Don't colour a link or a primary button. The accent is the ink.",
+ "Don't spend manila on anything but a vault owned by somebody else, or vermilion on anything but counting and the irreversible.",
+ "Don't set a sentence in condensed capitals, and don't set caps furniture in League Gothic — it is a poster face and goes spindly at label size.",
+ "Don't add hover transitions or a second animation. One authored moment; everything else is instant.",
+ "Don't put a notice behind a coloured left border; it is a top-ruled stamped block."
+ ]
+ }
+}
diff --git a/DESIGN.md b/DESIGN.md
new file mode 100644
index 0000000..d1f094f
--- /dev/null
+++ b/DESIGN.md
@@ -0,0 +1,507 @@
+---
+name: CONTRA
+description: A host-blind password manager dressed as a station cloakroom's left-luggage ticket.
+colors:
+ stock: "light-dark(#c8dacf, #131a16)"
+ stock-tint: "light-dark(#b3c9bb, #1d2621)"
+ stock-manila: "light-dark(#ddc78d, #3a3018)"
+ ink: "light-dark(#121512, #e6efe8)"
+ ink-strong: "light-dark(#000000, #ffffff)"
+ ink-soft: "light-dark(#bcd0c3, #232e28)"
+ on-ink: "light-dark(#eef3ee, #101512)"
+ ink-2: "light-dark(#b3301a, #ff7a5c)"
+ text-muted: "light-dark(#3a4741, #a9b8ae)"
+ text-faint: "light-dark(#434f49, #93a399)"
+ line: "light-dark(#a3b8ab, #2c3830)"
+ line-strong: "light-dark(#56655c, #7d8d83)"
+ tone-negative-fg: "light-dark(#8b2310, #f0a293)"
+ tone-negative-bg: "light-dark(#e3c2b6, #2a1b18)"
+ tone-positive-fg: "light-dark(#1d5134, #9fc9ac)"
+ tone-positive-bg: "light-dark(#b6d2be, #16241b)"
+ tone-warning-fg: "light-dark(#57430f, #d6c48a)"
+ tone-warning-bg: "light-dark(#d5cc9c, #24211a)"
+ tone-neutral-fg: "light-dark(#33403a, #c4d2c9)"
+ tone-neutral-bg: "light-dark(#b9cec2, #232e28)"
+typography:
+ display:
+ fontFamily: "League Gothic, Oswald, Archivo Narrow, system-ui, sans-serif"
+ fontSize: "clamp(2.4rem, 6vw, 3.6rem)"
+ fontWeight: 400
+ lineHeight: 1
+ letterSpacing: "0.01em"
+ headline:
+ fontFamily: "League Gothic, Oswald, Archivo Narrow, system-ui, sans-serif"
+ fontSize: "1.9rem"
+ fontWeight: 400
+ lineHeight: 1
+ letterSpacing: "0.04em"
+ title:
+ fontFamily: "League Gothic, Oswald, Archivo Narrow, system-ui, sans-serif"
+ fontSize: "1.35rem"
+ fontWeight: 400
+ lineHeight: 1
+ letterSpacing: "0.04em"
+ numbering:
+ fontFamily: "League Gothic, Oswald, Archivo Narrow, system-ui, sans-serif"
+ fontSize: "clamp(3rem, 12vw, 5rem)"
+ fontWeight: 400
+ lineHeight: 1
+ letterSpacing: "0.14em"
+ fontFeature: "tabular-nums"
+ body:
+ fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif"
+ fontSize: "0.875rem"
+ fontWeight: 400
+ lineHeight: 1.5
+ letterSpacing: "normal"
+ label:
+ fontFamily: "system-ui, -apple-system, Segoe UI, Roboto, Helvetica Neue, Arial, sans-serif"
+ fontSize: "0.71875rem"
+ fontWeight: 600
+ lineHeight: 1.2
+ letterSpacing: "0.12em"
+ serial:
+ fontFamily: "ui-monospace, SF Mono, Cascadia Mono, Menlo, Consolas, monospace"
+ fontSize: "0.95em"
+ fontWeight: 400
+ lineHeight: 1.5
+ letterSpacing: "0.06em"
+ fontFeature: "tabular-nums"
+rounded:
+ cut: "0"
+ stamp: "999px"
+spacing:
+ sp-1: "0.25rem"
+ sp-2: "0.5rem"
+ sp-3: "0.75rem"
+ sp-4: "1rem"
+ sp-5: "1.5rem"
+ sp-6: "2.5rem"
+components:
+ button-primary:
+ backgroundColor: "{colors.ink}"
+ textColor: "{colors.on-ink}"
+ typography: "{typography.label}"
+ rounded: "{rounded.cut}"
+ padding: "0.5rem 1.5rem"
+ button-primary-hover:
+ backgroundColor: "{colors.ink-strong}"
+ textColor: "{colors.on-ink}"
+ button-primary-disabled:
+ backgroundColor: "{colors.stock-tint}"
+ textColor: "{colors.text-faint}"
+ button-ruled:
+ backgroundColor: "transparent"
+ textColor: "{colors.ink}"
+ typography: "{typography.label}"
+ rounded: "{rounded.cut}"
+ padding: "0.5rem 1.5rem"
+ button-ruled-hover:
+ backgroundColor: "{colors.ink-soft}"
+ textColor: "{colors.ink}"
+ button-marginal:
+ backgroundColor: "transparent"
+ textColor: "{colors.ink}"
+ rounded: "{rounded.cut}"
+ padding: "0.15rem 0.5rem"
+ field-label:
+ textColor: "{colors.text-muted}"
+ typography: "{typography.label}"
+ field-input:
+ backgroundColor: "transparent"
+ textColor: "{colors.ink}"
+ typography: "{typography.body}"
+ rounded: "{rounded.cut}"
+ padding: "0 0 0.1rem"
+ width: "100%"
+ notice-stamp:
+ backgroundColor: "{colors.tone-negative-bg}"
+ textColor: "{colors.ink}"
+ rounded: "{rounded.cut}"
+ padding: "{spacing.sp-4}"
+ width: "68ch"
+ counterfoil:
+ backgroundColor: "{colors.stock-tint}"
+ textColor: "{colors.ink}"
+ rounded: "{rounded.cut}"
+ padding: "{spacing.sp-4}"
+ vault-shared:
+ backgroundColor: "{colors.stock-manila}"
+ textColor: "{colors.ink}"
+ rounded: "{rounded.cut}"
+ padding: "{spacing.sp-4}"
+ numbering-code:
+ backgroundColor: "transparent"
+ textColor: "{colors.ink-2}"
+ typography: "{typography.numbering}"
+ step-overprint:
+ backgroundColor: "transparent"
+ textColor: "{colors.tone-negative-fg}"
+ typography: "{typography.label}"
+ status-pill:
+ backgroundColor: "{colors.tone-neutral-bg}"
+ textColor: "{colors.tone-neutral-fg}"
+ typography: "{typography.label}"
+ rounded: "{rounded.stamp}"
+ padding: "0.1rem 0.6rem"
+ nav-link:
+ backgroundColor: "transparent"
+ textColor: "{colors.ink}"
+ typography: "{typography.label}"
+ padding: "0.5rem 0"
+ footer-overprint:
+ backgroundColor: "transparent"
+ textColor: "{colors.text-faint}"
+ typography: "{typography.label}"
+ padding: "0.75rem 0"
+---
+
+🤖 Machine-authored record of CONTRA's visual system, written from the shipped
+stylesheets and templates rather than from intention. Where the direction
+contract in `layout.html` and the built code disagree, the code is recorded and
+the divergence is noted. This file is the visual system only; the cryptographic
+protocol lives in `PROTOCOL.md`, which carried the name `DESIGN.md` until
+2026-09-04 and has nothing to do with anything below.
+
+# Design System: CONTRA
+
+## Overview
+
+**Creative North Star: "Left Luggage"**
+
+A station cloakroom holds your bag, never learns what is in it, and will not
+release it without the stub. That is the product's argument and it is also the
+material: coloured ticket stock, black ink, one vermilion spent only on the
+numbering machine, ruled fields with small letterspaced caps printed above the
+rule, and one perforation where the ticket tears. The world was not chosen to
+look like a ticket; six of the product's mechanics — the counterfoil, the
+never-repeating epoch, the coloured stock that codes a category, the expiring
+claim check, the perforation between client and server — already *are* ticket
+mechanics, so the visual system describes the protocol rather than costuming it.
+
+The register is institutional, plain, and unapologetic about consequences,
+which is the same voice `PRODUCT.md` records for the product. Ticket printing
+as a station cloakroom would do it: every mark is doing a job. Ticket printing
+as a fairground would do it — grain, distress, torn-paper shadows, rotated
+stamps, fake staples, perforated GIFs used as ornament — is refused in
+`contra.css` by name, because the moment the material gets texture it turns
+twee and the institution stops being credible. The category default is refused
+equally and explicitly: no deep navy, no rounded cards, no lock mark, no
+green-for-safe.
+
+Density is that of a printed form rather than a dashboard: one sheet of stock
+edge to edge, no cards, ruled rows for line items, more space above a heading
+than below it. Light is the primary scheme, and that is a decision about a
+scene — a person at a desk in daylight with a printer running, holding two
+sheets they were told can never be reissued. Dark is the same counter at night
+and is a complete scheme, not an inversion; every token is declared once as
+`light-dark(<light>, <dark>)` under a single `:root`, and
+`:root[data-theme=light|dark]` setting `color-scheme` is the whole toggle.
+
+**Key Characteristics:**
+- Eau-de-nil ticket stock as the ground, emphatically not cream and never white.
+- The accent is the ink: links and primary buttons are black, not coloured.
+- One second colour (vermilion), spent only on counting and on what cannot be undone.
+- Square-cut throughout (`0` radius); the status pill is the one round thing, and it is a rubber stamp.
+- Flat by conviction: ink on paper has no depth.
+- Five named devices — the field box, the numbering, the tear-line, the stamp, the stock code — and every screen is built from them.
+
+## Colors
+
+One stock, one ink, one second colour, plus a manila category code; ticket
+printing has no third colour and neither does this.
+
+### Primary
+- **Cloakroom Ink** (`{colors.ink}`): the accent *is* the ink. Body text, headings, link colour, the primary button's fill, the 3px rule under the page title, and the top rule that opens a list. A link is black text that underlines (1px thickness, 0.18em offset, thickening to 2px on hover), never a coloured word. **Ink Strong** (`{colors.ink-strong}`) is the hover state of anything filled with ink; **On Ink** (`{colors.on-ink}`) is the label printed on it; **Ink Soft** (`{colors.ink-soft}`) is the wash a ruled button takes on hover.
+
+### Secondary
+- **Numbering Vermilion** (`{colors.ink-2}`, `--contra-ink-2`): the numbering machine's ink, and the only colour on the page. It prints the handoff claim code, the device ladder's rung counters, and the caret in every text field. Its hue also carries the negative tone, so the second colour lands exactly where the product's consequences are: something being counted, or something that cannot be undone.
+
+### Tertiary
+- **Manila Stock** (`{colors.stock-manila}`, `--contra-stock-2`): the category code, coding exactly one thing — a vault that belongs to somebody else and you are on the list. `vault.js` sets `data-shared` on the open vault's root, so the whole sheet changes stock the way a cloakroom prints luggage tickets on different stock from coat tickets. It is also the `::selection` background. Never a badge, never decoration.
+
+### Neutral
+- **Eau-de-Nil Stock** (`{colors.stock}`): the page. `--rst-bg` and `--rst-surface` are the same value on purpose — the page is one sheet, and a card would be a second sheet on a counter that only ever holds one.
+- **Printed Tint Block** (`{colors.stock-tint}`): the darker band a ticket uses to group a field. Carries the counterfoil and disabled controls. A flatter surface, never a raised one.
+- **Muted / Faint Text** (`{colors.text-muted}`, `{colors.text-faint}`): field labels, secondary list text and status lines take muted; the tear-line caption, placeholders and the footer overprint take faint.
+- **Hairline** (`{colors.line}`) and **Rule** (`{colors.line-strong}`): the hairline divides rows and closes the footer; the rule is the control boundary — field underlines, the perforation's holes, the serial's underline.
+- **Status tones** (`{colors.tone-neutral-fg}` … `{colors.tone-negative-bg}`): the rubber stamps, held at low chroma so the page still reads as stock and ink at a glance. Negative is the exception, because that is the second colour doing its other job.
+
+### Named Rules
+
+**The One Stock, One Ink, One Second Colour Rule.** A screen has a ground, a
+black ink, and at most one vermilion mark. If a new element seems to need a
+fourth colour, it does not need a colour; it needs a rule, a case change, or a
+position.
+
+**The Colour Means A Category Rule.** Manila codes a vault owned by somebody
+else, and nothing else, ever. A colour that does not carry a category does not
+get used.
+
+**The Large-Only Rule.** `--contra-ink-2` measures 4.29:1 on the stock in
+light. It is a **large-text colour only**: 24px and up, or 18.66px bold. Small
+red text is `--rst-tone-negative-fg`, which clears 4.5:1 on its own tint. Never
+reach for the numbering ink at body size — darkening it until it passes at 14px
+turns a vermilion into a brick and costs the theme its second colour.
+
+### Accessibility floors
+
+WCAG 2.2 AA is binding (`PRODUCT.md`). `theme.css`'s header carries the full
+measured table for both schemes, computed from the exact hex values; if a token
+changes, its rows are recomputed there. Normal text needs 4.5:1; large text and
+UI component boundaries need 3:1. The tightest text pair in the build is
+`--rst-text-faint` on the tint block at **4.89:1** in light against a 4.5:1
+floor. Text on stock measures 12.60:1 light / 15.06:1 dark; a button label on
+the ink 16.37:1; the control border `--rst-line-strong` 4.22:1 light / 5.06:1
+dark against a 3:1 floor; the numbering ink 4.29:1 light / 6.90:1 dark against
+its large-text 3:1 floor, and 3.76:1 on manila.
+
+- `--rst-line` is a hairline **divider**, not a control border: 1.4.11's 3:1 does not apply to it. Control borders use `--rst-line-strong`.
+- `--rst-header-rule` is decorative — the ticket's rule under its title — and carries no contrast floor. It resolves to the ink, which makes it the heaviest mark on the sheet.
+- The shadow tokens and the overlay paint translucent layers over unknown grounds, so no ratio applies to them.
+
+## Typography
+
+**Display Font:** League Gothic (self-hosted WOFF2, ~10KB, OFL 1.1, `font-display: swap`), falling back to Oswald, Archivo Narrow, then the UI stack.
+**Body / Label Font:** the machine's own UI stack (`system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial`).
+**Serial Font:** `ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas`.
+
+**Character:** an Alternate Gothic revival — the condensed face poster and
+ticket printing has been set in for a century — over a workhorse UI sans. The
+display face does the ticket's shouting; the sans does the ticket's work. The
+webfont is same-origin because this app fetches nothing from anywhere, and a
+CDN font would be one more party the browser trusts on a page holding vault
+keys.
+
+### Hierarchy
+- **Display / h1** (League Gothic 400, `clamp(2.4rem, 6vw, 3.6rem)`, line-height 1, 0.01em): the page title, in **sentence case**, over a 3px ink rule with `0.5rem` of clearance and `1.5rem` beneath. One per screen.
+- **Headline / h2** (League Gothic 400, 1.9rem, line-height 1, 0.04em, uppercase): section labels. `2.5rem` above, `0.75rem` below.
+- **Title / h3** (League Gothic 400, 1.35rem, line-height 1, 0.04em, uppercase): sub-sections and the device ladder's rung headings.
+- **Numbering** (League Gothic 400, `clamp(3rem, 12vw, 5rem)`, tabular, 0.14em, in `{colors.ink-2}`): the handoff claim code, printed at the size a cloakroom prints a stub number, because a person reads it off one screen and types it into another. Rung counters are the same voice at 2.6rem.
+- **Brand overprint** (League Gothic, 1.5rem, 0.14em, uppercase): the issuing office's name in the shell bar.
+- **Body** (UI stack 400, 0.875rem/14px, line-height 1.5): prose is capped at **68ch**; the column itself is sized for forms and lists.
+- **Label** (UI stack 600, 0.71875rem/11.5px, 0.12em, uppercase): field names, disclosure summaries, the tear-line caption, the footer overprint. Buttons are the same voice at 0.78125rem/700/0.14em; nav links at 0.78125rem/600/0.08em; the status line at 0.78125rem/0.06em.
+- **Serial** (mono, 0.95em, tabular figures, 0.06em, underlined with `{colors.line-strong}`, `word-break: break-all`): opaque ids, build stamps, handoff codes — values a person transcribes by hand. Monospace here is measurement, not costume, and a serial sits on a ruled line rather than in a grey box.
+
+### Named Rules
+
+**The Caps Are For Labels Rule.** Capitals name things; they do not speak
+sentences. `h2` and `h3` are section labels and are uppercased; `h1` keeps
+sentence case and takes its character from the face and the rule under it,
+because this app's title is sometimes a whole sentence, and a whole sentence in
+condensed capitals is a poster nobody reads and a screen reader may spell out.
+
+**The Overprint Prints What It Is Rule.** Uppercasing stops at a serial. A
+build hash the reader will paste and compare renders in its own case
+(`[data-overprint] code`), because a page that exists to report a value must
+not restyle the value into a lie.
+
+**The Display Face Stays Big Rule.** League Gothic is a poster face. At 11.5px
+with 0.12em tracking it goes spindly and its condensed forms stop being
+legible, so none of the caps furniture — labels, buttons, nav, status, the
+overprint — uses it. That furniture is set in the UI stack and takes its ticket
+character from case, tracking, weight and the rule it sits on. **Recorded cost,
+accepted in the build:** the small caps render in a different typeface per
+operating system while the display line does not.
+
+## Layout
+
+One centred column, one sheet of stock. The framework's `tokens.css` declares
+the column at `64rem`; **CONTRA narrows it to `60rem`** in `contra.css` and
+gives it `min-block-size: calc(100vh - 8rem)` so the sheet reaches the fold on
+a short page. Column padding is `1.5rem 1rem 2.5rem`. The shell footer matches
+the column at `60rem`.
+
+The sheet has **no border of its own**. It had cut edges for one round and they
+were wrong: the console shell already rules the side of the navigation rail,
+and the page's own border landed about 55px inboard of it, showing two
+competing sheet edges with a dead strip between them. The rail's rule is the
+structural one; the sheet is defined by its ground and its rules, which is how
+a ticket is actually printed.
+
+Spacing is the framework's six-step rhythm — `0.25 / 0.5 / 0.75 / 1 / 1.5 /
+2.5rem` — and the build uses all six. Vertical rhythm is asymmetric on purpose:
+more space above a heading than below it. Form rows carry no top margin and
+`1rem` beneath.
+
+The shell is a top bar plus a side rail. Below 800px both fold behind **one**
+`<details>` disclosure with two reveals — the bar's tail via the
+adjacent-sibling combinator, the rail from the shell root via `:has()` —
+written as hide-when-closed, so an engine without `:has()` degrades to
+everything visible rather than to nothing. The summary control is drawn at
+390px.
+
+Reading measures: prose `68ch`, notices `68ch`, checkbox and radio sentence
+labels `68ch`, text inputs `32rem`.
+
+The `@media print` block strips the bar, rail, footer and skip link and removes
+the column cap, so a printed screen comes out black on white with no chrome.
+
+**The One Sheet Rule.** There are no cards. A card is a second sheet of paper
+on a counter that only ever holds one. Grouping is done with a rule, a tint
+block, or a change of stock.
+
+## Elevation & Depth
+
+Flat by conviction. Ink on paper has no depth and nothing in this app floats.
+The shadow tokens exist only because the framework's stylesheet paints with
+`var(--rst-shadow-*)` unconditionally and a theme cannot opt out of a property
+it must declare, so they are set to the least a theme can declare and still be
+declaring something: a single hairline layer, `0 1px 0`. You get an edge, not a
+lift. Depth, where it is needed at all, is carried by tonal layering — the tint
+block behind a counterfoil, manila behind a shared vault — and by rules.
+
+### Shadow Vocabulary
+- **Pop edge** (`box-shadow: 0 1px 0 light-dark(rgba(18,21,18,0.22), rgba(0,0,0,0.55))`): the framework's popovers and menus.
+- **Knob edge** (`box-shadow: 0 1px 0 light-dark(rgba(18,21,18,0.30), rgba(0,0,0,0.5))`): switch and slider knobs.
+- **Lift edge** (`box-shadow: 0 1px 0 light-dark(rgba(18,21,18,0.12), rgba(0,0,0,0.35))`): the framework's lightest lift.
+- **Overlay** (`light-dark(rgba(18,21,18,0.55), rgba(0,0,0,0.68))`): the only real darkening in the app, because a modal genuinely is a second sheet laid over the first.
+
+**The No Lift Rule.** Never add a shadow to make something look raised. If an
+element must separate from the sheet, give it a rule, a tint, or a perforation.
+The three shadow tokens are inherited obligations held at their minimum, not an
+elevation ramp to climb.
+
+## Shapes
+
+Cut, not moulded. `--rst-radius` and `--rst-radius-sm` are `0`, and every
+control CONTRA draws is a square rectangle: buttons, inputs, the notice stamp,
+the counterfoil, the manila block. The pill radius (`999px`) survives only
+because `tokens.css` paints status pills and row actions with it; in this world
+a pill is a rubber stamp, and a stamp is the one round thing on a ticket.
+
+The recurring geometry is **the rule**: 3px in the ink under the page title,
+2px under every field, 1px in the ink over a list, 1px hairlines between rows
+and above the footer, 3px in the negative foreground across the top of a stamp.
+Weight encodes rank — title, control, boundary, divider — and the build never
+substitutes a box for a rule.
+
+The one non-rectilinear mark in the app is the perforation, and it is drawn as
+real holes rather than as a dashed border.
+
+## Components
+
+### Buttons
+- **Shape:** square-cut (`0` radius), 2px border in the ink.
+- **Primary:** a solid rectangle of ink (`{colors.ink}` fill, `{colors.on-ink}` label), 0.78125rem/700 uppercase at 0.14em tracking, padded `0.5rem 1.5rem`. What a ticket office rubber-stamps.
+- **Hover:** fill and border shift to `{colors.ink-strong}`. No transition, no lift, no transform.
+- **Focus:** `outline: 2px solid {colors.ink}` at `3px` offset. The same ring everywhere in the app.
+- **Ruled (secondary):** the same rectangle ruled rather than filled — transparent fill, ink label and border; hover washes to `{colors.ink-soft}`. Used for `type="button"`, cancel, edit, delete.
+- **Marginal:** row-level edit and delete drop to a 1px border and `0.15rem 0.5rem` of padding. They are marginal notes on a line item, not stamps.
+- **Disabled:** tint-block fill, faint text, `{colors.line-strong}` border, `cursor: not-allowed`.
+
+### Inputs / Fields — Device 1, THE FIELD BOX
+- **Style:** no box. A small letterspaced cap label sits directly on a 2px `{colors.line-strong}` rule, with the value on the rule. Transparent background, no border but the underline, `line-height: 1.2` and `padding: 0 0 0.1rem` so the value sits close under its label — the label names the rule beneath it rather than heading an empty line. Width 100%, capped at `32rem`.
+- **Focus:** the underline turns to the numbering vermilion and the 2px ink outline appears at 3px offset. The caret is vermilion in every field, always.
+- **Placeholder:** `{colors.text-faint}`.
+- **Checkbox / radio:** opt out of the caps entirely — a checkbox label is a sentence, so it takes body size, normal tracking, sentence case, and a `68ch` measure. `accent-color` is the vermilion; the control is `1.1rem` square.
+- **Textarea:** `6rem` minimum block size, vertical resize only.
+
+### Lists
+Ruled rows, never cards. A 1px ink rule opens the list, 1px hairlines separate
+rows, rows are baseline-aligned and wrap from a `12rem` flex basis with
+`0.75rem` of vertical padding. Secondary text in the row is muted.
+
+### Navigation
+Rail links are uppercase 0.78125rem/600 at 0.08em, undecorated, with `0.5rem`
+of vertical padding, underlining on hover. The shell's disclosure summaries
+take the label voice (0.71875rem/700/0.12em uppercase, square) so a control
+never arrives in the framework's title case.
+
+### Status
+The live region reads as a printed endorsement rather than a toast: muted,
+0.78125rem, 0.06em, uppercase, with `1.4em` of reserved height so a message
+appearing does not shift the sheet.
+
+### Signature components
+
+**Device 2 — THE NUMBERING.** Anything counted prints tabular in the second
+colour. Three live instances and no others: `[data-code]`, the handoff claim
+code, in the display face at `clamp(3rem, 12vw, 5rem)`; `[data-rungs]`
+counters, the device ladder, at 2.6rem in the display face, absolutely
+positioned in a 3.5rem gutter beside each rung; and `[rst-steps]`, the signup
+step overprint, which is *small* and therefore prints in
+`{colors.tone-negative-fg}`, never in the numbering ink. Section numbers are
+usually decoration; these carry meaning — the rungs are ordered by what works
+with the least, and the last one always works.
+
+**Device 3 — THE TEAR-LINE.** A real perforation: `radial-gradient` holes,
+1.7px solid to 1.8px transparent, repeated at a fixed 12px pitch
+(`--contra-perf`, declared once so every tear-line in the app is the same
+machine), 9px tall, flanking a centred uppercase caption. **Exactly one per
+screen, and only on a genuine client/server boundary** — sign-in, where it runs
+through the middle of the form because that is where the boundary genuinely is,
+and the vault. Above it what the server holds; below it what only this browser
+knows. Never decoration, never twice.
+
+**Device 4 — THE STAMP.** `[data-notice]` and `[data-kind="warning"]`: a block
+on the negative tint with a **3px rule across the top** in the negative
+foreground, `1rem` padding, a `68ch` measure, and strong text in the negative
+foreground. This is how a ticket prints a condition of carriage. Never a
+coloured left border.
+
+**Device 5 — THE STOCK CODE.** `[data-vault][data-shared]` prints the entire
+open vault on manila with `1rem` of padding, set from `vault.js` when the vault
+is not owned. The whole sheet changes stock; there is no "shared" badge.
+
+**The counterfoil.** `[data-counterfoil]` is the tint block above the tear-line
+carrying the server's half of the ticket — opaque ids, counts, times. It is the
+only place in the app that changes colour without changing meaning, and the
+meaning it carries is "this side is theirs".
+
+**The overprint.** `[data-overprint]` ends the sheet: small, faint,
+letterspaced caps under a hairline, aligned to the start, carrying the build
+serial. A sheet with nothing at the foot is a sheet that just stops.
+
+### Motion
+
+**The One Authored Moment Rule.** The world's native motion is the tear, so
+there is exactly one animation in the app: the vault opening. The sealed half
+below the line is revealed once on unlock — `clip-path: inset(0 0 100% 0)` to
+`inset(0 0 0 0)` with opacity, `620ms`, `cubic-bezier(0.16, 1, 0.3, 1)`, `both`
+— easing the way paper separates, fast at the start and settling at the end.
+Nothing else animates; there is not one hover transition in `contra.css`. The
+keyframes' end state is the visible one, so an engine that ignores the
+animation shows the content immediately, and `prefers-reduced-motion: reduce`
+removes it outright.
+
+### Browser surfaces
+
+The parts nobody drew still carry the design: selection is manila under ink,
+scrollbars are thin in `{colors.line-strong}` on the tint block, and the caret
+is the numbering vermilion. On a page whose whole argument is deliberateness, a
+default that belongs to no design system shows.
+
+## Do's and Don'ts
+
+### Do:
+- **Do** print the page on one sheet: `{colors.stock}` edge to edge, the column at `60rem`, no border, no card.
+- **Do** build every input as the field box — cap label, 2px rule, value sitting on the rule.
+- **Do** keep the second colour large: 24px up, or 18.66px bold. Small red is `{colors.tone-negative-fg}`.
+- **Do** draw the tear-line only where a real client/server boundary exists, once per screen, at the shared 12px pitch.
+- **Do** group with a rule, a tint block, or a change of stock.
+- **Do** set headings in the display face and keep `h1` in sentence case over its 3px ink rule.
+- **Do** recompute the contrast rows in `theme.css`'s header whenever a colour token changes; the table is the record and 4.5:1 / 3:1 are binding.
+- **Do** let `code` print its own case and its own tabular figures — a serial is transcribed, not styled.
+- **Do** declare any new token once as `light-dark(<light>, <dark>)` under the single `:root`, so both schemes stay complete and the toggle keeps working.
+
+### Don't:
+- **Don't** edit `internal/contra/static/tokens.css`. It is vendored from the rastrillo framework and pinned byte-identical by a test; it contributes the spacing steps, the type scale, the framework component rules and the `64rem` column default. CONTRA's overrides go in `theme.css` (colour, type, shape) and `contra.css` (grammar and components), which loads last and wins.
+- **Don't** add a card, a rounded corner, or a shadow that reads as a lift. Radius is `0` except the status pill, which is a rubber stamp.
+- **Don't** add texture: no distress, no paper grain, no torn-paper shadows, no rotated stamps, no fake staples, no perforated GIFs as decoration. That is a fairground; this is a station cloakroom.
+- **Don't** reach for the category default — deep navy, rounded cards, a lock mark, green-for-safe.
+- **Don't** colour a link or a primary button. The accent is the ink.
+- **Don't** spend manila on anything but a vault owned by somebody else, or vermilion on anything but counting and the irreversible.
+- **Don't** set a sentence in condensed capitals, and don't set caps furniture in League Gothic — it is a poster face and goes spindly at label size.
+- **Don't** add hover transitions or a second animation. One authored moment; everything else is instant.
+- **Don't** put a notice behind a coloured left border; it is a top-ruled stamped block.
+
+## Known gaps in the shipped build
+
+Recorded so the next pass inherits the truth rather than rediscovering it.
+These are defects or accepted costs the build carries; none of them is a rule
+for a new surface to imitate.
+
+- **The caps furniture is in the system UI stack, not the display face.** The reasoning is written above the `label` rule in `contra.css` and is a legibility argument, not an omission. The accepted cost: the small caps render in a different typeface per operating system while the display line does not.
+- **The Emergency Kit PDFs** (`internal/contra/static/crypto/kit.js`, `pdf.js`) still use PDF base-14 fonts and have not been brought into this world. It is the product's only printed artefact and the largest outstanding item. Do not read its current typography as a system decision.
+- **`index.html`'s prose** carries two stale sentences awaiting a copy review. Copy, not visual system; out of scope for this file.
diff --git a/internal/contra/static/contra.css b/internal/contra/static/contra.css
index 84d4855..d57a697 100644
--- a/internal/contra/static/contra.css
+++ b/internal/contra/static/contra.css
@@ -513,6 +513,15 @@ code {
flex: 0 1 auto;
min-inline-size: 0;
}
+
+ /* The separator exists only where the caption is one line. Stacked, the
+ two halves are already two lines and a dot between them would be a
+ mark with nothing to separate — which is how it came to strand at the
+ end of the first line when the caption was a single string. */
+ [data-tear] span + span::before {
+ content: "·";
+ margin-inline: 0.7em;
+ }
}
/* ── Device 4: the stamp ──────────────────────────────────────────────
diff --git a/internal/contra/templates/signin.html b/internal/contra/templates/signin.html
index 2e51a5e..c3afc63 100644
--- a/internal/contra/templates/signin.html
+++ b/internal/contra/templates/signin.html
@@ -22,7 +22,7 @@ from it, and signs a challenge the server chose.</p>
This is the clearest place in the whole product to draw it: not a
diagram of the promise, the promise happening in one form. */}}
- <div data-tear><span>goes to the server ↑ &nbsp;·&nbsp; never leaves this page ↓</span></div>
+ <div data-tear><span>goes to the server ↑</span><span>never leaves this page ↓</span></div>
<p>
<label for="passphrase">Passphrase</label>
diff --git a/internal/contra/templates/vault.html b/internal/contra/templates/vault.html
index 5764793..99ee79d 100644
--- a/internal/contra/templates/vault.html
+++ b/internal/contra/templates/vault.html
@@ -45,7 +45,7 @@
Every product in this category has this boundary. Most would find
it awkward to draw honestly, which is the reason to draw it. */}}
-<div data-tear><span>held by this server ↑ &nbsp;·&nbsp; opened only here ↓</span></div>
+<div data-tear><span>held by this server ↑</span><span>opened only here ↓</span></div>
<div data-vault hidden></div>