The flyposted bill
The scaffold's own theme header called day "the theme an app ships on while it works out what it wants to look like". This is what it worked out. A sheet pasted on a wall: acid stock, two inks, three perforated tabs across the foot. Taking a tab is the primary action, so the sort happens in your hand before anyone asks for an address — there is no button on the landing page at all. The stance pages drop to bone because yellow is a shout and the replies are the part you read, and the tab you took is carried over with its torn edge. The green is not a colour anyone picked. Cyan multiplied into yellow stock is what a second ink does, and where it crosses the blue letters the ink builds and darkens. That is also why it sits beside carlosframework.com's teal as a relative rather than a copy. Two faces self-hosted, because rastrillo serves default-src 'self' and a Google Fonts link would be blocked at the browser: Big Shoulders for the shout, Public Sans — the US civic face, Franklin Gothic lineage — for everything else. First fetch grabbed the wrong unicode subset and shipped fonts with no ASCII in them. The first pass multiplied the whole headline over the stock, which crushed the blue to near-black and threw away half the identity. The second gave each tab a slight tilt, which pulled its top edge off the perforation and read as broken alignment rather than hand-cut. Both removed. theme.css is now declared deliberately diverged from the library; tokens.css stays pinned, so a framework upgrade is still caught. Three strings the design needed and the copy round never saw ship as placeholders, so the canonical host refuses to serve them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
18 files changed,
+813
−295
.impeccable/hook.cache.json+1 −0.impeccable/questions/f5b47819.log+0 −0.impeccable/questions/f5b47819.state.json+1 −0internal/movement/copy.go+7 −0internal/movement/static/bill.css+511 −0internal/movement/static/fonts/big-shoulders-800.woff2binaryinternal/movement/static/fonts/public-sans-400.woff2binaryinternal/movement/static/fonts/public-sans-700.woff2binaryinternal/movement/static/theme.css+115 −181internal/movement/templates/admin.html+19 −19internal/movement/templates/confirm.html+8 −6internal/movement/templates/index.html+34 −12internal/movement/templates/layout.html+19 −9internal/movement/templates/sent.html+7 −5internal/movement/templates/signin.html+10 −9internal/movement/templates/stance.html+61 −51internal/movementtest/copy_test.go+11 −2internal/movementtest/vendored_test.go+9 −1
diff --git a/.impeccable/hook.cache.json b/.impeccable/hook.cache.json| new file mode 100644 |
| index 0000000..e2e0223 |
| --- /dev/null |
| +++ b/.impeccable/hook.cache.json |
| @@ -0,0 +1 @@ |
| +{"version":1,"sessions":{"91e646a9-0b65-4ca7-83c7-59b544490463":{"updatedAt":1788368577068,"files":{"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/static/theme.css":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/static/bill.css":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/layout.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/index.html":{"editCount":2,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/stance.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/sent.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/confirm.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/signin.html":{"editCount":1,"findings":[],"cleanAcked":true},"/home/paulca/amadan.net/carlos/movement/.claude/worktrees/design/internal/movement/templates/admin.html":{"editCount":1,"findings":[],"cleanAcked":true}}}}} |
| \ No newline at end of file |
diff --git a/.impeccable/questions/f5b47819.log b/.impeccable/questions/f5b47819.log| new file mode 100644 |
| index 0000000..e69de29 |
diff --git a/.impeccable/questions/f5b47819.state.json b/.impeccable/questions/f5b47819.state.json| new file mode 100644 |
| index 0000000..c19830a |
| --- /dev/null |
| +++ b/.impeccable/questions/f5b47819.state.json |
| @@ -0,0 +1 @@ |
| +{"pid":2148984,"port":33475,"url":"http://127.0.0.1:33475/"} |
| \ No newline at end of file |
diff --git a/internal/movement/copy.go b/internal/movement/copy.go| index 859a33e..e25660d 100644 |
| --- a/internal/movement/copy.go |
| +++ b/internal/movement/copy.go |
| @@ -42,6 +42,13 @@ var table = map[string]entry{ |
| "stance.curious.message": {"So are we. AI means something different in almost every sentence it appears in. Most arguments about it are not about the technology, but about shitty people doing shitty things.\n\nHere is what we think is true: it lets a small number of people build infrastructure that used to need a company, and that changes who gets to own their software.\n\nHere is what we also think is true: plenty of people are using it badly. There’s potential to outdo that by building better. The more skepticism, the better, but also ambition!", false}, |
| "stance.enthusiast.message": {"This was entirely AI-generated. Human editor leaving as-is:\n\nThen we'll be honest with you: you may not be our people. Not because enthusiasm is wrong — we're building with this stuff too — but because we think the consequences are the interesting part. A project that only ever asks whether something can be built, and never whether it should, ends up somewhere we don't want to live. If that reads as hand-wringing, this probably isn't your movement. If it reads as fair, and you'd argue the other side of it well, sign up anyway. We'd rather be disagreed with than agreed with by default.", false}, |
| + // The tear-off strip. Not yet reviewed: the design needed words the copy |
| + // round never saw, so they ship as placeholders and the canonical host |
| + // refuses to serve them. |
| + "tear.instruction": {"PLACEHOLDER take the one that is yours", true}, |
| + "tab.take": {"PLACEHOLDER take", true}, |
| + "torn.label": {"PLACEHOLDER you took", true}, |
| + |
| // The form. |
| "form.email": {"Your email address", false}, |
| "form.background": {"Why are you interested, and how could you help?", false}, |
diff --git a/internal/movement/static/bill.css b/internal/movement/static/bill.css| new file mode 100644 |
| index 0000000..456b94c |
| --- /dev/null |
| +++ b/internal/movement/static/bill.css |
| @@ -0,0 +1,511 @@ |
| +/* The flyposted bill. |
| + Composition only; colour, type and shape are theme.css. |
| + |
| + Two surfaces. The bill is drenched acid yellow and shouts once. The |
| + sheet is bone and is meant to be read. Everything else follows from |
| + that split. */ |
| + |
| +/* ───────────────────────────────────────────────────────────────────── |
| + The sheet the whole thing is printed on |
| + ───────────────────────────────────────────────────────────────────── */ |
| + |
| +body { |
| + background: var(--rst-bg); |
| + color: var(--bill-ink); |
| + font-family: var(--rst-font); |
| + -webkit-font-smoothing: antialiased; |
| +} |
| + |
| +.bill-page { |
| + background: var(--bill-stock); |
| + --rst-bg: var(--bill-stock); |
| + --rst-surface: var(--bill-stock); |
| + --rst-text-muted: var(--bill-dim); |
| + --rst-line: rgba(23, 20, 15, 0.3); |
| +} |
| + |
| +.sheet-page { |
| + background: var(--bill-bone); |
| +} |
| + |
| +.bill { |
| + max-width: 78rem; |
| + margin: 0 auto; |
| + padding: clamp(1.25rem, 3vw, 2.5rem) clamp(1.25rem, 4vw, 3.5rem) |
| + clamp(3rem, 6vw, 5rem); |
| +} |
| + |
| +/* The printer's slug. A real bill carries who printed it and when; this |
| + one carries what it is. Set small in the body face, never in mono — |
| + monospace here would be a costume for "technical" on a page that is |
| + not about machines. */ |
| +.bill__slug { |
| + display: flex; |
| + flex-wrap: wrap; |
| + gap: 0.5rem 1.25rem; |
| + align-items: baseline; |
| + border-bottom: 2px solid var(--bill-ink); |
| + padding-bottom: 0.55rem; |
| + margin-bottom: clamp(1.5rem, 4vw, 3rem); |
| + font-size: 0.75rem; |
| + font-weight: 700; |
| + letter-spacing: 0.14em; |
| + text-transform: uppercase; |
| +} |
| +.bill__slug span:last-child { |
| + margin-left: auto; |
| + color: var(--bill-dim); |
| + letter-spacing: 0.1em; |
| +} |
| + |
| +/* ───────────────────────────────────────────────────────────────────── |
| + The shout |
| + ───────────────────────────────────────────────────────────────────── */ |
| + |
| +.bill { |
| + --shout: clamp(3.25rem, 12.5vw, 10.5rem); |
| +} |
| + |
| +.bill__shout { |
| + font-family: var(--bill-display); |
| + font-weight: 800; |
| + text-transform: uppercase; |
| + color: var(--bill-blue); |
| + font-size: var(--shout); |
| + line-height: 0.84; |
| + letter-spacing: -0.02em; |
| + margin: 0; |
| + max-width: 15ch; |
| + text-wrap: balance; |
| + position: relative; |
| + z-index: 0; |
| +} |
| + |
| +/* The band the headline crosses. Where the two overlap, the ink builds. */ |
| +.bill__band { |
| + position: relative; |
| +} |
| +.bill__band::after { |
| + content: ""; |
| + position: absolute; |
| + left: -100vw; |
| + right: -100vw; |
| + top: calc(var(--shout) * 0.52); |
| + height: calc(var(--shout) * 0.30); |
| + background: var(--bill-cyan); |
| + mix-blend-mode: multiply; |
| + z-index: 1; |
| + pointer-events: none; |
| +} |
| + |
| +/* ───────────────────────────────────────────────────────────────────── |
| + The argument, set in hard columns |
| + ───────────────────────────────────────────────────────────────────── */ |
| + |
| +.bill__body { |
| + margin: clamp(1.75rem, 4vw, 3rem) 0 0; |
| + columns: 2; |
| + column-gap: clamp(1.5rem, 4vw, 3.5rem); |
| + column-rule: 1px solid var(--rst-line); |
| + max-width: 62rem; |
| + font-size: clamp(1rem, 1.15vw, 1.125rem); |
| + line-height: 1.55; |
| +} |
| +.bill__body p { |
| + margin: 0 0 1em; |
| +} |
| +.bill__body p:last-child { |
| + margin-bottom: 0; |
| +} |
| + |
| +.bill__out { |
| + display: inline-block; |
| + margin-top: clamp(1.25rem, 3vw, 2rem); |
| + font-weight: 700; |
| + color: var(--bill-ink); |
| + text-decoration: none; |
| + border-bottom: 3px solid var(--bill-blue); |
| + padding-bottom: 1px; |
| +} |
| +.bill__out:hover { |
| + color: var(--bill-blue); |
| +} |
| + |
| +/* ───────────────────────────────────────────────────────────────────── |
| + The tear-off strip — the whole point of the page |
| + ───────────────────────────────────────────────────────────────────── */ |
| + |
| +.tear { |
| + margin-top: clamp(2.25rem, 5vw, 4rem); |
| +} |
| + |
| +.tear__instruction { |
| + font-weight: 700; |
| + font-size: 0.8125rem; |
| + letter-spacing: 0.16em; |
| + text-transform: uppercase; |
| + margin: 0 0 0.75rem; |
| +} |
| + |
| +/* The perforation. A dashed rule the tabs hang from, drawn as a real |
| + repeating gradient rather than a border so the dashes stay square. */ |
| +.tear__perf { |
| + height: 2px; |
| + background-image: repeating-linear-gradient( |
| + to right, |
| + var(--bill-ink) 0 7px, |
| + transparent 7px 14px |
| + ); |
| +} |
| + |
| +.tear__tabs { |
| + display: grid; |
| + grid-template-columns: repeat(3, 1fr); |
| + gap: 0; |
| + border-bottom: 2px solid var(--bill-ink); |
| +} |
| + |
| +.tab { |
| + display: flex; |
| + flex-direction: column; |
| + justify-content: flex-start; |
| + gap: 0.75rem; |
| + min-height: clamp(6.5rem, 10vw, 8.5rem); |
| + padding: 1.1rem 1.1rem 1.25rem; |
| + background: var(--bill-stock); |
| + color: var(--bill-ink); |
| + text-decoration: none; |
| + border-right: 2px dashed var(--bill-ink); |
| + transform-origin: top center; |
| + transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1), |
| + box-shadow 260ms cubic-bezier(0.16, 1, 0.3, 1); |
| +} |
| +.tab:last-child { |
| + border-right: 0; |
| +} |
| + |
| +.tab:hover, |
| +.tab:focus-visible { |
| + transform: translateY(-10px); |
| + box-shadow: var(--rst-shadow-pop); |
| + z-index: 1; |
| +} |
| + |
| +.tab__take { |
| + font-size: 0.6875rem; |
| + font-weight: 700; |
| + letter-spacing: 0.18em; |
| + text-transform: uppercase; |
| + color: var(--bill-dim); |
| +} |
| +.tab:hover .tab__take, |
| +.tab:focus-visible .tab__take { |
| + color: var(--bill-blue); |
| +} |
| + |
| +.tab__word { |
| + font-family: var(--bill-display); |
| + font-weight: 800; |
| + text-transform: uppercase; |
| + font-size: clamp(1.6rem, 3.2vw, 2.75rem); |
| + line-height: 0.92; |
| + letter-spacing: -0.01em; |
| + text-wrap: balance; |
| +} |
| + |
| +/* ───────────────────────────────────────────────────────────────────── |
| + The stance sheet — bone, and meant to be read |
| + ───────────────────────────────────────────────────────────────────── */ |
| + |
| +.sheet { |
| + max-width: 44rem; |
| + margin: 0 auto; |
| + padding: clamp(1.25rem, 3vw, 2.5rem) clamp(1.25rem, 5vw, 2.5rem) |
| + clamp(3rem, 7vw, 5rem); |
| +} |
| + |
| +/* The tab you took, carried over torn. Its bottom edge is ragged because |
| + it came off a perforation. */ |
| +.torn { |
| + background: var(--bill-stock); |
| + padding: 0.9rem 1.1rem 1.5rem; |
| + margin-bottom: clamp(1.75rem, 4vw, 2.75rem); |
| + position: relative; |
| + clip-path: polygon( |
| + 0 0, 100% 0, 100% calc(100% - 9px), |
| + 96% 100%, 92% calc(100% - 7px), 88% 100%, 84% calc(100% - 8px), |
| + 80% 100%, 76% calc(100% - 6px), 72% 100%, 68% calc(100% - 9px), |
| + 64% 100%, 60% calc(100% - 7px), 56% 100%, 52% calc(100% - 8px), |
| + 48% 100%, 44% calc(100% - 6px), 40% 100%, 36% calc(100% - 9px), |
| + 32% 100%, 28% calc(100% - 7px), 24% 100%, 20% calc(100% - 8px), |
| + 16% 100%, 12% calc(100% - 6px), 8% 100%, 4% calc(100% - 9px), |
| + 0 100% |
| + ); |
| +} |
| +.torn__label { |
| + font-size: 0.6875rem; |
| + font-weight: 700; |
| + letter-spacing: 0.18em; |
| + text-transform: uppercase; |
| + color: var(--bill-dim); |
| + margin: 0 0 0.3rem; |
| +} |
| +.torn__word { |
| + font-family: var(--bill-display); |
| + font-weight: 800; |
| + text-transform: uppercase; |
| + font-size: clamp(1.75rem, 5vw, 2.75rem); |
| + line-height: 0.9; |
| + margin: 0; |
| +} |
| + |
| +.sheet__reply { |
| + font-size: clamp(1.0625rem, 1.3vw, 1.1875rem); |
| + line-height: 1.62; |
| + max-width: 34rem; |
| +} |
| +.sheet__reply p { |
| + margin: 0 0 1.05em; |
| +} |
| +.sheet__reply p:first-child::first-line { |
| + font-weight: 700; |
| +} |
| + |
| +/* ───────────────────────────────────────────────────────────────────── |
| + The form, in the bill's own vocabulary |
| + Fields are ruled lines on paper, not rounded boxes on a screen. |
| + ───────────────────────────────────────────────────────────────────── */ |
| + |
| +.slip { |
| + margin-top: clamp(2.25rem, 6vw, 3.5rem); |
| + border-top: 2px solid var(--bill-ink); |
| + padding-top: clamp(1.25rem, 3vw, 1.75rem); |
| +} |
| +.slip__title { |
| + font-family: var(--bill-display); |
| + font-weight: 800; |
| + text-transform: uppercase; |
| + font-size: clamp(1.35rem, 3vw, 1.9rem); |
| + line-height: 1; |
| + margin: 0 0 1.25rem; |
| +} |
| + |
| +.field { |
| + display: block; |
| + margin-bottom: 1.35rem; |
| +} |
| +.field > span { |
| + display: block; |
| + font-size: 0.8125rem; |
| + font-weight: 700; |
| + letter-spacing: 0.06em; |
| + margin-bottom: 0.35rem; |
| +} |
| +.field input[type="email"], |
| +.field textarea { |
| + width: 100%; |
| + box-sizing: border-box; |
| + font: inherit; |
| + color: var(--bill-ink); |
| + background: transparent; |
| + border: 0; |
| + border-bottom: 2px solid var(--bill-ink); |
| + padding: 0.45rem 0; |
| + border-radius: 0; |
| +} |
| +.field textarea { |
| + border: 2px solid var(--bill-ink); |
| + padding: 0.6rem 0.7rem; |
| + resize: vertical; |
| + min-height: 7.5rem; |
| +} |
| +.field input[type="email"]::placeholder, |
| +.field textarea::placeholder { |
| + color: var(--bill-dim-bone); |
| +} |
| + |
| +.stamp { |
| + font: inherit; |
| + font-weight: 700; |
| + letter-spacing: 0.14em; |
| + text-transform: uppercase; |
| + font-size: 0.875rem; |
| + color: var(--bill-stock); |
| + background: var(--bill-blue); |
| + border: 0; |
| + border-radius: 0; |
| + padding: 0.85rem 1.75rem; |
| + cursor: pointer; |
| + transition: background 160ms ease-out; |
| +} |
| +.stamp:hover:not(:disabled) { |
| + background: var(--rst-accent-strong); |
| +} |
| +.stamp:disabled { |
| + background: transparent; |
| + color: var(--bill-dim-bone); |
| + border: 2px dashed var(--rst-line-strong); |
| + cursor: not-allowed; |
| +} |
| + |
| +.slip__status { |
| + margin: 0.85rem 0 0; |
| + font-size: 0.8125rem; |
| + font-weight: 700; |
| + letter-spacing: 0.1em; |
| + text-transform: uppercase; |
| + color: var(--bill-dim-bone); |
| +} |
| + |
| +.notice { |
| + border-left: 0; |
| + border-top: 2px solid var(--rst-tone-negative-fg); |
| + background: var(--rst-tone-negative-bg); |
| + color: var(--rst-tone-negative-fg); |
| + padding: 0.75rem 0.9rem; |
| + margin: 0 0 1.25rem; |
| + font-weight: 700; |
| +} |
| + |
| +/* The honeypot: present in the page, never on the screen. */ |
| +.hp { |
| + position: absolute; |
| + width: 1px; |
| + height: 1px; |
| + overflow: hidden; |
| + clip-path: inset(50%); |
| + white-space: nowrap; |
| +} |
| + |
| +/* ───────────────────────────────────────────────────────────────────── |
| + Receipts — what a signup, a confirmation and a dead link look like |
| + ───────────────────────────────────────────────────────────────────── */ |
| + |
| +.receipt { |
| + max-width: 34rem; |
| + margin: clamp(2rem, 8vh, 5rem) auto; |
| + padding: clamp(1.5rem, 5vw, 2.5rem); |
| + border: 2px solid var(--bill-ink); |
| + background: var(--bill-bone); |
| +} |
| +.receipt__mark { |
| + font-family: var(--bill-display); |
| + font-weight: 800; |
| + text-transform: uppercase; |
| + font-size: clamp(1.9rem, 5vw, 3rem); |
| + line-height: 0.92; |
| + color: var(--bill-blue); |
| + margin: 0 0 0.85rem; |
| + text-wrap: balance; |
| +} |
| +.receipt p { |
| + margin: 0; |
| + font-size: 1.0625rem; |
| + line-height: 1.6; |
| +} |
| + |
| +/* ───────────────────────────────────────────────────────────────────── |
| + Narrow screens: the strip stacks, the shout stays a shout |
| + ───────────────────────────────────────────────────────────────────── */ |
| + |
| +@media (max-width: 46rem) { |
| + .bill__body { |
| + columns: 1; |
| + } |
| + .tear__tabs { |
| + grid-template-columns: 1fr; |
| + } |
| + .tab { |
| + min-height: 0; |
| + border-right: 0; |
| + border-bottom: 2px dashed var(--bill-ink); |
| + flex-direction: row; |
| + align-items: baseline; |
| + justify-content: flex-start; |
| + gap: 0.85rem; |
| + padding: 0.95rem 0.25rem; |
| + } |
| + .tab:last-child { |
| + border-bottom: 0; |
| + } |
| + .tab__word { |
| + font-size: 1.5rem; |
| + } |
| + .tab:hover, |
| + .tab:focus-visible { |
| + transform: none; |
| + box-shadow: none; |
| + background: var(--bill-bone); |
| + } |
| +} |
| + |
| +@media (prefers-reduced-motion: reduce) { |
| + .tab { |
| + transition: none; |
| + } |
| + .tab:hover, |
| + .tab:focus-visible { |
| + transform: none; |
| + } |
| +} |
| + |
| +/* ───────────────────────────────────────────────────────────────────── |
| + The ledger — admin, in the same hand |
| + Operate, not persuade: the tally has to be scannable before it is |
| + anything else, so the bill's voice survives only in the type and rules. |
| + ───────────────────────────────────────────────────────────────────── */ |
| + |
| +.ledger { |
| + max-width: 66rem; |
| + margin: 0 auto; |
| + padding: clamp(1.25rem, 3vw, 2.5rem); |
| +} |
| +.ledger__title { |
| + font-family: var(--bill-display); |
| + font-weight: 800; |
| + text-transform: uppercase; |
| + font-size: clamp(2rem, 5vw, 3.25rem); |
| + line-height: 0.9; |
| + margin: 0 0 1.25rem; |
| + color: var(--bill-blue); |
| +} |
| +.ledger__tally, |
| +.ledger__split { |
| + margin: 0 0 0.65rem; |
| + font-size: 0.9375rem; |
| +} |
| +.ledger__tally b, |
| +.ledger__split b { |
| + font-family: var(--bill-display); |
| + font-size: 1.5em; |
| + line-height: 1; |
| + vertical-align: -0.06em; |
| + margin-right: 0.15em; |
| +} |
| +.ledger__split span + span { |
| + margin-left: 1.25rem; |
| +} |
| +.ledger__table { |
| + width: 100%; |
| + border-collapse: collapse; |
| + margin-top: 1.5rem; |
| + font-size: 0.9375rem; |
| + font-variant-numeric: tabular-nums; |
| +} |
| +.ledger__table th { |
| + text-align: left; |
| + font-size: 0.75rem; |
| + letter-spacing: 0.12em; |
| + text-transform: uppercase; |
| + border-bottom: 2px solid var(--bill-ink); |
| + padding: 0 0.75rem 0.5rem 0; |
| +} |
| +.ledger__table td { |
| + border-bottom: 1px solid var(--rst-line); |
| + padding: 0.7rem 0.75rem 0.7rem 0; |
| + vertical-align: top; |
| +} |
| +.ledger__table td:last-child { |
| + color: var(--bill-dim-bone); |
| + max-width: 28rem; |
| +} |
diff --git a/internal/movement/static/fonts/big-shoulders-800.woff2 b/internal/movement/static/fonts/big-shoulders-800.woff2| new file mode 100644 |
| index 0000000..47d2fe7 |
| Binary files /dev/null and b/internal/movement/static/fonts/big-shoulders-800.woff2 differ |
diff --git a/internal/movement/static/fonts/public-sans-400.woff2 b/internal/movement/static/fonts/public-sans-400.woff2| new file mode 100644 |
| index 0000000..e303dab |
| Binary files /dev/null and b/internal/movement/static/fonts/public-sans-400.woff2 differ |
diff --git a/internal/movement/static/fonts/public-sans-700.woff2 b/internal/movement/static/fonts/public-sans-700.woff2| new file mode 100644 |
| index 0000000..79c2c45 |
| Binary files /dev/null and b/internal/movement/static/fonts/public-sans-700.woff2 differ |
diff --git a/internal/movement/static/theme.css b/internal/movement/static/theme.css| index 5da4384..4b8d8d1 100644 |
| --- a/internal/movement/static/theme.css |
| +++ b/internal/movement/static/theme.css |
| @@ -1,187 +1,121 @@ |
| -/* day — rastrillo's default theme: an everyday blue on white and grey. |
| +/* bill — the flyposted bill. |
| Structure lives in tokens.css; this file is colour, type and shape. |
| - rastrillo new writes it once as static/theme.css. |
| - The look: the ordinary, personable interface. A white page, a plain |
| - grey text scale, one friendly blue that means "this does something", |
| - and the conventional green/amber/red for status. Nothing here is |
| - trying to be memorable — day is the theme an app ships on while it |
| - works out what it wants to look like, and the one that never gets in |
| - the way of the content. Semantic status colours are functional and |
| - never the accent, so a pill can never be mistaken for a link. |
| - |
| - ── One block, both schemes ─────────────────────────────────────────── |
| - Every token is declared once, as light-dark(<light>, <dark>), under a |
| - single :root that sets color-scheme: light dark. The browser picks the |
| - half that matches the resolved scheme, so there is no second copy of |
| - the palette to keep in sync and no way to author dark half-done. The |
| - two rules at the bottom are the whole toggle: setting color-scheme on |
| - :root[data-theme=…] re-resolves every light-dark() in the file at |
| - once, in both directions, which is why an explicit choice beats the OS |
| - without restating a single colour. |
| - |
| - Tokens with one value in both schemes — the font stack, the radii — |
| - are written plain, because light-dark(x, x) says nothing twice. |
| - |
| - 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. |
| - |
| - ── The shape axis ──────────────────────────────────────────────────── |
| - Radius and shadow are the theme's now, not tokens.css's: a theme that |
| - can only change colour cannot change how an app feels. day is soft and |
| - unremarkable — 8px cards, 6px controls, shadows with a wide blur and |
| - little alpha, the depth of paper resting on paper. |
| - |
| - ── WCAG 2.2 AA, measured ───────────────────────────────────────────── |
| - Normal text needs 4.5:1; large text and UI component boundaries need |
| - 3:1. Every pair below was computed from these exact hex values, in |
| - both schemes. If you change a token, recompute its rows. |
| - |
| - LIGHT ratio requirement |
| - --rst-text on --rst-surface 16.91 4.5:1 text |
| - --rst-text on --rst-bg 16.91 4.5:1 text |
| - --rst-text on --rst-surface-2 15.77 4.5:1 text |
| - --rst-text on --rst-accent-soft 15.21 4.5:1 text |
| - --rst-text-muted on --rst-surface 7.56 4.5:1 text |
| - --rst-text-muted on --rst-bg 7.56 4.5:1 text |
| - --rst-text-muted on --rst-surface-2 7.05 4.5:1 text |
| - --rst-text-muted on --rst-accent-soft 6.80 4.5:1 text |
| - --rst-text-faint on --rst-surface 5.29 4.5:1 text |
| - --rst-text-faint on --rst-bg 5.29 4.5:1 text |
| - --rst-text-faint on --rst-surface-2 4.93 4.5:1 text |
| - --rst-text-faint on --rst-accent-soft 4.76 4.5:1 text |
| - --rst-accent on --rst-surface 5.28 4.5:1 text + focus ring |
| - --rst-accent on --rst-bg 5.28 4.5:1 text + focus ring |
| - --rst-accent on --rst-surface-2 4.93 4.5:1 text |
| - --rst-accent on --rst-accent-soft 4.75 4.5:1 text |
| - --rst-on-accent on --rst-accent 5.28 4.5:1 button label |
| - --rst-on-accent on --rst-accent-strong 7.18 4.5:1 button label, hover |
| - --rst-line-strong on --rst-surface 3.63 3:1 control border |
| - --rst-line-strong on --rst-bg 3.63 3:1 control border |
| - --rst-line-strong on --rst-surface-2 3.39 3:1 control border |
| - pill neutral fg on its own tint 9.02 4.5:1 text |
| - pill positive fg on its own tint 6.49 4.5:1 text |
| - pill warning fg on its own tint 6.15 4.5:1 text |
| - pill negative fg on its own tint 5.30 4.5:1 text |
| - --rst-on-accent on negative fg (fill) 6.47 4.5:1 danger button label |
| - |
| - DARK ratio requirement |
| - --rst-text on --rst-surface 13.74 4.5:1 text |
| - --rst-text on --rst-bg 15.32 4.5:1 text |
| - --rst-text on --rst-surface-2 14.63 4.5:1 text |
| - --rst-text on --rst-accent-soft 13.02 4.5:1 text |
| - --rst-text-muted on --rst-surface 7.56 4.5:1 text |
| - --rst-text-muted on --rst-bg 8.43 4.5:1 text |
| - --rst-text-muted on --rst-surface-2 8.05 4.5:1 text |
| - --rst-text-muted on --rst-accent-soft 7.16 4.5:1 text |
| - --rst-text-faint on --rst-surface 6.33 4.5:1 text |
| - --rst-text-faint on --rst-bg 7.06 4.5:1 text |
| - --rst-text-faint on --rst-surface-2 6.74 4.5:1 text |
| - --rst-text-faint on --rst-accent-soft 6.00 4.5:1 text |
| - --rst-accent on --rst-surface 6.95 4.5:1 text + focus ring |
| - --rst-accent on --rst-bg 7.75 4.5:1 text + focus ring |
| - --rst-accent on --rst-surface-2 7.40 4.5:1 text |
| - --rst-accent on --rst-accent-soft 6.59 4.5:1 text |
| - --rst-on-accent on --rst-accent 7.86 4.5:1 button label |
| - --rst-on-accent on --rst-accent-strong 10.29 4.5:1 button label, hover |
| - --rst-line-strong on --rst-surface 4.26 3:1 control border |
| - --rst-line-strong on --rst-bg 4.75 3:1 control border |
| - --rst-line-strong on --rst-surface-2 4.53 3:1 control border |
| - pill neutral fg on its own tint 8.44 4.5:1 text |
| - pill positive fg on its own tint 10.18 4.5:1 text |
| - pill warning fg on its own tint 9.62 4.5:1 text |
| - pill negative fg on its own tint 7.85 4.5:1 text |
| - --rst-on-accent on negative fg (fill) 8.99 4.5:1 danger button label |
| - |
| - The tightest pair in the set is --rst-accent on --rst-accent-soft in |
| - light, at 4.75:1 against a 4.5:1 floor. Put a token on a surface not |
| - listed above and you are guessing; recompute first. |
| - |
| - --rst-line is a hairline divider between rows, not a control border or |
| - a meaningful graphic, so 1.4.11's 3:1 does not apply to it. Control |
| - borders use --rst-line-strong, which does clear 3:1. |
| - |
| - --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. |
| - |
| - Pagination chips are the one deliberate exception: they are bordered |
| - with --rst-line rather than --rst-line-strong, because a page strip |
| - ringed in hard outlines shouts louder than the list it belongs to. |
| - 1.4.11 is satisfied without that border — each chip's meaning is |
| - carried by its text label at 4.5:1 or better, and the current page |
| - adds a filled background, a heavier weight and aria-current on top. |
| - |
| - --rst-shadow-pop, --rst-shadow-knob, --rst-shadow-lift and |
| - --rst-overlay are not in the table: they paint a translucent shadow or |
| - scrim over whatever sits underneath, not text over a fixed |
| - background, so a contrast ratio does not apply. The scrim deepens in |
| - dark, matching --rst-shadow-pop's own jump: black reads lighter |
| - against a dark backdrop, so the overlay needs more alpha to still |
| - read as a dimming layer. |
| - ──────────────────────────────────────────────────────────────────── */ |
| + The look: a sheet pasted on a wall. Cheap coloured stock, two inks, and |
| + a heavy impression. Where the two inks cross they overprint to a third, |
| + which is where the green comes from — it is a consequence of the |
| + material, not a colour anyone chose, and it is why this sits next to |
| + carlosframework.com's teal as a relative rather than a copy. |
| + |
| + Drenched, not accented: the stock IS the surface on the bill itself. |
| + The stance pages drop to bone, because acid yellow is a shout and the |
| + three responses are the part you actually read. |
| + |
| + No radius anywhere. A printed sheet has corners. Depth is ink and |
| + perforation, never elevation — the one shadow in the system belongs to |
| + a tab lifting off the wall, which is a thing paper does. |
| + ─────────────────────────────────────────────────────────────────────── */ |
| + |
| +@font-face { |
| + font-family: "Big Shoulders"; |
| + src: url("fonts/big-shoulders-800.woff2") format("woff2"); |
| + font-weight: 800; |
| + font-style: normal; |
| + font-display: swap; |
| +} |
| +@font-face { |
| + font-family: "Public Sans"; |
| + src: url("fonts/public-sans-400.woff2") format("woff2"); |
| + font-weight: 400; |
| + font-style: normal; |
| + font-display: swap; |
| +} |
| +@font-face { |
| + font-family: "Public Sans"; |
| + src: url("fonts/public-sans-700.woff2") format("woff2"); |
| + font-weight: 700; |
| + font-style: normal; |
| + font-display: swap; |
| +} |
| :root { |
| - color-scheme: light dark; |
| - |
| - /* Type: the family only. Sizes and spacing are structural and stay in |
| - tokens.css. */ |
| - --rst-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; |
| - |
| - /* Shape: soft, ordinary, unremarkable. */ |
| - --rst-radius: 8px; |
| - --rst-radius-sm: 6px; |
| - --rst-radius-pill: 999px; |
| - |
| - /* Surfaces and lines */ |
| - --rst-bg: light-dark(#ffffff, #111418); |
| - --rst-surface: light-dark(#ffffff, #1a1f26); |
| - --rst-surface-2: light-dark(#f6f7f8, #15191f); |
| - --rst-line: light-dark(#e5e7eb, #2a3038); |
| - --rst-line-strong: light-dark(#7f8792, #79828e); |
| - |
| - /* The page header's rule, derived rather than authored: the accent |
| - folded into the line at 18%, so a theme that changes its accent |
| - gets a matching rule with no second value to keep in step. day's |
| - is barely there — a hairline you would call grey until you put it |
| - beside plain's. Decorative: it carries no contrast floor. */ |
| - --rst-header-rule: color-mix(in oklab, var(--rst-accent) 18%, var(--rst-line)); |
| - |
| - /* Text */ |
| - --rst-text: light-dark(#1a1d21, #e8eaed); |
| - --rst-text-muted: light-dark(#4b5563, #a8b0ba); |
| - --rst-text-faint: light-dark(#656c78, #98a1ac); |
| - |
| - /* Accent */ |
| - --rst-accent: light-dark(#2464e0, #7ba7ff); |
| - --rst-accent-strong: light-dark(#1a4fc0, #a3c0ff); |
| - --rst-accent-soft: light-dark(#eef3fe, #17233a); |
| - --rst-on-accent: light-dark(#ffffff, #0b1220); |
| - |
| - /* Status tones */ |
| - --rst-tone-neutral-fg: light-dark(#374151, #c3c9d1); |
| - --rst-tone-neutral-bg: light-dark(#eef0f2, #262c34); |
| - --rst-tone-positive-fg: light-dark(#166534, #6ee7a8); |
| - --rst-tone-positive-bg: light-dark(#dcfce7, #10281c); |
| - --rst-tone-warning-fg: light-dark(#854d0e, #f0c46a); |
| - --rst-tone-warning-bg: light-dark(#fef3c7, #2c2110); |
| - --rst-tone-negative-fg: light-dark(#b91c1c, #f79aa0); |
| - --rst-tone-negative-bg: light-dark(#fee2e2, #331819); |
| - |
| - /* Depth */ |
| - --rst-shadow-pop: 0 8px 24px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5)); |
| - --rst-shadow-knob: 0 1px 2px rgba(0, 0, 0, 0.3); |
| - --rst-shadow-lift: 0 1px 2px rgba(0, 0, 0, 0.08); |
| - --rst-overlay: light-dark(rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.6)); |
| + color-scheme: light; |
| + |
| + /* ── The stock ────────────────────────────────────────────────────── |
| + Yellow is the bill. Bone is the reading surface. Both are paper, so |
| + both are warm; neither is white, because white is a screen colour and |
| + this is a printed thing. */ |
| + --bill-stock: #f0e34a; |
| + --bill-bone: #f7f4e6; |
| + --bill-ink: #17140f; |
| + --bill-blue: #1b2a8f; |
| + --bill-cyan: #14a0c8; /* the second ink */ |
| + --bill-over: #2f6b46; /* what cyan makes of the yellow stock */ |
| + --bill-dim: #6b5f14; /* the ink thinned, for secondary text ON yellow */ |
| + --bill-dim-bone: #6a6455; |
| + |
| + /* ── rastrillo's contract ─────────────────────────────────────────── |
| + tokens.css reads these. Defaults here are the bone reading surface; |
| + the bill itself overrides them on its own page. */ |
| + --rst-bg: var(--bill-bone); |
| + --rst-surface: var(--bill-bone); |
| + --rst-surface-2: #efeada; |
| + --rst-overlay: rgba(23, 20, 15, 0.55); |
| + |
| + --rst-text: var(--bill-ink); |
| + --rst-text-muted: var(--bill-dim-bone); |
| + --rst-text-faint: #8b8574; |
| + |
| + --rst-accent: var(--bill-blue); |
| + --rst-accent-strong: #121d68; |
| + --rst-accent-soft: rgba(27, 42, 143, 0.1); |
| + --rst-on-accent: var(--bill-stock); |
| + |
| + --rst-line: rgba(23, 20, 15, 0.22); |
| + --rst-line-strong: rgba(23, 20, 15, 0.55); |
| + --rst-header-rule: var(--bill-ink); |
| + |
| + /* A printed sheet has corners. */ |
| + --rst-radius: 0; |
| + --rst-radius-sm: 0; |
| + --rst-radius-pill: 0; |
| + |
| + /* The only depth in the system: paper lifting off a wall. Offset and |
| + blur, because that is what a real shadow has. */ |
| + --rst-shadow-lift: 0 6px 18px rgba(23, 20, 15, 0.28); |
| + --rst-shadow-pop: 0 10px 30px rgba(23, 20, 15, 0.32); |
| + --rst-shadow-knob: 0 2px 6px rgba(23, 20, 15, 0.3); |
| + |
| + /* Status stays legible against paper without becoming a second accent. */ |
| + --rst-tone-positive-bg: rgba(47, 107, 70, 0.14); |
| + --rst-tone-positive-fg: #1f5334; |
| + --rst-tone-warning-bg: rgba(160, 96, 12, 0.16); |
| + --rst-tone-warning-fg: #7a4708; |
| + --rst-tone-negative-bg: rgba(158, 42, 26, 0.14); |
| + --rst-tone-negative-fg: #8e2417; |
| + --rst-tone-neutral-bg: rgba(23, 20, 15, 0.08); |
| + --rst-tone-neutral-fg: #4a4437; |
| + |
| + --rst-font: "Public Sans", ui-sans-serif, system-ui, -apple-system, sans-serif; |
| + --bill-display: "Big Shoulders", "Haettenschweiler", "Arial Narrow", sans-serif; |
| + --bill-mono: ui-monospace, SFMono-Regular, "Cascadia Mono", Menlo, monospace; |
| } |
| -/* The whole toggle. Setting color-scheme re-resolves every light-dark() |
| - above, so an explicit choice beats the OS in both directions without |
| - restating a colour. */ |
| -:root[data-theme="light"] { color-scheme: light; } |
| -:root[data-theme="dark"] { color-scheme: dark; } |
| +/* ── The browser's own surfaces ───────────────────────────────────────── |
| + Selection, caret, focus and scrollbar ship with defaults that belong to |
| + no design system. On a two-ink sheet they are the cheapest place to be |
| + consistent: selection is the inks swapped, focus is a registration mark. */ |
| +::selection { |
| + background: var(--bill-blue); |
| + color: var(--bill-stock); |
| +} |
| +:root { |
| + caret-color: var(--bill-blue); |
| + scrollbar-color: var(--bill-ink) transparent; |
| + scrollbar-width: thin; |
| +} |
| +:where(a, button, input, textarea, summary, [tabindex]):focus-visible { |
| + outline: 3px solid var(--bill-blue); |
| + outline-offset: 2px; |
| + border-radius: 0; |
| +} |
diff --git a/internal/movement/templates/admin.html b/internal/movement/templates/admin.html| index 2add264..5fb2bdf 100644 |
| --- a/internal/movement/templates/admin.html |
| +++ b/internal/movement/templates/admin.html |
| @@ -1,24 +1,24 @@ |
| {{define "title"}}{{say "admin.title"}}{{end}} |
| {{define "content"}} |
| -<h1 class="rst-page-title">{{say "admin.title"}}</h1> |
| +<div class="ledger"> |
| + <h1 class="ledger__title">{{say "admin.title"}}</h1> |
| -{{/* Confirmed rows only. A pending row is an address nobody has proven owns |
| - it, joined to a stated position on AI — the pair the logging rules keep |
| - apart — so the funnel shows as a count rather than a list. */}} |
| -<p> |
| - <span rst-badge>{{len .Rows}}</span> {{say "admin.confirmed"}} |
| - · |
| - <span rst-badge>{{.Pending}}</span> {{say "admin.pending"}} |
| -</p> |
| + {{/* The tally, in the bill's own hand. Confirmed rows only: a pending |
| + row is an address nobody has proven owns it, joined to a stated |
| + position, which is the pair this app takes most care with. */}} |
| + <p class="ledger__tally"> |
| + <b>{{len .Rows}}</b> {{say "admin.confirmed"}} |
| + <span>·</span> |
| + <b>{{.Pending}}</b> {{say "admin.pending"}} |
| + </p> |
| -<p> |
| - {{range $key, $count := .Counts}}<span rst-badge>{{$count}}</span> {{say (printf "stance.%s.label" $key)}} {{end}} |
| -</p> |
| + <p class="ledger__split"> |
| + {{range $key, $count := .Counts}}<span><b>{{$count}}</b> {{say (printf "stance.%s.label" $key)}}</span>{{end}} |
| + </p> |
| -<p><a href="/admin/export.csv">{{say "admin.export"}}</a></p> |
| + <p><a class="bill__out" href="/admin/export.csv">{{say "admin.export"}}</a></p> |
| -<div class="rst-list"> |
| - <table> |
| + <table class="ledger__table"> |
| <thead> |
| <tr> |
| <th>{{say "admin.col.email"}}</th> |
| @@ -40,9 +40,9 @@ |
| {{end}} |
| </tbody> |
| </table> |
| -</div> |
| -<form method="post" action="/signout"> |
| - <button type="submit">{{say "admin.signout"}}</button> |
| -</form> |
| + <form method="post" action="/signout"> |
| + <button type="submit" class="stamp">{{say "admin.signout"}}</button> |
| + </form> |
| +</div> |
| {{end}} |
diff --git a/internal/movement/templates/confirm.html b/internal/movement/templates/confirm.html| index 31621b5..3ebccbf 100644 |
| --- a/internal/movement/templates/confirm.html |
| +++ b/internal/movement/templates/confirm.html |
| @@ -1,17 +1,19 @@ |
| {{define "title"}}{{say "confirm.title"}}{{end}} |
| {{define "content"}} |
| +<div class="receipt"> |
| {{if eq .State "offer"}} |
| - <h1 class="rst-page-title">{{say "confirm.title"}}</h1> |
| - {{/* A button, not a redirect. Mail scanners fetch every link in an |
| + <p class="receipt__mark">{{say "confirm.title"}}</p> |
| + {{/* A button, not a redirect: mail scanners fetch every link in an |
| arriving message, and a GET that confirmed would put people on the |
| list who never clicked. */}} |
| - <form class="rst-form" method="post" action="/confirm"> |
| + <form class="slip" method="post" action="/confirm"> |
| <input type="hidden" name="token" value="{{.Token}}"> |
| - <button type="submit">{{say "confirm.button"}}</button> |
| + <button type="submit" class="stamp">{{say "confirm.button"}}</button> |
| </form> |
| {{else if eq .State "done"}} |
| - <h1 class="rst-page-title">{{say "confirm.done"}}</h1> |
| + <p class="receipt__mark">{{say "confirm.done"}}</p> |
| {{else}} |
| - <h1 class="rst-page-title">{{say "confirm.dead"}}</h1> |
| + <p class="receipt__mark">{{say "confirm.dead"}}</p> |
| {{end}} |
| +</div> |
| {{end}} |
diff --git a/internal/movement/templates/index.html b/internal/movement/templates/index.html| index c564a88..87289ad 100644 |
| --- a/internal/movement/templates/index.html |
| +++ b/internal/movement/templates/index.html |
| @@ -1,15 +1,37 @@ |
| +{{define "surface"}}bill-page{{end}} |
| {{define "title"}}{{say "index.headline"}}{{end}} |
| {{define "content"}} |
| -<h1 class="rst-page-title">{{say "index.headline"}}</h1> |
| -{{range .Lede}}<p>{{.}}</p>{{end}} |
| - |
| -<p><a href="https://carlosframework.com">{{say "index.link"}}</a></p> |
| - |
| -{{/* The three choices. Picking one is the first thing anyone does here, and |
| - the sorting is the product — so they are the page, not a widget on it. */}} |
| -<ul class="rst-list"> |
| - {{range .Choices}} |
| - <li><a href="/{{.Slug}}">{{.Label}}</a></li> |
| - {{end}} |
| -</ul> |
| +<div class="bill"> |
| + |
| + {{/* The second ink crosses the first. The stripe is cyan multiplied into |
| + the yellow stock, which is where the green comes from; where it |
| + passes over the blue letters the ink builds and darkens. The press |
| + does this, not a gradient. */}} |
| + <div class="bill__band"> |
| + <h1 class="bill__shout">{{say "index.headline"}}</h1> |
| + </div> |
| + |
| + <div class="bill__body"> |
| + {{range .Lede}}<p>{{.}}</p>{{end}} |
| + </div> |
| + |
| + <a class="bill__out" href="https://carlosframework.com">{{say "index.link"}}</a> |
| + |
| + {{/* The strip. Taking a tab IS the primary action — there is no button on |
| + this page, because the sort happens before anyone is asked for an |
| + address. The word leads; the affordance follows it. */}} |
| + <section class="tear"> |
| + <p class="tear__instruction">{{say "tear.instruction"}}</p> |
| + <div class="tear__perf"></div> |
| + <div class="tear__tabs"> |
| + {{range .Choices}} |
| + <a class="tab" href="/{{.Slug}}"> |
| + <span class="tab__word">{{.Label}}</span> |
| + <span class="tab__take">{{say "tab.take"}}</span> |
| + </a> |
| + {{end}} |
| + </div> |
| + </section> |
| + |
| +</div> |
| {{end}} |
diff --git a/internal/movement/templates/layout.html b/internal/movement/templates/layout.html| index 67f5cff..545bffa 100644 |
| --- a/internal/movement/templates/layout.html |
| +++ b/internal/movement/templates/layout.html |
| @@ -3,22 +3,32 @@ |
| <head> |
| <meta charset="utf-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1"> |
| -<title>{{block "title" .}}Hello{{end}}</title> |
| +<title>{{block "title" .}}Movement{{end}}</title> |
| <link rel="stylesheet" href="{{asset "static/tokens.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/theme.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/bill.css"}}"> |
| <script defer src="{{asset "static/rastrillo.js"}}"></script> |
| {{iconAssets}} |
| -<script defer src="{{asset "static/select.js"}}"></script> |
| -<script defer src="{{asset "static/datetime.js"}}"></script> |
| -{{/* head is the app's own slot in <head>: a favicon, a meta tag, one |
| - more stylesheet. Empty by default, last in the head so an app's |
| - own CSS wins over the framework's, and the only block in the |
| - three shells that is not chrome. */}} |
| {{block "head" .}}{{end}} |
| </head> |
| -<body> |
| +<body class="{{block "surface" .}}sheet-page{{end}}"> |
| +<!-- |
| +THESIS: A bill pasted on a wall that sorts you before it asks you for |
| +anything. Refuses the centred waitlist page with one email field. |
| +OWN-WORLD: Acid-yellow stock, ultramarine ink, green only where the two |
| +overprint. Big Shoulders condensed caps against Public Sans. Zero radius, |
| +perforated rules, ruled fields, no cards anywhere. |
| +STORY: A sceptic sees a question asked in earnest, takes the position that |
| +is theirs, reads a reply written to that position, and signs. |
| +FIRST VIEWPORT: Full-bleed yellow. The headline in caps crossing a green |
| +overprint band. The opening in two ruled columns. Three perforated tabs |
| +across the foot — taking one is the primary action. |
| +FORM: The Flyposted Bill, candidate 5 of 7, seed 60fbd6ef. |
| +FINISH: unreviewed and undocumented is unfinished; this build ends with the |
| +finish review, the verdict, and DESIGN.md |
| +--> |
| <a rst-skip href="#main">{{T "rastrillo.ui.shell_skip"}}</a> |
| -<main rst-page id="main"> |
| +<main id="main"> |
| {{template "content" .}} |
| </main> |
| </body> |
diff --git a/internal/movement/templates/sent.html b/internal/movement/templates/sent.html| index f5f431f..94fccb4 100644 |
| --- a/internal/movement/templates/sent.html |
| +++ b/internal/movement/templates/sent.html |
| @@ -1,9 +1,11 @@ |
| {{define "title"}}{{say "sent.title"}}{{end}} |
| {{define "content"}} |
| {{/* Every submission ends here: accepted, budget-suppressed, replayed, |
| - already confirmed, or refused outright. One page, one set of bytes. |
| - Anything that varies with the state of an address turns this form into a |
| - way to check who has taken a public position on AI. */}} |
| -<h1 class="rst-page-title">{{say "sent.title"}}</h1> |
| -<p>{{say "sent.body"}}</p> |
| + already confirmed, or refused. One page, one set of bytes. Anything |
| + that varied with the state of an address would turn this form into a |
| + way to check who is on the list. */}} |
| +<div class="receipt"> |
| + <p class="receipt__mark">{{say "sent.title"}}</p> |
| + <p>{{say "sent.body"}}</p> |
| +</div> |
| {{end}} |
diff --git a/internal/movement/templates/signin.html b/internal/movement/templates/signin.html| index 0549ebd..b33b40a 100644 |
| --- a/internal/movement/templates/signin.html |
| +++ b/internal/movement/templates/signin.html |
| @@ -1,19 +1,20 @@ |
| {{define "title"}}{{say "signin.title"}}{{end}} |
| {{define "content"}} |
| -<h1 class="rst-page-title">{{say "signin.title"}}</h1> |
| - |
| +<div class="receipt"> |
| + <p class="receipt__mark">{{say "signin.title"}}</p> |
| {{if .Sent}} |
| - {{/* Shown whether or not the address was on the allowlist. Saying "that |
| - address cannot sign in" would turn this page into a way to enumerate |
| - who the admins are. */}} |
| - <p rst-callout>{{say "signin.sent"}}</p> |
| + {{/* Shown whether or not the address is on the allowlist. Saying "that |
| + address cannot sign in" would make this page a way to enumerate the |
| + admins. */}} |
| + <p>{{say "signin.sent"}}</p> |
| {{else}} |
| - <form class="rst-form" method="post" action="/signin"> |
| - <label class="rst-field" for="address"> |
| + <form class="slip" method="post" action="/signin"> |
| + <label class="field" for="address"> |
| <span>{{say "form.email"}}</span> |
| <input type="email" id="address" name="address" required autocomplete="email"> |
| </label> |
| - <button type="submit">{{say "signin.submit"}}</button> |
| + <button type="submit" class="stamp">{{say "signin.submit"}}</button> |
| </form> |
| {{end}} |
| +</div> |
| {{end}} |
diff --git a/internal/movement/templates/stance.html b/internal/movement/templates/stance.html| index c50be48..5638d90 100644 |
| --- a/internal/movement/templates/stance.html |
| +++ b/internal/movement/templates/stance.html |
| @@ -1,57 +1,67 @@ |
| {{define "title"}}{{say (printf "stance.%s.label" .Stance)}}{{end}} |
| {{define "head"}}<script type="module" src="{{asset "static/pow.js"}}"></script>{{end}} |
| {{define "content"}} |
| -<h1 class="rst-page-title">{{say (printf "stance.%s.label" .Stance)}}</h1> |
| -{{range .Message}}<p>{{.}}</p>{{end}} |
| - |
| -{{if .Error}}<p rst-callout="warn" role="alert">{{.Error}}</p>{{end}} |
| - |
| -<form class="rst-form" method="post" action="/signup" |
| - data-pow-form |
| - data-pow-nonce="{{.Challenge.Nonce}}" |
| - data-pow-difficulty="{{.Challenge.Difficulty}}" |
| - data-pow-worker="{{asset "static/pow-worker.js"}}"> |
| - |
| - <input type="hidden" name="stance" value="{{.Stance}}"> |
| - <input type="hidden" name="nonce" value="{{.Challenge.Nonce}}"> |
| - <input type="hidden" name="issued_at" value="{{.Challenge.IssuedAt}}"> |
| - <input type="hidden" name="difficulty" value="{{.Challenge.Difficulty}}"> |
| - <input type="hidden" name="seal" value="{{.Challenge.Seal}}"> |
| - <input type="hidden" name="counter" value="" data-pow-counter> |
| - |
| - {{/* The honeypot. Hidden visually, and kept away from anything that |
| - autofills: a browser or password manager filling this would discard a |
| - real person's signup behind a success page they have no way to see |
| - through. */}} |
| - <div class="hp" aria-hidden="true"> |
| - <label for="{{.Honeypot}}">{{say "form.honeypot"}}</label> |
| - <input type="text" id="{{.Honeypot}}" name="{{.Honeypot}}" |
| - tabindex="-1" autocomplete="off"> |
| +<div class="sheet"> |
| + |
| + {{/* The tab you tore off the bill, carried over. Its lower edge is |
| + ragged because that is where the perforation gave. */}} |
| + <div class="torn"> |
| + <p class="torn__label">{{say "torn.label"}}</p> |
| + <p class="torn__word">{{say (printf "stance.%s.label" .Stance)}}</p> |
| + </div> |
| + |
| + <div class="sheet__reply"> |
| + {{range .Message}}<p>{{.}}</p>{{end}} |
| </div> |
| - <label class="rst-field" for="email"> |
| - <span>{{say "form.email"}}</span> |
| - <input type="email" id="email" name="email" required |
| - autocomplete="email" data-pow-email> |
| - </label> |
| - |
| - <label class="rst-field" for="background"> |
| - <span>{{say "form.background"}}</span> |
| - <textarea id="background" name="background" rows="5" maxlength="2000"></textarea> |
| - </label> |
| - |
| - {{/* Rendered disabled, and the module enables it once loaded. The other way |
| - round is unbuildable — script cannot enable a control inside <noscript> |
| - — and this ordering fails safe: a module that throws leaves the button |
| - disabled rather than live and inert. |
| - |
| - There is deliberately no explanation beside it. Paul's call, after copy |
| - review: why the form needs JavaScript is our problem, not a visitor's, |
| - and the page is not the place to explain proof of work. */}} |
| - <button type="submit" disabled data-pow-submit |
| - data-label="{{say "form.submit"}}" |
| - data-working-label="{{say "form.working"}}">{{say "form.submit"}}</button> |
| - |
| - <p hidden data-pow-status></p> |
| -</form> |
| + {{if .Error}}<p class="notice" role="alert">{{.Error}}</p>{{end}} |
| + |
| + <form class="slip" method="post" action="/signup" |
| + data-pow-form |
| + data-pow-nonce="{{.Challenge.Nonce}}" |
| + data-pow-difficulty="{{.Challenge.Difficulty}}" |
| + data-pow-worker="{{asset "static/pow-worker.js"}}"> |
| + |
| + <p class="slip__title">{{say "form.submit"}}</p> |
| + |
| + <input type="hidden" name="stance" value="{{.Stance}}"> |
| + <input type="hidden" name="nonce" value="{{.Challenge.Nonce}}"> |
| + <input type="hidden" name="issued_at" value="{{.Challenge.IssuedAt}}"> |
| + <input type="hidden" name="difficulty" value="{{.Challenge.Difficulty}}"> |
| + <input type="hidden" name="seal" value="{{.Challenge.Seal}}"> |
| + <input type="hidden" name="counter" value="" data-pow-counter> |
| + |
| + {{/* Hidden from the screen but present in the page, and kept away from |
| + anything a browser or password manager would autofill: filling it |
| + discards a real person's signup behind a page they cannot see |
| + through. */}} |
| + <div class="hp" aria-hidden="true"> |
| + <label for="{{.Honeypot}}">{{say "form.honeypot"}}</label> |
| + <input type="text" id="{{.Honeypot}}" name="{{.Honeypot}}" |
| + tabindex="-1" autocomplete="off"> |
| + </div> |
| + |
| + <label class="field" for="email"> |
| + <span>{{say "form.email"}}</span> |
| + <input type="email" id="email" name="email" required |
| + autocomplete="email" data-pow-email> |
| + </label> |
| + |
| + <label class="field" for="background"> |
| + <span>{{say "form.background"}}</span> |
| + <textarea id="background" name="background" rows="5" maxlength="2000"></textarea> |
| + </label> |
| + |
| + {{/* Disabled until the module loads and enables it. The other way round |
| + is unbuildable, and this ordering fails safe: a module that throws |
| + leaves the button disabled rather than live and inert. There is |
| + deliberately no explanation beside it. */}} |
| + <button type="submit" class="stamp" disabled data-pow-submit |
| + data-label="{{say "form.submit"}}" |
| + data-working-label="{{say "form.working"}}">{{say "form.submit"}}</button> |
| + |
| + <p class="slip__status" hidden data-pow-status></p> |
| + </form> |
| + |
| +</div> |
| {{end}} |
diff --git a/internal/movementtest/copy_test.go b/internal/movementtest/copy_test.go| index 0032f6c..1b6f5ab 100644 |
| --- a/internal/movementtest/copy_test.go |
| +++ b/internal/movementtest/copy_test.go |
| @@ -107,9 +107,18 @@ func TestNoStringShippedWithoutReview(t *testing.T) { |
| reviewed[s.ID] = true |
| } |
| + // A string still marked as a placeholder has not shipped: the app |
| + // refuses to serve the canonical host while any remain, which is the |
| + // mechanism that lets design work run ahead of the next copy round. |
| + pending := map[string]bool{} |
| + for _, id := range movement.Placeholders() { |
| + pending[id] = true |
| + } |
| + |
| for _, id := range movement.CopyIDs() { |
| - if !reviewed[id] { |
| - t.Errorf("%q is in the copy table but was never reviewed", id) |
| + if !reviewed[id] && !pending[id] { |
| + t.Errorf("%q is in the copy table, was never reviewed, and is not "+ |
| + "marked as a placeholder — so it would ship unreviewed", id) |
| } |
| } |
| } |
diff --git a/internal/movementtest/vendored_test.go b/internal/movementtest/vendored_test.go| index 9345f7c..7066724 100644 |
| --- a/internal/movementtest/vendored_test.go |
| +++ b/internal/movementtest/vendored_test.go |
| @@ -20,7 +20,15 @@ const vendoredTheme = "day" |
| // 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 the whole visual identity now, not the scaffold's "day". |
| + // The scaffold's own header called day "the theme an app ships on while |
| + // it works out what it wants to look like" — this app worked it out, and |
| + // what replaced it is the flyposted bill: acid stock, two inks, and the |
| + // green that only exists where they overprint. |
| + // |
| + // tokens.css stays pinned. Structure is still the framework's, and an |
| + // upgrade that changes it should still be caught. |
| + "theme.css": true, |
| } |
| // The scaffold delivered these files once; they are app-owned from |