design: CONTRA looks like something — the left-luggage world
The app has been wearing rastrillo's `plain` since the scaffold wrote it. plain is the theme you ship on while you work out what the app looks like; its own header says so and tells you to add the hue. This adds it. THE WORLD. A station cloakroom. You hand over your bag; they hand you a numbered stub. They never learn what is in it, they do not want your name, and they will not release it without the stub — not because they are unhelpful, but because a cloakroom that gives your bag to anyone with a good story is not a cloakroom. That is this product's argument, and it is older than the category it is arguing with. It was chosen against a roll (seed bfd735a7) rather than by ranking, so this is not the first thing that came to mind. Six of the product's mechanics turned out to be ticket mechanics rather than metaphors for them: the counterfoil is the wrap and the key, "no article released without the ticket" is the absence of a reset link, a numbering machine that never repeats is the vault epoch and the item version, coloured stock is the owner/member distinction, an expiring claim check is the ten-minute handoff, and the perforation is the client/server line. WHAT LANDED. theme.css is now CONTRA's, recorded in vendoredIsMine so the pin test stops calling it drift and rastrillo doctor leaves it alone. tokens.css stays the library's: structure and components still upgrade with the module, and only colour, type and shape are ours to defend. One stock (eau-de-nil, emphatically not cream), one ink, and one vermilion that is spent on exactly two things — the numbering, and what cannot be undone. Every pair is measured in both schemes in the file header, and the tightest text pair is 4.89:1 against a 4.5:1 floor. contra.css is new, and it exists because the templates write bare HTML rather than rastrillo's component classes: without it every control on the page is a browser default. Five devices, each with a rule about where it may appear — the field box, the numbering, the tear-line, the stamp, the stock code. The tear-line is the one worth arguing about. Exactly one per screen, and only on the real boundary: above it what this server holds and could hand to a thief, below it what only this browser has ever seen. Every product in this category has that boundary and most would find it awkward to draw honestly. The display face is League Gothic, self-hosted at 10KB with its OFL beside it. Same-origin on purpose: a font from a CDN is one more party the browser trusts on a page that holds vault keys. Light is primary, and that is a decision about a scene rather than a taste — the moment this product is most needed is a person at a desk in daylight with a printer running. Dark is complete, not an inversion. Copy is untouched. index.html's prose is a reviewed artifact and two of its sentences have gone stale as the ladder and the kit PDFs shipped; that is a copy review, not a design change, and it is not smuggled in here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
8 files changed,
+976
−131
.impeccable/world-payload.json+53 −0internal/contra/static/contra.css+583 −0internal/contra/static/fonts/OFL.txt+93 −0internal/contra/static/fonts/leaguegothic-latin.woff2binaryinternal/contra/static/theme.css+208 −130internal/contra/templates/layout.html+19 −0internal/contra/templates/vault.html+10 −0internal/contratest/vendored_test.go+10 −1
diff --git a/.impeccable/world-payload.json b/.impeccable/world-payload.json| new file mode 100644 |
| index 0000000..7a57cff |
| --- /dev/null |
| +++ b/.impeccable/world-payload.json |
| @@ -0,0 +1,53 @@ |
| +{ |
| + "title": "Choose CONTRA's visual world", |
| + "question": "CONTRA has been running rastrillo's `plain` placeholder. The roll assigned Left Luggage. Keep it, take an alternate, or re-roll.", |
| + "options": [ |
| + { |
| + "id": "assigned", |
| + "label": "Left Luggage", |
| + "kicker": "THE ROLL", |
| + "lineage": "Station cloakroom and left-luggage ticketing: counterfoils, numbering machines, coloured ticket stock", |
| + "thesis": "The cloakroom holds your bag, never learns what is in it, and will not release it without the stub.", |
| + "palette": ["#bcd4c4", "#141210", "#d8341f", "#e0c88a"], |
| + "materials": ["coloured ticket stock", "letterpress rules and field boxes", "perforation", "Bates numbering"], |
| + "viewport": "A pale ticket-stock page, square-cut, ruled field boxes with small letterspaced caps printed above each rule. One perforation line runs across the screen: above it, what the server holds (opaque ids, counts, times); below it, what only this browser knows. Tabular numerals carry every count.", |
| + "risk": "Ticket printing slides into twee vintage the moment it gets texture. No distress, no torn-paper shadows, no rotated stamps; the perforation appears exactly once per screen, on the one real boundary." |
| + }, |
| + { |
| + "id": "challenger-sevenseg", |
| + "label": "Seven Segment", |
| + "lineage": "Bedside clocks, price totems, scoreboards: one eight-cell mask where unlit segments are designed too", |
| + "thesis": "Absence is drawn as deliberately as light; place never moves, only which segments are lit.", |
| + "case": "The strongest challenger. Its central rule is CONTRA's thesis, and items are already quantised into fixed 256-byte buckets, so the rigid mask is literal rather than metaphorical. It loses on the audience's scene and on the kit: the product's only designed artefact is two printed sheets, and a PDF cannot be an emissive display.", |
| + "hero": "https://impeccable.style/worlds/cards/signals-instruments-seven-segment-alarm-clock-hero.webp", |
| + "board": "https://impeccable.style/worlds/cards/signals-instruments-seven-segment-alarm-clock.webp" |
| + }, |
| + { |
| + "id": "challenger-kraftwerk", |
| + "label": "Man-Machine", |
| + "lineage": "Neon line diagrams on a strict traffic grid; one hazard red carries every accent", |
| + "thesis": "The key hierarchy drawn as a wiring schematic, current lighting the path it reaches.", |
| + "case": "Fuses well - one root key and three peer wraps really is a wiring diagram, and one hazard red matches 'say the cost where the promise is made'. Against it: near-black plus neon is the named AI default for this category, and its traffic-signal palette collides with the status tones the app already owes.", |
| + "hero": "https://impeccable.style/worlds/cards/kraftwerk-man-machine-hero.webp", |
| + "board": "https://impeccable.style/worlds/cards/kraftwerk-man-machine.webp" |
| + }, |
| + { |
| + "id": "challenger-cape", |
| + "label": "Drawcord", |
| + "lineage": "Matte black silk with one gold cord that is both the accent and the only control", |
| + "thesis": "One pull turns a flat plane into structure; nothing else in the surface may borrow the cord's colour.", |
| + "case": "A genuinely strong single-affordance grammar - the cord is the key, and unlocking is a transformation rather than a page change. Fuses poorly on audience: a team migrating credentials does not live in couture, and black-silk-plus-gold reads as luxury crypto wallet, this category's worst neighbour.", |
| + "hero": "https://impeccable.style/worlds/cards/textiles-weave-drape-fashion-drawcord-transforming-cape-hero.webp", |
| + "board": "https://impeccable.style/worlds/cards/textiles-weave-drape-fashion-drawcord-transforming-cape.webp" |
| + } |
| + ], |
| + "reroll": true, |
| + "canon": true, |
| + "canonCard": { |
| + "label": "The category standard", |
| + "thesis": "What a password manager ships, executed impeccably: deep navy, rounded cards, a lock mark, a workhorse UI sans, green for safe.", |
| + "viewport": "The arrangement a person arriving from 1Password expects, at the craft level of the best of them.", |
| + "risk": "Nothing about the surface would tell anyone this product is different from the ones it is arguing with." |
| + }, |
| + "steer": true |
| +} |
diff --git a/internal/contra/static/contra.css b/internal/contra/static/contra.css| new file mode 100644 |
| index 0000000..6635dc1 |
| --- /dev/null |
| +++ b/internal/contra/static/contra.css |
| @@ -0,0 +1,583 @@ |
| +/* contra.css — the grammar of the ticket. |
| + |
| + theme.css is colour, type and shape: the tokens tokens.css consumes. |
| + This file is CONTRA's own, and it exists because the app's templates |
| + write bare HTML — <h1>, <form>, <input>, <ul> — rather than |
| + rastrillo's component classes. Without it the world stops at the |
| + palette and every control on the page is a browser default, which is |
| + the visual equivalent of shipping the crypto and skipping the UI. |
| + |
| + Loaded after theme.css, last of the three, so it wins. |
| + |
| + ── The five devices, and where each may appear ─────────────────────── |
| + 1. THE FIELD BOX. A small letterspaced cap label, a rule, and the |
| + value sitting on the rule. Every input in the app is this; there |
| + are no boxed text fields, because a ticket has ruled fields. |
| + 2. THE NUMBERING. Anything counted prints large, tabular, in the |
| + second colour. Item counts, versions, epochs, the handoff code. |
| + Large only — theme.css measures why. |
| + 3. THE TEAR-LINE. Exactly one per screen, on the real boundary: |
| + above it what the server holds, below it what only this browser |
| + knows. Never decoration, never twice. |
| + 4. THE STAMP. Status pills, and the notices that state a cost. |
| + 5. THE STOCK CODE. Manila for a vault shared with you, the way a |
| + cloakroom prints luggage tickets on different stock from coats. |
| + |
| + ── What this file refuses ──────────────────────────────────────────── |
| + No cards. No rounded corners. No texture, no distress, no torn-paper |
| + shadows, no rotated stamps, no fake staples or perforated GIFs |
| + scattered as decoration. Ticket printing turns twee the moment it |
| + gets grain; what keeps it institutional is that every mark is doing a |
| + job. This is a station cloakroom, not a fairground. |
| + ──────────────────────────────────────────────────────────────────── */ |
| + |
| +/* The display voice: League Gothic, an Alternate Gothic revival and the |
| + condensed face poster and ticket printing has been set in for a |
| + century. Self-hosted, same-origin, 10KB — this app fetches nothing |
| + from anywhere, and a webfont from a CDN would be one more party the |
| + browser trusts on a page that holds vault keys. |
| + |
| + OFL 1.1; the licence ships beside the file at static/fonts/OFL.txt. |
| + swap, because a display face that blocks the first paint of an |
| + unlock screen has its priorities wrong. */ |
| +@font-face { |
| + font-family: "League Gothic"; |
| + font-style: normal; |
| + font-weight: 400; |
| + font-display: swap; |
| + src: url("/static/fonts/leaguegothic-latin.woff2") format("woff2"); |
| +} |
| + |
| +:root { |
| + --contra-display: "League Gothic", "Oswald", "Archivo Narrow", var(--rst-font); |
| + |
| + /* The numbering machine's ink. LARGE ONLY — 24px up, or 18.66px bold. |
| + theme.css carries the measurement and the reason. */ |
| + --contra-ink-2: light-dark(#b3301a, #ff7a5c); |
| + |
| + /* Manila: the category code, and it codes one thing. */ |
| + --contra-stock-2: light-dark(#ddc78d, #3a3018); |
| + |
| + /* The perforation's pitch. One place, so every tear-line in the app |
| + is the same machine. */ |
| + --contra-perf: 12px; |
| +} |
| + |
| +/* ── Browser surfaces ───────────────────────────────────────────────── |
| + The parts we did not draw still carry the design. Left alone these |
| + ship with defaults that belong to no design system, and on a page |
| + whose whole argument is deliberateness that shows. */ |
| + |
| +::selection { |
| + background: var(--contra-stock-2); |
| + color: var(--rst-text); |
| +} |
| + |
| +:root { |
| + scrollbar-color: var(--rst-line-strong) var(--rst-surface-2); |
| + scrollbar-width: thin; |
| +} |
| + |
| +input, |
| +textarea, |
| +select { |
| + caret-color: var(--contra-ink-2); |
| +} |
| + |
| +/* Links are text that underlines, printed rather than coloured. The |
| + offset and thickness are set because the defaults are a browser's |
| + guess at a font we did not pick. */ |
| +a { |
| + color: var(--rst-accent); |
| + text-decoration-thickness: 1px; |
| + text-underline-offset: 0.18em; |
| +} |
| + |
| +a:hover { |
| + text-decoration-thickness: 2px; |
| +} |
| + |
| +/* ── The sheet ────────────────────────────────────────────────────── */ |
| + |
| +/* The page is one piece of stock. tokens.css already centres the column; |
| + this gives it the ticket's cut edge on the wide layout, where there is |
| + ground either side to cut it out of. */ |
| +[rst-page] { |
| + border-inline: 1px solid var(--rst-line); |
| + max-width: 60rem; |
| +} |
| + |
| +/* The brand is the ticket's overprint: the issuing office's name, set |
| + in the display face, letterspaced, small. */ |
| +[rst-shell-brand] { |
| + font-family: var(--contra-display); |
| + font-size: 1.5rem; |
| + letter-spacing: 0.14em; |
| + line-height: 1; |
| + text-decoration: none; |
| + text-transform: uppercase; |
| +} |
| + |
| +/* Headings are set like a ticket's title: condensed, uppercase, tight, |
| + and the h1 carries the heaviest rule on the sheet under it. More |
| + space above a heading than below it, throughout. */ |
| +h1, |
| +h2, |
| +h3 { |
| + font-family: var(--contra-display); |
| + font-weight: 400; |
| + letter-spacing: 0.04em; |
| + line-height: 1; |
| + text-transform: uppercase; |
| + text-wrap: balance; |
| +} |
| + |
| +/* Caps are for labels, not for sentences. A ticket prints its field |
| + names in capitals and its title in the display face, and this app's |
| + h1 is sometimes a whole sentence — "A password manager where the |
| + server knows nothing about you" — which in condensed capitals is a |
| + poster nobody reads and a screen reader may spell out. So the title |
| + keeps its sentence case and gets its character from the face and the |
| + rule under it; h2 and h3 are section labels and stay capitalised. */ |
| +h1 { |
| + border-bottom: 3px solid var(--rst-header-rule); |
| + font-size: clamp(2.4rem, 6vw, 3.6rem); |
| + letter-spacing: 0.01em; |
| + margin: 0 0 var(--rst-sp-5); |
| + padding-bottom: var(--rst-sp-2); |
| + text-transform: none; |
| +} |
| + |
| +h2 { |
| + font-size: 1.9rem; |
| + margin: var(--rst-sp-6) 0 var(--rst-sp-3); |
| +} |
| + |
| +h3 { |
| + font-size: 1.35rem; |
| + margin: var(--rst-sp-5) 0 var(--rst-sp-2); |
| +} |
| + |
| +/* A reading measure for prose, because this product explains itself |
| + constantly and the column is sized for forms and lists. */ |
| +[rst-page] > p, |
| +[rst-page] > section > p, |
| +form > p:not(:has(input, textarea, select, button)) { |
| + max-width: 68ch; |
| +} |
| + |
| +/* ── Device 1: the field box ────────────────────────────────────────── |
| + A label printed in small letterspaced caps, a rule, and the value |
| + sitting on the rule. No boxes: a ticket rules its fields, it does not |
| + draw containers round them. |
| + |
| + The rule is the ink rather than the hairline, which is what makes it |
| + a control boundary rather than a divider — 12.6:1 in light, well past |
| + 1.4.11's 3:1. */ |
| + |
| +label { |
| + color: var(--rst-text-muted); |
| + display: block; |
| + font-size: var(--rst-fs-xs); |
| + font-weight: 600; |
| + letter-spacing: 0.12em; |
| + margin-bottom: var(--rst-sp-1); |
| + text-transform: uppercase; |
| +} |
| + |
| +/* A checkbox's label is a sentence, not a field name, so it opts out of |
| + the caps entirely. */ |
| +label:has(input[type="checkbox"]), |
| +label:has(input[type="radio"]) { |
| + color: var(--rst-text); |
| + display: flex; |
| + font-size: var(--rst-fs-base); |
| + font-weight: 400; |
| + gap: var(--rst-sp-2); |
| + letter-spacing: normal; |
| + line-height: 1.5; |
| + max-width: 68ch; |
| + text-transform: none; |
| +} |
| + |
| +input[type="text"], |
| +input[type="email"], |
| +input[type="password"], |
| +input[type="file"], |
| +input:not([type]), |
| +textarea, |
| +select { |
| + background: transparent; |
| + border: 0; |
| + border-bottom: 2px solid var(--rst-line-strong); |
| + border-radius: 0; |
| + color: var(--rst-text); |
| + font: inherit; |
| + inline-size: 100%; |
| + max-inline-size: 32rem; |
| + padding: var(--rst-sp-2) 0; |
| +} |
| + |
| +textarea { |
| + min-block-size: 6rem; |
| + resize: vertical; |
| +} |
| + |
| +input:focus, |
| +textarea:focus, |
| +select:focus { |
| + border-bottom-color: var(--contra-ink-2); |
| + outline: 2px solid var(--rst-accent); |
| + outline-offset: 3px; |
| +} |
| + |
| +input::placeholder, |
| +textarea::placeholder { |
| + color: var(--rst-text-faint); |
| +} |
| + |
| +input[type="checkbox"], |
| +input[type="radio"] { |
| + accent-color: var(--contra-ink-2); |
| + block-size: 1.1rem; |
| + flex: none; |
| + inline-size: 1.1rem; |
| + margin-block-start: 0.15rem; |
| +} |
| + |
| +/* Form paragraphs are the ticket's field rows. */ |
| +form > p { |
| + margin: 0 0 var(--rst-sp-4); |
| +} |
| + |
| +/* ── Buttons ────────────────────────────────────────────────────────── |
| + The primary is a solid rectangle of ink with letterspaced caps: what |
| + a ticket office rubber-stamps. Everything else is the same rectangle |
| + ruled rather than filled. */ |
| + |
| +button, |
| +[rst-btn] { |
| + background: var(--rst-accent); |
| + border: 2px solid var(--rst-accent); |
| + border-radius: 0; |
| + color: var(--rst-on-accent); |
| + cursor: pointer; |
| + font: inherit; |
| + font-size: var(--rst-fs-sm); |
| + font-weight: 700; |
| + letter-spacing: 0.14em; |
| + padding: var(--rst-sp-2) var(--rst-sp-5); |
| + text-transform: uppercase; |
| +} |
| + |
| +button:hover { |
| + background: var(--rst-accent-strong); |
| + border-color: var(--rst-accent-strong); |
| +} |
| + |
| +button:focus-visible { |
| + outline: 2px solid var(--rst-accent); |
| + outline-offset: 3px; |
| +} |
| + |
| +button[type="button"], |
| +button[data-cancel], |
| +button[data-edit], |
| +button[data-delete] { |
| + background: transparent; |
| + color: var(--rst-accent); |
| +} |
| + |
| +button[type="button"]:hover, |
| +button[data-cancel]:hover, |
| +button[data-edit]:hover, |
| +button[data-delete]:hover { |
| + background: var(--rst-accent-soft); |
| + border-color: var(--rst-accent); |
| +} |
| + |
| +/* The row buttons on an item are marginal notes, not stamps. */ |
| +button[data-edit], |
| +button[data-delete] { |
| + border-width: 1px; |
| + padding: 0.15rem var(--rst-sp-2); |
| +} |
| + |
| +button:disabled { |
| + border-color: var(--rst-line-strong); |
| + background: var(--rst-surface-2); |
| + color: var(--rst-text-faint); |
| + cursor: not-allowed; |
| +} |
| + |
| +/* ── Device 2: the numbering ────────────────────────────────────────── |
| + Anything counted prints large, tabular, in the second colour. The |
| + numbering machine only ever counts up, which is the same promise the |
| + vault epoch and the item version make. */ |
| + |
| +[data-number] { |
| + color: var(--contra-ink-2); |
| + display: block; |
| + font-family: var(--contra-display); |
| + font-size: 3rem; |
| + font-variant-numeric: tabular-nums; |
| + letter-spacing: 0.02em; |
| + line-height: 0.9; |
| +} |
| + |
| +/* The handoff code IS a claim check: eight symbols, ten minutes, and the |
| + only thing that gets the Account Key across. It prints at the size a |
| + cloakroom prints a stub number, because a person is reading it off one |
| + screen and typing it into another. */ |
| +[data-code] { |
| + color: var(--contra-ink-2); |
| + display: block; |
| + font-family: var(--contra-display); |
| + font-size: clamp(3rem, 12vw, 5rem); |
| + font-variant-numeric: tabular-nums; |
| + letter-spacing: 0.14em; |
| + line-height: 1; |
| + margin: var(--rst-sp-3) 0; |
| +} |
| + |
| +/* The three-rung ladder, numbered by the machine. Section numbers are |
| + usually decoration; these are not. The rungs are in order of what |
| + works with the least, and the last one always works — which is the |
| + whole promise of the screen, and it is carried by the order. */ |
| +[data-rungs] { |
| + counter-reset: rung; |
| + list-style: none; |
| + margin: var(--rst-sp-5) 0; |
| + padding: 0; |
| +} |
| + |
| +[data-rungs] > li { |
| + border-top: 1px solid var(--rst-line); |
| + counter-increment: rung; |
| + padding: var(--rst-sp-4) 0 var(--rst-sp-4) 3.5rem; |
| + position: relative; |
| +} |
| + |
| +[data-rungs] > li::before { |
| + color: var(--contra-ink-2); |
| + content: counter(rung); |
| + font-family: var(--contra-display); |
| + font-size: 2.6rem; |
| + font-variant-numeric: tabular-nums; |
| + inset-block-start: var(--rst-sp-4); |
| + inset-inline-start: 0; |
| + line-height: 0.8; |
| + position: absolute; |
| +} |
| + |
| +[data-rungs] > li > h3 { |
| + margin-block-start: 0; |
| +} |
| + |
| +[data-number-label] { |
| + color: var(--rst-text-muted); |
| + display: block; |
| + font-size: var(--rst-fs-xs); |
| + font-weight: 600; |
| + letter-spacing: 0.12em; |
| + text-transform: uppercase; |
| +} |
| + |
| +/* Opaque identifiers are machine-printed serials: the account id you |
| + hand somebody, the build stamp, a handoff code. Monospace here is |
| + measurement, not costume — these are hex and Crockford Base32, and |
| + the reader has to transcribe them by hand. */ |
| +code { |
| + background: var(--rst-surface-2); |
| + font-family: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace; |
| + font-size: 0.95em; |
| + font-variant-numeric: tabular-nums; |
| + letter-spacing: 0.06em; |
| + padding: 0.1em 0.35em; |
| + word-break: break-all; |
| +} |
| + |
| +/* ── Device 3: the tear-line ────────────────────────────────────────── |
| + Exactly one per screen, and only on the real boundary. Above it is |
| + what this server holds — opaque ids, counts, times. Below it is what |
| + only this browser can read. |
| + |
| + Every product in this category has this line. Most of them would find |
| + it embarrassing to draw honestly, which is the reason to draw it. |
| + |
| + A real perforation, not a dashed border: round holes at a fixed pitch |
| + with the stock showing through. The caption sits on the line the way |
| + a printed instruction does. */ |
| + |
| +[data-tear] { |
| + align-items: center; |
| + display: flex; |
| + gap: var(--rst-sp-3); |
| + margin: var(--rst-sp-6) 0 var(--rst-sp-5); |
| +} |
| + |
| +[data-tear]::before, |
| +[data-tear]::after { |
| + background-image: radial-gradient( |
| + circle at calc(var(--contra-perf) / 2) 50%, |
| + var(--rst-line-strong) 1.7px, |
| + transparent 1.8px |
| + ); |
| + background-repeat: repeat-x; |
| + background-size: var(--contra-perf) 100%; |
| + block-size: 9px; |
| + content: ""; |
| + flex: 1; |
| +} |
| + |
| +[data-tear] span { |
| + color: var(--rst-text-faint); |
| + flex: none; |
| + font-size: var(--rst-fs-xs); |
| + font-weight: 600; |
| + letter-spacing: 0.12em; |
| + text-align: center; |
| + text-transform: uppercase; |
| +} |
| + |
| +/* ── Device 4: the stamp ────────────────────────────────────────────── |
| + A notice that states a cost. Not a coloured left border — a stamped |
| + block with a rule across the top, which is how a ticket prints a |
| + condition of carriage. */ |
| + |
| +[data-kind="warning"], |
| +[data-notice] { |
| + background: var(--rst-tone-negative-bg); |
| + border-top: 3px solid var(--rst-tone-negative-fg); |
| + color: var(--rst-text); |
| + margin: var(--rst-sp-5) 0; |
| + max-width: 68ch; |
| + padding: var(--rst-sp-4); |
| +} |
| + |
| +[data-kind="warning"] strong, |
| +[data-notice] strong { |
| + color: var(--rst-tone-negative-fg); |
| +} |
| + |
| +/* ── Device 5: the stock code ───────────────────────────────────────── |
| + Manila, and it means one thing: this vault belongs to somebody else |
| + and you are on the list. Colour as a category, never as decoration. */ |
| + |
| +[data-shared] { |
| + background: var(--contra-stock-2); |
| + padding: var(--rst-sp-1) var(--rst-sp-2); |
| +} |
| + |
| +/* ── Lists: the ticket's line items ─────────────────────────────────── |
| + Ruled rows, not cards. A card would be a second sheet of paper. */ |
| + |
| +[data-items], |
| +[data-members] { |
| + border-top: 1px solid var(--rst-accent); |
| + list-style: none; |
| + margin: var(--rst-sp-4) 0; |
| + max-width: 100%; |
| + padding: 0; |
| +} |
| + |
| +[data-items] > li, |
| +[data-members] > li { |
| + align-items: baseline; |
| + border-bottom: 1px solid var(--rst-line); |
| + display: flex; |
| + flex-wrap: wrap; |
| + gap: var(--rst-sp-2); |
| + padding: var(--rst-sp-3) 0; |
| +} |
| + |
| +[data-items] > li > strong { |
| + flex: 1 1 12rem; |
| + font-weight: 700; |
| +} |
| + |
| +[data-items] > li > span { |
| + color: var(--rst-text-muted); |
| + flex: 1 1 12rem; |
| +} |
| + |
| +/* ── Status ─────────────────────────────────────────────────────────── |
| + A live region that reports what just happened. Set like a printed |
| + endorsement rather than a toast: it belongs to the sheet. */ |
| + |
| +[data-status], |
| +[data-share-status] { |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-sm); |
| + letter-spacing: 0.06em; |
| + min-block-size: 1.4em; |
| + text-transform: uppercase; |
| +} |
| + |
| +/* ── Navigation ───────────────────────────────────────────────────── */ |
| + |
| +[rst-shell-nav] a { |
| + display: block; |
| + font-size: var(--rst-fs-sm); |
| + font-weight: 600; |
| + letter-spacing: 0.08em; |
| + padding: var(--rst-sp-2) 0; |
| + text-decoration: none; |
| + text-transform: uppercase; |
| +} |
| + |
| +[rst-shell-nav] a:hover { |
| + text-decoration: underline; |
| +} |
| + |
| +/* ── The one authored moment ────────────────────────────────────────── |
| + The world's native motion is the tear. So the single piece of motion |
| + in this app is the vault opening: the sealed half below the line is |
| + revealed once, on unlock, easing out the way paper separates — fast |
| + at the start, settling at the end. |
| + |
| + Nothing else animates. Scattered hover transitions would make this |
| + read as an effect rather than as the moment it is. |
| + |
| + The default is visible: an engine that ignores the animation, or a |
| + reader who asked for less motion, gets the content immediately. */ |
| + |
| +@keyframes contra-tear { |
| + from { |
| + clip-path: inset(0 0 100% 0); |
| + opacity: 0; |
| + } |
| + to { |
| + clip-path: inset(0 0 0 0); |
| + opacity: 1; |
| + } |
| +} |
| + |
| +[data-vault]:not([hidden]) { |
| + animation: contra-tear 620ms cubic-bezier(0.16, 1, 0.3, 1) both; |
| +} |
| + |
| +@media (prefers-reduced-motion: reduce) { |
| + [data-vault]:not([hidden]) { |
| + animation: none; |
| + } |
| +} |
| + |
| +/* ── Print ──────────────────────────────────────────────────────────── |
| + The kit is generated as a PDF in the browser and does not come |
| + through here, but a person who prints a screen should still get |
| + something a cloakroom would recognise: black on white, no chrome. */ |
| + |
| +@media print { |
| + [rst-shell-bar], |
| + [rst-shell-rail], |
| + [rst-shell-foot], |
| + [rst-skip] { |
| + display: none; |
| + } |
| + |
| + [rst-page] { |
| + border: 0; |
| + max-width: none; |
| + } |
| +} |
diff --git a/internal/contra/static/fonts/OFL.txt b/internal/contra/static/fonts/OFL.txt| new file mode 100644 |
| index 0000000..06b467d |
| --- /dev/null |
| +++ b/internal/contra/static/fonts/OFL.txt |
| @@ -0,0 +1,93 @@ |
| +Copyright 2010 The League Gothic Project Authors (https://github.com/theleagueof/league-gothic) |
| + |
| +This Font Software is licensed under the SIL Open Font License, Version 1.1. |
| +This license is copied below, and is also available with a FAQ at: |
| +https://scripts.sil.org/OFL |
| + |
| + |
| +----------------------------------------------------------- |
| +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 |
| +----------------------------------------------------------- |
| + |
| +PREAMBLE |
| +The goals of the Open Font License (OFL) are to stimulate worldwide |
| +development of collaborative font projects, to support the font creation |
| +efforts of academic and linguistic communities, and to provide a free and |
| +open framework in which fonts may be shared and improved in partnership |
| +with others. |
| + |
| +The OFL allows the licensed fonts to be used, studied, modified and |
| +redistributed freely as long as they are not sold by themselves. The |
| +fonts, including any derivative works, can be bundled, embedded, |
| +redistributed and/or sold with any software provided that any reserved |
| +names are not used by derivative works. The fonts and derivatives, |
| +however, cannot be released under any other type of license. The |
| +requirement for fonts to remain under this license does not apply |
| +to any document created using the fonts or their derivatives. |
| + |
| +DEFINITIONS |
| +"Font Software" refers to the set of files released by the Copyright |
| +Holder(s) under this license and clearly marked as such. This may |
| +include source files, build scripts and documentation. |
| + |
| +"Reserved Font Name" refers to any names specified as such after the |
| +copyright statement(s). |
| + |
| +"Original Version" refers to the collection of Font Software components as |
| +distributed by the Copyright Holder(s). |
| + |
| +"Modified Version" refers to any derivative made by adding to, deleting, |
| +or substituting -- in part or in whole -- any of the components of the |
| +Original Version, by changing formats or by porting the Font Software to a |
| +new environment. |
| + |
| +"Author" refers to any designer, engineer, programmer, technical |
| +writer or other person who contributed to the Font Software. |
| + |
| +PERMISSION & CONDITIONS |
| +Permission is hereby granted, free of charge, to any person obtaining |
| +a copy of the Font Software, to use, study, copy, merge, embed, modify, |
| +redistribute, and sell modified and unmodified copies of the Font |
| +Software, subject to the following conditions: |
| + |
| +1) Neither the Font Software nor any of its individual components, |
| +in Original or Modified Versions, may be sold by itself. |
| + |
| +2) Original or Modified Versions of the Font Software may be bundled, |
| +redistributed and/or sold with any software, provided that each copy |
| +contains the above copyright notice and this license. These can be |
| +included either as stand-alone text files, human-readable headers or |
| +in the appropriate machine-readable metadata fields within text or |
| +binary files as long as those fields can be easily viewed by the user. |
| + |
| +3) No Modified Version of the Font Software may use the Reserved Font |
| +Name(s) unless explicit written permission is granted by the corresponding |
| +Copyright Holder. This restriction only applies to the primary font name as |
| +presented to the users. |
| + |
| +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font |
| +Software shall not be used to promote, endorse or advertise any |
| +Modified Version, except to acknowledge the contribution(s) of the |
| +Copyright Holder(s) and the Author(s) or with their explicit written |
| +permission. |
| + |
| +5) The Font Software, modified or unmodified, in part or in whole, |
| +must be distributed entirely under this license, and must not be |
| +distributed under any other license. The requirement for fonts to |
| +remain under this license does not apply to any document created |
| +using the Font Software. |
| + |
| +TERMINATION |
| +This license becomes null and void if any of the above conditions are |
| +not met. |
| + |
| +DISCLAIMER |
| +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, |
| +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF |
| +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT |
| +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE |
| +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, |
| +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL |
| +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING |
| +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM |
| +OTHER DEALINGS IN THE FONT SOFTWARE. |
diff --git a/internal/contra/static/fonts/leaguegothic-latin.woff2 b/internal/contra/static/fonts/leaguegothic-latin.woff2| new file mode 100644 |
| index 0000000..40f8447 |
| Binary files /dev/null and b/internal/contra/static/fonts/leaguegothic-latin.woff2 differ |
diff --git a/internal/contra/static/theme.css b/internal/contra/static/theme.css| index 6df33a1..f70b401 100644 |
| --- a/internal/contra/static/theme.css |
| +++ b/internal/contra/static/theme.css |
| @@ -1,26 +1,74 @@ |
| -/* plain — the skeleton: greyscale, system type, almost no shape. |
| - Structure lives in tokens.css; this file is colour, type and shape. |
| - |
| - The look: nothing. plain is the theme you pick when you intend to |
| - write your own — a working, accessible, entirely unstyled-looking app |
| - that shows you the structure and none of the taste, so the first |
| - colour you add is visibly yours. There is no accent hue: the accent |
| - is near-black in light and near-white in dark, which is to say a link |
| - reads as text with an underline on hover and a primary button reads as |
| - a filled rectangle. That is deliberate. Add the hue. |
| - |
| - Status still has to signal. A skeleton that renders an error the same |
| - grey as a success is not minimal, it is broken, so the four tone |
| - pairs keep conventional hues at very low chroma — a green you would |
| - call grey-green, an ochre, a brick — each still clearing 4.5:1 on its |
| - own tint. Colour is never the only channel (the pills carry words), |
| - but it is not thrown away either. |
| - |
| - Shadows are real and minimal. tokens.css paints with |
| - var(--rst-shadow-*) unconditionally and a theme cannot opt out of a |
| - property it must declare, so "no shadows" here means one hairline |
| - layer at low alpha rather than a value the CSS parser would drop. |
| - You get an edge, not a lift. |
| +/* left-luggage — CONTRA's theme: ticket stock, black ink, one vermilion |
| + for the numbering. Structure lives in tokens.css; this file is colour, |
| + type and shape. |
| + |
| + Replaces rastrillo's `plain`, which is the theme you ship on while you |
| + work out what the app looks like. It is recorded as a deliberate |
| + divergence in contratest/vendored_test.go (vendoredIsMine), so |
| + `rastrillo doctor` leaves it alone and the pin test stops treating it |
| + as drift. |
| + |
| + ── The world ───────────────────────────────────────────────────────── |
| + A station cloakroom. You hand over your bag; they hand you a numbered |
| + stub. They never learn what is in it, they do not want your name, and |
| + they will not release it without the stub — not because they are |
| + unhelpful, but because a cloakroom that gives your bag to anyone with |
| + a good story is not a cloakroom. That is this product's argument, and |
| + it is older than the category it is arguing with. |
| + |
| + So the material is left-luggage ticketing: coloured stock, black ink, |
| + ruled field boxes with small letterspaced caps printed above the rule, |
| + a numbering machine that only ever counts up, and one perforation |
| + where the ticket tears. |
| + |
| + Six of the product's mechanics are already ticket mechanics rather |
| + than metaphors for them: |
| + |
| + the counterfoil, two halves and one number the wrap and the key |
| + no article released without the ticket no reset, no override |
| + a numbering machine never repeats epoch, item version |
| + coloured stock IS the category code owner vs member vault |
| + a claim check that expires the 10-minute handoff |
| + the perforation client / server |
| + |
| + ── The palette, and why it is this one ─────────────────────────────── |
| + ONE STOCK, ONE INK, ONE SECOND COLOUR. Ticket printing has no third. |
| + |
| + The ground is eau-de-nil ticket stock, not white and emphatically not |
| + cream: cream is where a machine lands when it wants "paper" without |
| + deciding anything, and real cloakroom stock is a dyed pale green, blue |
| + or manila. The page IS the ticket, so --rst-bg and --rst-surface are |
| + the same value. There are no cards here; a card would be a second |
| + sheet of paper on a counter that only ever holds one. |
| + |
| + --rst-surface-2 is the printed tint block, the darker band a ticket |
| + uses to group a field — not a raised card, a flatter one. |
| + |
| + The ACCENT IS THE INK. Links and primary buttons are black on stock, |
| + because in this world the only thing printed in colour is the number. |
| + Which means the second colour lands exactly where the product's |
| + consequences are: the numbering (--contra-ink-2, in contra.css) and |
| + the negative tone. A vermilion this product prints is either counting |
| + something or telling you something cannot be undone. |
| + |
| + Manila (--contra-stock-2, contra.css) is the category code, and it |
| + codes one thing only: a vault shared with you, printed on different |
| + stock the way a cloakroom prints luggage tickets on different stock |
| + from coat tickets. Colour that means something categorically, or it |
| + does not get used. |
| + |
| + ── Shape ───────────────────────────────────────────────────────────── |
| + Square. Tickets are cut, not moulded. --rst-radius is 0 and the pill |
| + radius stays only because tokens.css paints status pills with it and a |
| + theme cannot opt out of a property it must declare — the pills are the |
| + one round thing on the page, and they are round because a pill in this |
| + world is a rubber stamp. |
| + |
| + ── Depth ───────────────────────────────────────────────────────────── |
| + Ink on paper has no depth, so the shadows are the least a theme can |
| + declare and still be declaring something: one hairline layer. Nothing |
| + in this app floats. The overlay is the only real darkening, because a |
| + modal genuinely is a second sheet laid over the first. |
| ── One block, both schemes ─────────────────────────────────────────── |
| Every token is declared once, as light-dark(<light>, <dark>), under a |
| @@ -32,9 +80,15 @@ |
| light-dark() is a colour function: it may only stand where a <color> |
| is expected. So a shadow token wraps its colour in it and writes the |
| - geometry once, outside the call — `0 8px 24px light-dark(a, b)`, never |
| - `light-dark(0 8px 24px a, …)`, which no browser would accept as a |
| - box-shadow. |
| + geometry once, outside the call. |
| + |
| + LIGHT is the primary scheme, and that is a decision about a scene |
| + rather than a taste: the moment this product is most needed is a |
| + person at a desk in daylight with a printer running, holding two |
| + sheets of paper they were told can never be reissued. The only |
| + artefact CONTRA designs from scratch is printed, and a dark-first |
| + console would spend the whole product fighting its own kit. DARK is |
| + the same counter at night, and is complete, not an inversion. |
| ── WCAG 2.2 AA, measured ───────────────────────────────────────────── |
| Normal text needs 4.5:1; large text and UI component boundaries need |
| @@ -42,73 +96,90 @@ |
| both schemes. If you change a token, recompute its rows. |
| LIGHT ratio requirement |
| - --rst-text on --rst-surface 18.88 4.5:1 text |
| - --rst-text on --rst-bg 18.88 4.5:1 text |
| - --rst-text on --rst-surface-2 17.17 4.5:1 text |
| - --rst-text on --rst-accent-soft 16.13 4.5:1 text |
| - --rst-text-muted on --rst-surface 8.86 4.5:1 text |
| - --rst-text-muted on --rst-bg 8.86 4.5:1 text |
| - --rst-text-muted on --rst-surface-2 8.06 4.5:1 text |
| - --rst-text-muted on --rst-accent-soft 7.57 4.5:1 text |
| - --rst-text-faint on --rst-surface 6.01 4.5:1 text |
| - --rst-text-faint on --rst-bg 6.01 4.5:1 text |
| - --rst-text-faint on --rst-surface-2 5.46 4.5:1 text |
| - --rst-text-faint on --rst-accent-soft 5.13 4.5:1 text |
| - --rst-accent on --rst-surface 18.88 4.5:1 text + focus ring |
| - --rst-accent on --rst-bg 18.88 4.5:1 text + focus ring |
| - --rst-accent on --rst-surface-2 17.17 4.5:1 text |
| - --rst-accent on --rst-accent-soft 16.13 4.5:1 text |
| - --rst-on-accent on --rst-accent 18.88 4.5:1 button label |
| - --rst-on-accent on --rst-accent-strong 21.00 4.5:1 button label, hover |
| - --rst-line-strong on --rst-surface 4.54 3:1 control border |
| - --rst-line-strong on --rst-bg 4.54 3:1 control border |
| - --rst-line-strong on --rst-surface-2 4.13 3:1 control border |
| - pill neutral fg on its own tint 8.75 4.5:1 text |
| - pill positive fg on its own tint 6.46 4.5:1 text |
| - pill warning fg on its own tint 6.09 4.5:1 text |
| - pill negative fg on its own tint 7.55 4.5:1 text |
| - --rst-on-accent on negative fg (fill) 9.44 4.5:1 danger button label |
| + --rst-text on --rst-surface 12.60 4.5:1 text |
| + --rst-text on --rst-bg 12.60 4.5:1 text |
| + --rst-text on --rst-surface-2 10.50 4.5:1 text |
| + --rst-text on --rst-accent-soft 11.34 4.5:1 text |
| + --rst-text-muted on --rst-surface 6.67 4.5:1 text |
| + --rst-text-muted on --rst-bg 6.67 4.5:1 text |
| + --rst-text-muted on --rst-surface-2 5.56 4.5:1 text |
| + --rst-text-muted on --rst-accent-soft 6.00 4.5:1 text |
| + --rst-text-faint on --rst-surface 5.86 4.5:1 text |
| + --rst-text-faint on --rst-bg 5.86 4.5:1 text |
| + --rst-text-faint on --rst-surface-2 4.89 4.5:1 text |
| + --rst-text-faint on --rst-accent-soft 5.28 4.5:1 text |
| + --rst-accent on --rst-surface 12.60 4.5:1 text + focus ring |
| + --rst-accent on --rst-bg 12.60 4.5:1 text + focus ring |
| + --rst-accent on --rst-surface-2 10.50 4.5:1 text |
| + --rst-accent on --rst-accent-soft 11.34 4.5:1 text |
| + --rst-on-accent on --rst-accent 16.37 4.5:1 button label |
| + --rst-on-accent on --rst-accent-strong 18.69 4.5:1 button label, hover |
| + --rst-line-strong on --rst-surface 4.22 3:1 control border |
| + --rst-line-strong on --rst-bg 4.22 3:1 control border |
| + --rst-line-strong on --rst-surface-2 3.51 3:1 control border |
| + --contra-ink-2 on --rst-surface 4.29 3:1 numbering, large |
| + --contra-ink-2 on --rst-bg 4.29 3:1 numbering, large |
| + --contra-ink-2 on --rst-surface-2 3.57 3:1 numbering, large |
| + --contra-ink-2 on --contra-stock-2 3.76 3:1 numbering, large |
| + --rst-text on --contra-stock-2 11.06 4.5:1 text on manila |
| + pill neutral fg on its own tint 6.55 4.5:1 text |
| + pill positive fg on its own tint 5.68 4.5:1 text |
| + pill warning fg on its own tint 5.84 4.5:1 text |
| + pill negative fg on its own tint 5.40 4.5:1 text |
| + --rst-on-accent on negative fg (fill) 7.97 4.5:1 danger button label |
| DARK ratio requirement |
| - --rst-text on --rst-surface 16.01 4.5:1 text |
| - --rst-text on --rst-bg 17.12 4.5:1 text |
| - --rst-text on --rst-surface-2 16.60 4.5:1 text |
| - --rst-text on --rst-accent-soft 13.87 4.5:1 text |
| + --rst-text on --rst-surface 15.06 4.5:1 text |
| + --rst-text on --rst-bg 15.06 4.5:1 text |
| + --rst-text on --rst-surface-2 13.23 4.5:1 text |
| + --rst-text on --rst-accent-soft 11.97 4.5:1 text |
| --rst-text-muted on --rst-surface 8.55 4.5:1 text |
| - --rst-text-muted on --rst-bg 9.14 4.5:1 text |
| - --rst-text-muted on --rst-surface-2 8.86 4.5:1 text |
| - --rst-text-muted on --rst-accent-soft 7.40 4.5:1 text |
| - --rst-text-faint on --rst-surface 6.86 4.5:1 text |
| - --rst-text-faint on --rst-bg 7.33 4.5:1 text |
| - --rst-text-faint on --rst-surface-2 7.11 4.5:1 text |
| - --rst-text-faint on --rst-accent-soft 5.94 4.5:1 text |
| - --rst-accent on --rst-surface 16.01 4.5:1 text + focus ring |
| - --rst-accent on --rst-bg 17.12 4.5:1 text + focus ring |
| - --rst-accent on --rst-surface-2 16.60 4.5:1 text |
| - --rst-accent on --rst-accent-soft 13.87 4.5:1 text |
| - --rst-on-accent on --rst-accent 17.12 4.5:1 button label |
| - --rst-on-accent on --rst-accent-strong 19.17 4.5:1 button label, hover |
| - --rst-line-strong on --rst-surface 5.54 3:1 control border |
| - --rst-line-strong on --rst-bg 5.93 3:1 control border |
| - --rst-line-strong on --rst-surface-2 5.75 3:1 control border |
| - pill neutral fg on its own tint 9.05 4.5:1 text |
| - pill positive fg on its own tint 8.70 4.5:1 text |
| - pill warning fg on its own tint 8.82 4.5:1 text |
| - pill negative fg on its own tint 7.63 4.5:1 text |
| - --rst-on-accent on negative fg (fill) 8.93 4.5:1 danger button label |
| - |
| - The tightest pair in the set is --rst-text-faint on the accent tint — |
| - 5.13:1 in light, 5.94:1 in dark, against a 4.5:1 floor. Everything |
| - else has room, which is what a greyscale palette buys you. |
| + --rst-text-muted on --rst-bg 8.55 4.5:1 text |
| + --rst-text-muted on --rst-surface-2 7.51 4.5:1 text |
| + --rst-text-muted on --rst-accent-soft 6.80 4.5:1 text |
| + --rst-text-faint on --rst-surface 6.69 4.5:1 text |
| + --rst-text-faint on --rst-bg 6.69 4.5:1 text |
| + --rst-text-faint on --rst-surface-2 5.87 4.5:1 text |
| + --rst-text-faint on --rst-accent-soft 5.32 4.5:1 text |
| + --rst-accent on --rst-surface 15.06 4.5:1 text + focus ring |
| + --rst-accent on --rst-bg 15.06 4.5:1 text + focus ring |
| + --rst-accent on --rst-surface-2 13.23 4.5:1 text |
| + --rst-accent on --rst-accent-soft 11.97 4.5:1 text |
| + --rst-on-accent on --rst-accent 15.71 4.5:1 button label |
| + --rst-on-accent on --rst-accent-strong 18.45 4.5:1 button label, hover |
| + --rst-line-strong on --rst-surface 5.06 3:1 control border |
| + --rst-line-strong on --rst-bg 5.06 3:1 control border |
| + --rst-line-strong on --rst-surface-2 4.45 3:1 control border |
| + --contra-ink-2 on --rst-surface 6.90 3:1 numbering, large |
| + --contra-ink-2 on --rst-bg 6.90 3:1 numbering, large |
| + --contra-ink-2 on --rst-surface-2 6.06 3:1 numbering, large |
| + --contra-ink-2 on --contra-stock-2 5.07 3:1 numbering, large |
| + --rst-text on --contra-stock-2 11.06 4.5:1 text on manila |
| + pill neutral fg on its own tint 8.98 4.5:1 text |
| + pill positive fg on its own tint 8.77 4.5:1 text |
| + pill warning fg on its own tint 9.27 4.5:1 text |
| + pill negative fg on its own tint 8.12 4.5:1 text |
| + --rst-on-accent on negative fg (fill) 9.05 4.5:1 danger button label |
| + |
| + The tightest text pair is --rst-text-faint on the tint block, 4.89:1 |
| + in light against a 4.5:1 floor. |
| + |
| + --contra-ink-2 IS THE ONE TOKEN WITH A SIZE RULE, and it is here |
| + rather than in contra.css so that it sits with its measurements. At |
| + 4.29:1 in light it is a LARGE-TEXT colour: 24px and up, or 18.66px |
| + bold, which is what "the numbering machine prints big" means in CSS. |
| + Small red text is --rst-tone-negative-fg, which clears 4.5:1 on its |
| + own tint. Do not 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. |
| --rst-line is a hairline divider, not a control border, so 1.4.11's |
| 3:1 does not apply to it. Control borders use --rst-line-strong. |
| - --rst-header-rule is not in the table either, and deliberately so |
| - (design doc §6-v2.2): the page header's rule is decoration, not |
| - structure — the heading's size, weight and spacing carry the |
| - structure — so no contrast floor applies to it and it is not in |
| - ui/contrast_test.go's pair table. Do not argue it up to 3:1. |
| + --rst-header-rule is not in the table and deliberately so: the page |
| + header's rule is decoration, not structure. Here it is the ink, which |
| + makes it the heaviest rule on the page — that is the ticket's rule |
| + under its title, and it carries no contrast floor. |
| --rst-shadow-pop, --rst-shadow-knob, --rst-shadow-lift and |
| --rst-overlay are not in the table: they paint a translucent layer |
| @@ -119,58 +190,65 @@ |
| :root { |
| color-scheme: light dark; |
| - /* Type: whatever the machine already has. */ |
| + /* Type: the machine's own UI stack carries body, labels and controls. |
| + The display voice — the condensed gothic a ticket is set in — is a |
| + self-hosted face declared in contra.css, which is also where the |
| + rule about where it may appear lives. */ |
| --rst-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; |
| - /* Shape: barely rounded. A corner you notice is a decision plain has |
| - not made yet. */ |
| - --rst-radius: 4px; |
| - --rst-radius-sm: 3px; |
| + /* Shape: cut, not moulded. The pill radius survives because tokens.css |
| + paints status pills with it; in this world a pill is a rubber stamp, |
| + and a stamp is the one round thing on a ticket. */ |
| + --rst-radius: 0; |
| + --rst-radius-sm: 0; |
| --rst-radius-pill: 999px; |
| - /* Surfaces and lines */ |
| - --rst-bg: light-dark(#ffffff, #0f0f0f); |
| - --rst-surface: light-dark(#ffffff, #171717); |
| - --rst-surface-2: light-dark(#f4f4f4, #131313); |
| - --rst-line: light-dark(#e0e0e0, #2b2b2b); |
| - --rst-line-strong: light-dark(#767676, #8f8f8f); |
| + /* Surfaces and lines. bg and surface are the same value on purpose: |
| + the page is one sheet of stock, and a card would be a second sheet |
| + on a counter that only ever holds one. */ |
| + --rst-bg: light-dark(#c8dacf, #131a16); |
| + --rst-surface: light-dark(#c8dacf, #131a16); |
| + --rst-surface-2: light-dark(#b3c9bb, #1d2621); |
| + --rst-line: light-dark(#a3b8ab, #2c3830); |
| + --rst-line-strong: light-dark(#56655c, #7d8d83); |
| - /* The page header's rule. plain has no accent hue to fold in, so it |
| - is the line and nothing else — the same derived token every theme |
| - declares, resolving here to plain grey. Decorative: it carries no |
| - contrast floor. */ |
| - --rst-header-rule: var(--rst-line); |
| + /* The page header's rule, printed in the ink rather than the hairline: |
| + on a ticket the rule under the title is the heaviest mark on the |
| + sheet. Decorative, so no contrast floor applies. */ |
| + --rst-header-rule: var(--rst-accent); |
| /* Text */ |
| - --rst-text: light-dark(#111111, #f2f2f2); |
| - --rst-text-muted: light-dark(#4a4a4a, #b3b3b3); |
| - --rst-text-faint: light-dark(#636363, #a0a0a0); |
| - |
| - /* Accent: none. The "accent" is the text colour, so a link is text |
| - that underlines on hover and a primary button is a filled |
| - rectangle. */ |
| - --rst-accent: light-dark(#111111, #f2f2f2); |
| + --rst-text: light-dark(#121512, #e6efe8); |
| + --rst-text-muted: light-dark(#3a4741, #a9b8ae); |
| + --rst-text-faint: light-dark(#434f49, #93a399); |
| + |
| + /* Accent: the ink. A link is black text that underlines, a primary |
| + button is a black rectangle. The colour on this page is reserved |
| + for the number and for what cannot be undone. */ |
| + --rst-accent: light-dark(#121512, #e6efe8); |
| --rst-accent-strong: light-dark(#000000, #ffffff); |
| - --rst-accent-soft: light-dark(#ededed, #242424); |
| - --rst-on-accent: light-dark(#ffffff, #0f0f0f); |
| - |
| - /* Status tones: conventional hues held to very low chroma. Muted |
| - enough to read as greyscale at a glance, separated enough that an |
| - error is not a success. */ |
| - --rst-tone-neutral-fg: light-dark(#3f3f3f, #c8c8c8); |
| - --rst-tone-neutral-bg: light-dark(#eaeaea, #262626); |
| - --rst-tone-positive-fg: light-dark(#2e5b3e, #9fc8ac); |
| - --rst-tone-positive-bg: light-dark(#e4ebe6, #1a231d); |
| - --rst-tone-warning-fg: light-dark(#6a5320, #d3c08a); |
| - --rst-tone-warning-bg: light-dark(#efeadd, #262218); |
| - --rst-tone-negative-fg: light-dark(#7a2c2c, #dba3a3); |
| - --rst-tone-negative-bg: light-dark(#f0e3e3, #2a1c1c); |
| - |
| - /* Depth: one hairline layer. Real values, minimal effect. */ |
| - --rst-shadow-pop: 0 1px 3px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.5)); |
| - --rst-shadow-knob: 0 1px 1px light-dark(rgba(0, 0, 0, 0.22), rgba(0, 0, 0, 0.4)); |
| - --rst-shadow-lift: 0 1px 1px light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.3)); |
| - --rst-overlay: light-dark(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.6)); |
| + --rst-accent-soft: light-dark(#bcd0c3, #232e28); |
| + --rst-on-accent: light-dark(#eef3ee, #101512); |
| + |
| + /* Status tones: the rubber stamps. Held at low chroma so the page |
| + stays stock-and-ink at a glance, except negative, which is the |
| + product's second colour doing its other job. */ |
| + --rst-tone-neutral-fg: light-dark(#33403a, #c4d2c9); |
| + --rst-tone-neutral-bg: light-dark(#b9cec2, #232e28); |
| + --rst-tone-positive-fg: light-dark(#1d5134, #9fc9ac); |
| + --rst-tone-positive-bg: light-dark(#b6d2be, #16241b); |
| + --rst-tone-warning-fg: light-dark(#57430f, #d6c48a); |
| + --rst-tone-warning-bg: light-dark(#d5cc9c, #24211a); |
| + --rst-tone-negative-fg: light-dark(#8b2310, #f0a293); |
| + --rst-tone-negative-bg: light-dark(#e3c2b6, #2a1b18); |
| + |
| + /* Depth: ink on paper has none. One hairline layer, because tokens.css |
| + paints with these unconditionally and a theme cannot opt out of a |
| + property it must declare. You get an edge, not a lift. */ |
| + --rst-shadow-pop: 0 1px 0 light-dark(rgba(18, 21, 18, 0.22), rgba(0, 0, 0, 0.55)); |
| + --rst-shadow-knob: 0 1px 0 light-dark(rgba(18, 21, 18, 0.30), rgba(0, 0, 0, 0.5)); |
| + --rst-shadow-lift: 0 1px 0 light-dark(rgba(18, 21, 18, 0.12), rgba(0, 0, 0, 0.35)); |
| + --rst-overlay: light-dark(rgba(18, 21, 18, 0.55), rgba(0, 0, 0, 0.68)); |
| } |
| /* The whole toggle. Setting color-scheme re-resolves every light-dark() |
diff --git a/internal/contra/templates/layout.html b/internal/contra/templates/layout.html| index 1d6bee2..dd105a4 100644 |
| --- a/internal/contra/templates/layout.html |
| +++ b/internal/contra/templates/layout.html |
| @@ -6,6 +6,9 @@ |
| <title>{{block "title" .}}Hello{{end}}</title> |
| <link rel="stylesheet" href="{{asset "static/tokens.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/theme.css"}}"> |
| +{{/* CONTRA's own grammar, last of the three so it wins: theme.css is |
| + the palette, this is the ticket the palette is printed on. */}} |
| +<link rel="stylesheet" href="{{asset "static/contra.css"}}"> |
| <script defer src="{{asset "static/rastrillo.js"}}"></script> |
| {{iconAssets}} |
| <script defer src="{{asset "static/select.js"}}"></script> |
| @@ -17,6 +20,22 @@ |
| {{block "head" .}}{{end}} |
| </head> |
| <body> |
| +<!-- DIRECTION — CONTRA |
| + THESIS: A cloakroom holds your bag, never learns what is in it, and |
| + will not release it without the stub. Refuses this category's |
| + navy-shield-and-rounded-cards arrangement. |
| + OWN-WORLD: Eau-de-nil ticket stock, black ink, one vermilion spent |
| + only on the numbering and on what cannot be undone; manila codes a |
| + shared vault. Square-cut, no cards, condensed gothic caps, ruled |
| + fields instead of boxes. |
| + STORY: The visitor learns the server cannot help them, believes |
| + that is the protection rather than a gap, and prints the kit. |
| + FIRST VIEWPORT: Ticket stock; condensed uppercase title over a 3px |
| + ink rule; a ruled passphrase field; one perforation across the |
| + sheet with the sealed half below it. |
| + FORM: Left Luggage, 7th of 7 on the ordered list; seed bfd735a7. |
| + FINISH: unreviewed and undocumented is unfinished; this build ends |
| + with the finish review, the verdict, and DESIGN.md --> |
| {{/* The console shell — a brand-and-account bar across the top AND a |
| navigation rail down the side, which is the shape most admin |
| consoles are and the one the other three shells could not make. |
diff --git a/internal/contra/templates/vault.html b/internal/contra/templates/vault.html| index c93100e..821696c 100644 |
| --- a/internal/contra/templates/vault.html |
| +++ b/internal/contra/templates/vault.html |
| @@ -28,6 +28,16 @@ |
| <select id="vault-picker" data-vault-picker></select> |
| </p> |
| +{{/* The tear-line, and the only one in the app. Everything above it is |
| + what this server holds and could hand to anyone who stole it: a |
| + public key, opaque ids, counts, the times of day you connected. |
| + Everything below it was opened in this browser and the server has |
| + never seen a byte of it in the clear. |
| + |
| + 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 ↑ · opened only here ↓</span></div> |
| + |
| <div data-vault hidden></div> |
| {{/* Sharing. Hidden until the vault is open, because everything in it needs |
diff --git a/internal/contratest/vendored_test.go b/internal/contratest/vendored_test.go| index 920ed75..26600dd 100644 |
| --- a/internal/contratest/vendored_test.go |
| +++ b/internal/contratest/vendored_test.go |
| @@ -20,7 +20,16 @@ const vendoredTheme = "plain" |
| // record that the edit was on purpose, so the next reader (and the next |
| // upgrade) can tell it from drift. |
| var vendoredIsMine = map[string]bool{ |
| - // "tokens.css": true, |
| + // theme.css is CONTRA's own from 2026-09-05: `plain` is the theme |
| + // rastrillo scaffolds with while an app works out what it looks |
| + // like, and this app now knows. It is the left-luggage theme — |
| + // ticket stock, black ink, one vermilion for the numbering — and its |
| + // header carries the measured WCAG table for every pair. |
| + // |
| + // tokens.css is NOT ours: structure, spacing and the component rules |
| + // stay pinned to the library, so a rastrillo upgrade still lands |
| + // here and only the colour, type and shape are the app's to defend. |
| + "theme.css": true, |
| } |
| // The scaffold delivered these files once; they are app-owned from |