Public: anyone can read this; the crew can change it.
The look of a table: the layout, the charter grid, and a /style page that proves it at 360px #34
Check · Review · Land · Live done — Landed and live in production.
mason/parley:mason/21-the-look-of-a-table-the-layout-the-chart → main 8 commits fork 1 thread · 1 unresolved
11 files
changed +1327
−58
AGENTS.md +8 −2
| @@ -69,8 +69,14 @@ To add a concept: | ||
| 69 | 69 | a test in `src/actions.test.ts` that fails without the rule. |
| 70 | 70 | - **A migration** — append to `migrations` in `src/db.ts`. Never edit one |
| 71 | 71 | that has run. `bun run src/db.ts --check` shows the version. |
| 72 | - **A page** — a row in `src/routes.ts` and a function in `src/page.ts`. | |
| 73 | With a second page, move them under `src/pages/` and keep one `layout`. | |
| 72 | - **A page** — a row in `src/routes.ts` and a function in `src/pages/` | |
| 73 | that returns a body and hands it to the one `layout` | |
| 74 | (`src/pages/layout.ts`). There is one layout and there is never a second | |
| 75 | copy of it. The parts a table is made of are `src/pages/table.ts`: plain | |
| 76 | data in, escaped HTML out, and every one of them shown at `/style` from | |
| 77 | a fixture. A page that invents its own markup for a charter, a claim or | |
| 78 | an act is a drift; add to `table.ts` instead. (`src/page.ts` is the | |
| 79 | lobby's body; it renders no document of its own.) | |
| 74 | 80 | - **An API route** — a row in `src/routes.ts` that calls the action and |
| 75 | 81 | answers with `answer(...)`; the status follows the failure. |
| 76 | 82 | - **A tool** — an entry in `tools` in `src/mcp.ts`: a name, one sentence, |
README.md +16 −2
| @@ -25,6 +25,15 @@ plain function — `(db, who, input)` in, data or a named failure out — and | ||
| 25 | 25 | the page, the JSON API, the MCP server and the terminal all call it and |
| 26 | 26 | decide nothing themselves. `AGENTS.md` says how to add to it. |
| 27 | 27 | |
| 28 | `/style` is where the look lives: every part a table is made of — the | |
| 29 | header's three doors, the charter grid, sworn and said claims, the act log, | |
| 30 | the clock slot, the offer form — rendered from a fixture in | |
| 31 | `src/pages/style.ts`, never from a table, so it reads no database and | |
| 32 | nobody's identity. The fixture is the worked example of RULES.md §10, and | |
| 33 | `src/pages/style.test.ts` holds it to those rules: a styleguide that | |
| 34 | disagrees with the game teaches the wrong thing twice. The table page | |
| 35 | renders the same functions from real state. | |
| 36 | ||
| 28 | 37 | | file | what it is | |
| 29 | 38 | | ---------------------------------- | ------------------------------------------------------------ | |
| 30 | 39 | | `src/actions.ts` | open, sit, leave, read a table — and who may see what | |
| @@ -38,7 +47,10 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 38 | 47 | | `src/live.ts` | the in-process bus and `GET /events` | |
| 39 | 48 | | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` | |
| 40 | 49 | | `src/routes.ts` | every path, in one table | |
| 41 | | `src/page.ts` | the HTML, rendered on the server | | |
| 50 | | `src/pages/layout.ts` | the one document every page is poured into | | |
| 51 | | `src/pages/table.ts` | the parts a table is made of: charter, claims, acts, clock | | |
| 52 | | `src/pages/style.ts` | `/style`, where the look lives | | |
| 53 | | `src/page.ts` | the lobby's body, poured into the layout | | |
| 42 | 54 | | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files | |
| 43 | 55 | | `src/server.ts` | boot: open the database, serve | |
| 44 | 56 | | `src/tty.ts` | the terminal, over the same actions | |
| @@ -97,7 +109,9 @@ so no shared cache, CDN or browser history can hand one player a view | ||
| 97 | 109 | redacted for another. The cost is the back button: going back refetches |
| 98 | 110 | rather than restoring instantly. Files under `/static` are the exception — |
| 99 | 111 | they are the same for everyone and are cached for a year when linked by |
| 100 | content — and a response that is genuinely public says so at its call site. | |
| 112 | content — and a response that is genuinely public says so at its call site: | |
| 113 | `/style` reads no table and nobody's identity, so it is `public, max-age=60` | |
| 114 | and varies on nothing a person carries. | |
| 101 | 115 | |
| 102 | 116 | ## Any interface |
| 103 | 117 |
src/http.ts +7 −3
| @@ -90,12 +90,16 @@ export const text = ( | ||
| 90 | 90 | }, |
| 91 | 91 | }); |
| 92 | 92 | |
| 93 | /** Back to the page, with a word for it to show once (`readNotice`). */ | |
| 94 | export const back = (notice?: string): Response => | |
| 93 | /** | |
| 94 | * Back to the page, with a word for it to show once (`readNotice`). `to` | |
| 95 | * is a path from the route table and never from a request: a redirect | |
| 96 | * built out of what a caller sent is how an open redirect happens. | |
| 97 | */ | |
| 98 | export const back = (notice?: string, to = "/"): Response => | |
| 95 | 99 | new Response(null, { |
| 96 | 100 | status: 303, |
| 97 | 101 | headers: { |
| 98 | location: "/", | |
| 102 | location: to, | |
| 99 | 103 | ...SECURITY, |
| 100 | 104 | ...PRIVATE, |
| 101 | 105 | ...(notice === undefined |
src/page.ts +12 −48
| @@ -1,53 +1,17 @@ | ||
| 1 | 1 | /** |
| 2 | * The page, rendered on the server: the first paint is complete HTML, and | |
| 3 | * `static/live.js` keeps the table list fresh afterwards. When a second page | |
| 4 | * arrives, move `layout` to `src/pages/layout.ts` and give each page a file. | |
| 2 | * The lobby, rendered on the server: the first paint is complete HTML, and | |
| 3 | * `static/live.js` keeps the table list fresh afterwards. The document | |
| 4 | * around it is `src/pages/layout.ts` — there is one layout, and this file | |
| 5 | * is only a body. | |
| 5 | 6 | */ |
| 6 | 7 | |
| 7 | 8 | import { canJoin, canLeave, SEATS, TITLE_MAX, type Table } from "./actions.ts"; |
| 8 | import { commit, description, env, ssh, title, where } from "./env.ts"; | |
| 9 | import { asset, escape } from "./http.ts"; | |
| 9 | import { where } from "./env.ts"; | |
| 10 | import { escape } from "./http.ts"; | |
| 11 | import { layout } from "./pages/layout.ts"; | |
| 10 | 12 | import { duration, timeTag } from "./when.ts"; |
| 11 | 13 | import type { Who } from "./who.ts"; |
| 12 | 14 | |
| 13 | export function layout(body: string, who: Who): string { | |
| 14 | const door = | |
| 15 | who.role === "anon" | |
| 16 | ? `<a class="button button-quiet" href="/__forge/signin?to=/">Sign in</a>` | |
| 17 | : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span> | |
| 18 | <a class="button button-quiet" href="/__forge/signout">Sign out</a>`; | |
| 19 | return `<!doctype html> | |
| 20 | <html lang="en"> | |
| 21 | <meta charset="utf-8"> | |
| 22 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 23 | <meta name="color-scheme" content="light dark"> | |
| 24 | <link rel="icon" href="data:,"> | |
| 25 | <title>${escape(title)}</title> | |
| 26 | <link rel="stylesheet" href="${asset("app.css")}"> | |
| 27 | <script type="module" src="${asset("live.js")}"></script> | |
| 28 | <body> | |
| 29 | <div class="page"> | |
| 30 | <header class="head"> | |
| 31 | <div> | |
| 32 | <h1>${escape(title)}</h1> | |
| 33 | <p class="muted">${escape(description)}</p> | |
| 34 | </div> | |
| 35 | <nav class="row"> | |
| 36 | <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""} | |
| 37 | ${door} | |
| 38 | </nav> | |
| 39 | </header> | |
| 40 | <main>${body}</main> | |
| 41 | <footer class="muted"> | |
| 42 | ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} | |
| 43 | <p>Also at <a href="/api/tables">/api/tables</a> and <code>POST /mcp</code>, with the same rules.</p> | |
| 44 | </footer> | |
| 45 | </div> | |
| 46 | </body> | |
| 47 | </html> | |
| 48 | `; | |
| 49 | } | |
| 50 | ||
| 51 | 15 | export function home(input: { |
| 52 | 16 | who: Who; |
| 53 | 17 | tables: readonly Table[]; |
| @@ -68,12 +32,12 @@ export function home(input: { | ||
| 68 | 32 | tables.length === 0 |
| 69 | 33 | ? `<p class="empty">No tables yet. A table opened here stays in ${escape(where)} data.</p>` |
| 70 | 34 | : `<ul class="list">${tables.map((table) => item(table, who)).join("")}</ul>`; |
| 71 | return layout( | |
| 72 | `<section class="card">${opener}</section> | |
| 73 | <section id="tables" data-live="table.opened seat.joined seat.left">${list}</section> | |
| 74 | ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`, | |
| 35 | return layout({ | |
| 75 | 36 | who, |
| 76 | ); | |
| 37 | notice, | |
| 38 | body: `<section class="card">${opener}</section> | |
| 39 | <section id="tables" data-live="table.opened seat.joined seat.left">${list}</section>`, | |
| 40 | }); | |
| 77 | 41 | } |
| 78 | 42 | |
| 79 | 43 | const seatOptions = (): string => |
src/pages/layout.ts added +85
| @@ -0,0 +1,85 @@ | ||
| 1 | /** | |
| 2 | * One document, every page. A page hands over its title, its body and who | |
| 3 | * is asking; the layout gives them the `<head>`, the header with the | |
| 4 | * forge's own sign-in and sign-out doors, the notice the last request left | |
| 5 | * behind, and the footer. Everything that came from a person is escaped | |
| 6 | * here, once, so a page never has to remember to. | |
| 7 | */ | |
| 8 | ||
| 9 | import { | |
| 10 | commit, | |
| 11 | description, | |
| 12 | env, | |
| 13 | ssh, | |
| 14 | title as appTitle, | |
| 15 | where, | |
| 16 | } from "../env.ts"; | |
| 17 | import { asset, escape } from "../http.ts"; | |
| 18 | import type { Who } from "../who.ts"; | |
| 19 | ||
| 20 | export interface Page { | |
| 21 | /** What this page is, for the tab. The app's name follows it. */ | |
| 22 | readonly title?: string | undefined; | |
| 23 | /** The page's HTML, already escaped by whoever built it. */ | |
| 24 | readonly body: string; | |
| 25 | readonly who: Who; | |
| 26 | /** Event types the main region follows (`static/live.js`). */ | |
| 27 | readonly live?: string | undefined; | |
| 28 | /** The word a redirect left for this request (`readNotice`). */ | |
| 29 | readonly notice?: string | undefined; | |
| 30 | } | |
| 31 | ||
| 32 | export function layout(page: Page): string { | |
| 33 | const { title, body, who, live, notice } = page; | |
| 34 | return `<!doctype html> | |
| 35 | <html lang="en"> | |
| 36 | <meta charset="utf-8"> | |
| 37 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 38 | <meta name="color-scheme" content="light dark"> | |
| 39 | <link rel="icon" href="data:,"> | |
| 40 | <title>${escape(title === undefined ? appTitle : `${title} · ${appTitle}`)}</title> | |
| 41 | <link rel="stylesheet" href="${asset("app.css")}"> | |
| 42 | <script type="module" src="${asset("live.js")}"></script> | |
| 43 | <body> | |
| 44 | <div class="page"> | |
| 45 | ${header(who)} | |
| 46 | <main id="main"${live === undefined ? "" : ` data-live="${escape(live)}"`}>${body}</main> | |
| 47 | ${footer()} | |
| 48 | </div> | |
| 49 | ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`} | |
| 50 | </body> | |
| 51 | </html> | |
| 52 | `; | |
| 53 | } | |
| 54 | ||
| 55 | /** The app's name, where this copy runs, and the door for who is asking. */ | |
| 56 | const header = (who: Who): string => `<header class="head"> | |
| 57 | <div> | |
| 58 | <h1><a href="/">${escape(appTitle)}</a></h1> | |
| 59 | <p class="muted">${escape(description)}</p> | |
| 60 | </div> | |
| 61 | ${headerNav(who)} | |
| 62 | </header>`; | |
| 63 | ||
| 64 | /** | |
| 65 | * Where this copy runs, which commit it is, and the door. Exported because | |
| 66 | * it is the one part of the header that changes with who is asking, and so | |
| 67 | * the one part `/style` has to be able to show in every state it has. | |
| 68 | */ | |
| 69 | export const headerNav = (who: Who): string => `<nav class="row"> | |
| 70 | <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""} | |
| 71 | ${door(who)} | |
| 72 | </nav>`; | |
| 73 | ||
| 74 | // Signing in and out is the forge's, on this app's own host: the app never | |
| 75 | // invents an identity, so it never offers one. | |
| 76 | const door = (who: Who): string => | |
| 77 | who.role === "anon" | |
| 78 | ? `<a class="button button-quiet" href="/__forge/signin?to=/">Sign in</a>` | |
| 79 | : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span> | |
| 80 | <a class="button button-quiet" href="/__forge/signout">Sign out</a>`; | |
| 81 | ||
| 82 | const footer = (): string => `<footer class="muted"> | |
| 83 | <p>${ssh === undefined ? "The terminal is off." : `Terminal: <code>${escape(ssh)}</code>`}</p> | |
| 84 | <p>Also at <a href="/api/tables">/api/tables</a> and <code>POST /mcp</code>, with the same rules. Every part a table is made of is at <a href="/style">/style</a>.</p> | |
| 85 | </footer>`; |
src/pages/style.test.ts added +141
| @@ -0,0 +1,141 @@ | ||
| 1 | /** | |
| 2 | * The fixture at `/style` claims to be the worked example of RULES.md §10, | |
| 3 | * and a styleguide that lies is worse than none: #12 and #15 read this page | |
| 4 | * to learn what a table looks like. So the fixture is checked against the | |
| 5 | * rules it cites — the seats against the ledger, the ledger against the | |
| 6 | * turn order and the clock, every claim against the charter — because | |
| 7 | * nothing else would notice a seat holding a sworn claim that no act of | |
| 8 | * its own ever made. | |
| 9 | */ | |
| 10 | ||
| 11 | import { expect, test } from "bun:test"; | |
| 12 | import { CLAUSE_POOL } from "../board.ts"; | |
| 13 | import { | |
| 14 | ACTS, | |
| 15 | CHARTER, | |
| 16 | DEADLINE_AT, | |
| 17 | DEADLINE_SECONDS, | |
| 18 | NOW, | |
| 19 | QUORUM, | |
| 20 | SEATS, | |
| 21 | STANDING, | |
| 22 | STARTED, | |
| 23 | TURN_SECONDS, | |
| 24 | WINDOW_ENDS, | |
| 25 | } from "./style.ts"; | |
| 26 | ||
| 27 | const ms = (iso: string): number => Date.parse(iso); | |
| 28 | const seatNamed = (name: string) => SEATS.find((seat) => seat.name === name)!; | |
| 29 | const offers = ACTS.filter((act) => act.kind === "offer"); | |
| 30 | const lastOffer = offers[offers.length - 1]!; | |
| 31 | const accepts = ACTS.filter((act) => act.kind === "accept"); | |
| 32 | /** One key for an aim, to compare a seat's sworn claims with the ledger's. */ | |
| 33 | const aim = (s: { seat: number; clause: string; setting: string; weight: number }) => | |
| 34 | `${s.seat}:${s.clause}=${s.setting}:${s.weight}`; | |
| 35 | ||
| 36 | test("the charter is 7 clauses of the pool, each with its own 3 settings", () => { | |
| 37 | expect(CHARTER).toHaveLength(7); | |
| 38 | expect(new Set(CHARTER.map((clause) => clause.id)).size).toBe(7); | |
| 39 | for (const clause of CHARTER) { | |
| 40 | expect(CLAUSE_POOL).toContain(clause); | |
| 41 | expect(clause.settings).toHaveLength(3); | |
| 42 | } | |
| 43 | }); | |
| 44 | ||
| 45 | test("the standing offer is an accord: one setting for every clause, no more", () => { | |
| 46 | expect(Object.keys(STANDING).sort()).toEqual( | |
| 47 | CHARTER.map((clause) => clause.id).sort(), | |
| 48 | ); | |
| 49 | for (const clause of CHARTER) | |
| 50 | expect(clause.settings).toContain(STANDING[clause.id]!); | |
| 51 | }); | |
| 52 | ||
| 53 | test("every sworn claim a seat holds was written by an accept act of that seat", () => { | |
| 54 | const held = SEATS.flatMap((seat) => seat.sworn.map(aim)).sort(); | |
| 55 | const written = accepts.map((act) => aim(act.sworn!)).sort(); | |
| 56 | expect(held).toEqual(written); | |
| 57 | for (const act of accepts) { | |
| 58 | expect(act.sworn!.seat).toBe(seatNamed(act.name).ordinal); | |
| 59 | // Sworn once in a game: an aim is citable exactly one time (§6). | |
| 60 | expect(written.filter((one) => one === aim(act.sworn!))).toHaveLength(1); | |
| 61 | } | |
| 62 | // And a pass or an offer never writes one: only an acceptance can (§6). | |
| 63 | for (const act of ACTS) | |
| 64 | if (act.kind !== "accept") expect(act.sworn).toBeUndefined(); | |
| 65 | }); | |
| 66 | ||
| 67 | test("an acceptance of the offer that stands cites an aim that offer satisfies", () => { | |
| 68 | for (const act of accepts.filter((one) => one.n > lastOffer.n)) { | |
| 69 | const { clause, setting, weight } = act.sworn!; | |
| 70 | expect(STANDING[clause]).toBe(setting); | |
| 71 | expect([1, 2, 3, 5]).toContain(weight); | |
| 72 | } | |
| 73 | }); | |
| 74 | ||
| 75 | test("a seat stands as accepted exactly when it accepted the offer that stands", () => { | |
| 76 | const since = new Set( | |
| 77 | accepts.filter((act) => act.n > lastOffer.n).map((act) => act.name), | |
| 78 | ); | |
| 79 | for (const seat of SEATS) | |
| 80 | expect(seat.standing === "accepted").toBe(since.has(seat.name)); | |
| 81 | // Two of the three that would bind it: this table is still playing. | |
| 82 | expect(QUORUM).toBe(Math.ceil(SEATS.length / 2) + 1); | |
| 83 | expect(since.size).toBeLessThan(QUORUM); | |
| 84 | // The seat to act is the one the fixed order says it is (§5). | |
| 85 | const toAct = SEATS.filter((seat) => seat.standing === "to act"); | |
| 86 | expect(toAct).toHaveLength(1); | |
| 87 | expect(toAct[0]!.ordinal).toBe(ACTS.length % SEATS.length); | |
| 88 | }); | |
| 89 | ||
| 90 | test("the ledger is in order, in seat order, and no seat is skipped", () => { | |
| 91 | ACTS.forEach((act, i) => { | |
| 92 | expect(act.n).toBe(i + 1); | |
| 93 | expect(seatNamed(act.name).ordinal).toBe(i % SEATS.length); | |
| 94 | if (i > 0) expect(ms(act.at)).toBeGreaterThan(ms(ACTS[i - 1]!.at)); | |
| 95 | }); | |
| 96 | }); | |
| 97 | ||
| 98 | test("every act happens inside its own turn window, and a lapse at the end of one", () => { | |
| 99 | ACTS.forEach((act, i) => { | |
| 100 | const opened = i === 0 ? ms(STARTED) : ms(ACTS[i - 1]!.at); | |
| 101 | const waited = (ms(act.at) - opened) / 1000; | |
| 102 | expect(waited).toBeGreaterThan(0); | |
| 103 | expect(waited).toBeLessThanOrEqual(TURN_SECONDS); | |
| 104 | // A window that passed unplayed is a pass written at the moment it ran | |
| 105 | // out, and nothing else is marked lapsed (§8). | |
| 106 | if (act.lapsed === true) { | |
| 107 | expect(act.kind).toBe("pass"); | |
| 108 | expect(waited).toBe(TURN_SECONDS); | |
| 109 | } | |
| 110 | expect(ms(act.at)).toBeLessThan(ms(NOW)); | |
| 111 | expect(act.when.endsWith(" ago")).toBe(true); | |
| 112 | }); | |
| 113 | expect(ACTS.some((act) => act.lapsed === true)).toBe(true); | |
| 114 | }); | |
| 115 | ||
| 116 | test("the clock the page shows is the arithmetic of the ledger it shows", () => { | |
| 117 | const last = ACTS[ACTS.length - 1]!; | |
| 118 | expect(ms(WINDOW_ENDS)).toBe(ms(last.at) + TURN_SECONDS * 1000); | |
| 119 | expect(ms(DEADLINE_AT)).toBe(ms(STARTED) + DEADLINE_SECONDS * 1000); | |
| 120 | // Both still ahead of the moment the fixture is frozen at, and the | |
| 121 | // deadline is never less than two turn windows (§8). | |
| 122 | expect(ms(WINDOW_ENDS)).toBeGreaterThan(ms(NOW)); | |
| 123 | expect(ms(DEADLINE_AT)).toBeGreaterThan(ms(NOW)); | |
| 124 | expect(DEADLINE_SECONDS).toBeGreaterThanOrEqual(2 * TURN_SECONDS); | |
| 125 | }); | |
| 126 | ||
| 127 | test("every claim names a clause of this charter at a setting it holds", () => { | |
| 128 | const settings = new Map( | |
| 129 | CHARTER.map((clause) => [clause.id, clause.settings as readonly string[]]), | |
| 130 | ); | |
| 131 | const claims = [ | |
| 132 | ...SEATS.flatMap((seat) => seat.sworn), | |
| 133 | ...ACTS.flatMap((act) => [ | |
| 134 | ...(act.sworn === undefined ? [] : [act.sworn]), | |
| 135 | ...(act.said ?? []), | |
| 136 | ]), | |
| 137 | ]; | |
| 138 | expect(claims.length).toBeGreaterThan(0); | |
| 139 | for (const claim of claims) | |
| 140 | expect(settings.get(claim.clause)).toContain(claim.setting); | |
| 141 | }); |
src/pages/style.ts added +292
| @@ -0,0 +1,292 @@ | ||
| 1 | /** | |
| 2 | * `/style`: every part of a table, rendered from a fixture. | |
| 3 | * | |
| 4 | * The fixture is the worked example of RULES.md §10 — seed `tidewater`, | |
| 5 | * four seats, the second offer standing — frozen here as constants: no | |
| 6 | * database, no identity, no clock, no secret. What this page shows is the | |
| 7 | * output of `src/pages/table.ts`, which is what the table page (#12) will | |
| 8 | * call with real state, so a reviewer looking at this page is looking at | |
| 9 | * the real components and not at a drawing of them. | |
| 10 | */ | |
| 11 | ||
| 12 | import { CLAUSE_POOL, type Accord, type Clause } from "../board.ts"; | |
| 13 | import { ANON, type Who } from "../who.ts"; | |
| 14 | import { headerNav, layout } from "./layout.ts"; | |
| 15 | import { | |
| 16 | actBar, | |
| 17 | actLog, | |
| 18 | charterGrid, | |
| 19 | clockSlot, | |
| 20 | offerForm, | |
| 21 | refusal, | |
| 22 | saidClaim, | |
| 23 | seatList, | |
| 24 | type Act, | |
| 25 | type Said, | |
| 26 | type Seat, | |
| 27 | } from "./table.ts"; | |
| 28 | ||
| 29 | // ----------------------------------------------------------- the fixture | |
| 30 | ||
| 31 | /** | |
| 32 | * The moment this table is frozen at, and the clock it plays on: a 6 hour | |
| 33 | * turn window (the default) and a 3 day deadline. Every `at` below is a | |
| 34 | * real instant against these, and `when` is the words for it, so the | |
| 35 | * ledger is arithmetic a reader can check (`src/pages/style.test.ts` | |
| 36 | * checks it for them). | |
| 37 | */ | |
| 38 | export const NOW = "2026-09-18T12:00:00Z"; | |
| 39 | export const STARTED = "2026-09-17T06:00:00Z"; | |
| 40 | export const TURN_SECONDS = 6 * 3600; | |
| 41 | export const DEADLINE_SECONDS = 3 * 86400; | |
| 42 | ||
| 43 | /** | |
| 44 | * The seven clauses of RULES.md §10, by id. A clause the pool no longer | |
| 45 | * holds fails at boot rather than at the first request for the page. | |
| 46 | */ | |
| 47 | export const CHARTER: readonly Clause[] = [ | |
| 48 | "beacon", | |
| 49 | "hold", | |
| 50 | "ledger", | |
| 51 | "pilot", | |
| 52 | "name", | |
| 53 | "chart", | |
| 54 | "salvage", | |
| 55 | ].map((id) => { | |
| 56 | const clause = CLAUSE_POOL.find((one) => one.id === id); | |
| 57 | if (clause === undefined) | |
| 58 | throw new Error(`style: the pool holds no clause ${id} (RULES §3)`); | |
| 59 | return clause; | |
| 60 | }); | |
| 61 | ||
| 62 | /** A₂, the standing offer after act 5. */ | |
| 63 | export const STANDING: Accord = { | |
| 64 | beacon: "kept", | |
| 65 | hold: "sealed", | |
| 66 | ledger: "published", | |
| 67 | pilot: "reinstated", | |
| 68 | name: "changed", | |
| 69 | chart: "locked", | |
| 70 | salvage: "to_the_finders", | |
| 71 | }; | |
| 72 | ||
| 73 | /** | |
| 74 | * The seats after act 8, with A₂ standing. Ash is to act; Brail and Cleat | |
| 75 | * have accepted A₂ — two of the three that would bind it — and Dunnage has | |
| 76 | * done nothing but pass. Every sworn claim here was written by an accept | |
| 77 | * act in `ACTS` below, which is the one thing about this fixture that is | |
| 78 | * easy to get wrong and is therefore tested. | |
| 79 | */ | |
| 80 | export const SEATS: readonly Seat[] = [ | |
| 81 | { ordinal: 0, name: "Ash", standing: "to act", sworn: [] }, | |
| 82 | { | |
| 83 | ordinal: 1, | |
| 84 | name: "Brail", | |
| 85 | standing: "accepted", | |
| 86 | sworn: [{ seat: 1, clause: "name", setting: "changed", weight: 1 }], | |
| 87 | }, | |
| 88 | { | |
| 89 | ordinal: 2, | |
| 90 | name: "Cleat", | |
| 91 | standing: "accepted", | |
| 92 | sworn: [ | |
| 93 | { seat: 2, clause: "pilot", setting: "reinstated", weight: 3 }, | |
| 94 | { seat: 2, clause: "salvage", setting: "to_the_finders", weight: 1 }, | |
| 95 | ], | |
| 96 | }, | |
| 97 | { ordinal: 3, name: "Dunnage", standing: "silent", sworn: [] }, | |
| 98 | ]; | |
| 99 | ||
| 100 | /** | |
| 101 | * The example ledger of RULES.md §10, oldest first, two rounds of four. | |
| 102 | * Cleat's sworn `pilot` survives A₂ clearing his acceptance (§5): sworn | |
| 103 | * claims are public forever. Dunnage's last window ran out at act 8, so | |
| 104 | * the engine wrote the pass for him and marked it lapsed (§8). | |
| 105 | */ | |
| 106 | export const ACTS: readonly Act[] = [ | |
| 107 | { | |
| 108 | n: 1, | |
| 109 | name: "Ash", | |
| 110 | kind: "offer", | |
| 111 | at: "2026-09-17T08:00:00Z", | |
| 112 | when: "1d 4h ago", | |
| 113 | said: [{ clause: "ledger", setting: "redacted", worth: "small" }], | |
| 114 | }, | |
| 115 | { | |
| 116 | n: 2, | |
| 117 | name: "Brail", | |
| 118 | kind: "pass", | |
| 119 | at: "2026-09-17T11:00:00Z", | |
| 120 | when: "1d 1h ago", | |
| 121 | said: [{ clause: "ledger", setting: "published", worth: "large" }], | |
| 122 | }, | |
| 123 | { | |
| 124 | n: 3, | |
| 125 | name: "Cleat", | |
| 126 | kind: "accept", | |
| 127 | at: "2026-09-17T15:00:00Z", | |
| 128 | when: "21h ago", | |
| 129 | sworn: { seat: 2, clause: "pilot", setting: "reinstated", weight: 3 }, | |
| 130 | }, | |
| 131 | { | |
| 132 | n: 4, | |
| 133 | name: "Dunnage", | |
| 134 | kind: "pass", | |
| 135 | at: "2026-09-17T19:00:00Z", | |
| 136 | when: "17h ago", | |
| 137 | said: [{ clause: "ledger", setting: "redacted", worth: "none" }], | |
| 138 | }, | |
| 139 | { | |
| 140 | n: 5, | |
| 141 | name: "Ash", | |
| 142 | kind: "offer", | |
| 143 | at: "2026-09-17T22:00:00Z", | |
| 144 | when: "14h ago", | |
| 145 | said: [], | |
| 146 | }, | |
| 147 | { | |
| 148 | n: 6, | |
| 149 | name: "Brail", | |
| 150 | kind: "accept", | |
| 151 | at: "2026-09-18T00:00:00Z", | |
| 152 | when: "12h ago", | |
| 153 | sworn: { seat: 1, clause: "name", setting: "changed", weight: 1 }, | |
| 154 | }, | |
| 155 | { | |
| 156 | n: 7, | |
| 157 | name: "Cleat", | |
| 158 | kind: "accept", | |
| 159 | at: "2026-09-18T04:00:00Z", | |
| 160 | when: "8h ago", | |
| 161 | sworn: { seat: 2, clause: "salvage", setting: "to_the_finders", weight: 1 }, | |
| 162 | }, | |
| 163 | { | |
| 164 | n: 8, | |
| 165 | name: "Dunnage", | |
| 166 | kind: "pass", | |
| 167 | at: "2026-09-18T10:00:00Z", | |
| 168 | when: "2h ago", | |
| 169 | lapsed: true, | |
| 170 | }, | |
| 171 | ]; | |
| 172 | ||
| 173 | /** The quorum for four seats: ⌈4/2⌉+1 (RULES §2). */ | |
| 174 | export const QUORUM = 3; | |
| 175 | ||
| 176 | /** When the window of the seat to act runs out: the last act plus one window. */ | |
| 177 | export const WINDOW_ENDS = "2026-09-18T16:00:00Z"; | |
| 178 | ||
| 179 | /** When the table is impounded if nothing binds: it started plus the deadline. */ | |
| 180 | export const DEADLINE_AT = "2026-09-20T06:00:00Z"; | |
| 181 | ||
| 182 | /** | |
| 183 | * The three kinds of person the header has a door for. Sample identities, | |
| 184 | * not the caller's: `/style` never reads who is asking. | |
| 185 | */ | |
| 186 | const PEOPLE: readonly Who[] = [ | |
| 187 | ANON, | |
| 188 | { role: "user", id: "u1", name: "brail" }, | |
| 189 | { role: "admin", id: "u0", name: "quartermaster" }, | |
| 190 | ]; | |
| 191 | ||
| 192 | /** One said claim in its three states: while the game runs, and both verdicts. */ | |
| 193 | const CLAIMED: Said = { | |
| 194 | clause: "ledger", | |
| 195 | setting: "redacted", | |
| 196 | worth: "none", | |
| 197 | }; | |
| 198 | ||
| 199 | // -------------------------------------------------------------- the page | |
| 200 | ||
| 201 | /** One specimen: what it is, why it looks like that, and the thing itself. */ | |
| 202 | interface Specimen { | |
| 203 | readonly heading: string; | |
| 204 | readonly why: string; | |
| 205 | readonly body: string; | |
| 206 | } | |
| 207 | ||
| 208 | const SPECIMENS: readonly Specimen[] = [ | |
| 209 | { | |
| 210 | heading: "The header, for each kind of person", | |
| 211 | why: "The one part of the layout that changes with who is asking: a guest is offered the forge's sign-in, a person is named, an admin is named as one. It wraps rather than clipping the door at 360px.", | |
| 212 | body: PEOPLE.map(headerNav).join("\n"), | |
| 213 | }, | |
| 214 | { | |
| 215 | heading: "The charter", | |
| 216 | why: "Seven clauses by three settings. The columns are fixed and every cell carries the mark's space, so marking a setting moves nothing around it.", | |
| 217 | body: charterGrid({ | |
| 218 | clauses: CHARTER, | |
| 219 | offer: STANDING, | |
| 220 | caption: "The standing offer, marked.", | |
| 221 | }), | |
| 222 | }, | |
| 223 | { | |
| 224 | heading: "The clock", | |
| 225 | why: "The words come from one module (src/when.ts) and the slot has a fixed width in tabular figures, so a countdown ticking in it never reflows its row.", | |
| 226 | body: | |
| 227 | clockSlot({ label: "Your move", at: WINDOW_ENDS, words: "4h left" }) + | |
| 228 | clockSlot({ label: "Deadline", at: DEADLINE_AT, words: "1d 18h left" }), | |
| 229 | }, | |
| 230 | { | |
| 231 | heading: "The seats", | |
| 232 | why: "Where each seat stands on the offer that stands now, and every aim it has sworn. A sworn claim is engine-verified: it cannot be false.", | |
| 233 | body: seatList({ seats: SEATS, clauses: CHARTER, quorum: QUORUM }), | |
| 234 | }, | |
| 235 | { | |
| 236 | heading: "A said claim, three ways", | |
| 237 | why: "Said claims are shape-checked and never truth-checked. They are unjudged while the game runs, and marked true or false in public at the reveal.", | |
| 238 | body: `<ul class="claims"> | |
| 239 | ${saidClaim(CLAIMED, CHARTER)} | |
| 240 | ${saidClaim({ ...CLAIMED, verdict: true }, CHARTER)} | |
| 241 | ${saidClaim({ ...CLAIMED, verdict: false }, CHARTER)} | |
| 242 | </ul>`, | |
| 243 | }, | |
| 244 | { | |
| 245 | heading: "The acts", | |
| 246 | why: "The ledger, oldest first: the newest act is the last line. A turn window that passed unplayed is a real pass, marked lapsed.", | |
| 247 | body: actLog({ acts: ACTS, clauses: CHARTER }), | |
| 248 | }, | |
| 249 | { | |
| 250 | heading: "Accept, and pass", | |
| 251 | why: "Accepting costs a true, fresh aim of your own, so the button says what it would publish before you press it.", | |
| 252 | body: actBar({ | |
| 253 | accept: "/style", | |
| 254 | pass: "/style", | |
| 255 | clauses: CHARTER, | |
| 256 | swears: { seat: 0, clause: "beacon", setting: "kept", weight: 3 }, | |
| 257 | }), | |
| 258 | }, | |
| 259 | { | |
| 260 | heading: "And when you may not accept", | |
| 261 | why: "Accept is not rendered at all. The refusal is one sentence in the voice the action uses, and it never names an aim the reader does not already hold.", | |
| 262 | body: | |
| 263 | actBar({ | |
| 264 | pass: "/style", | |
| 265 | clauses: CHARTER, | |
| 266 | refused: | |
| 267 | "This accord satisfies no aim of yours that is still secret — there is nothing left for you to swear for it.", | |
| 268 | }) + refusal("That is not one of your aims."), | |
| 269 | }, | |
| 270 | { | |
| 271 | heading: "An offer", | |
| 272 | why: "Seven radio groups and one button. It works with JavaScript off, and every choice is a 44px target.", | |
| 273 | body: offerForm({ clauses: CHARTER, action: "/style", chosen: STANDING }), | |
| 274 | }, | |
| 275 | ]; | |
| 276 | ||
| 277 | export function style(): string { | |
| 278 | return layout({ | |
| 279 | title: "The look of a table", | |
| 280 | who: ANON, | |
| 281 | body: `<p class="note-top muted">Every part of a table, rendered from a fixture — the worked example of RULES.md §10, frozen in <code>src/pages/style.ts</code> and checked against those rules by <code>src/pages/style.test.ts</code>. No table is read and nobody's identity is, so this page is the same bytes for everyone. The forms are the real markup and really post: they land back here and write nothing. The table page calls these same functions with real state.</p> | |
| 282 | ${SPECIMENS.map(part).join("\n")}`, | |
| 283 | }); | |
| 284 | } | |
| 285 | ||
| 286 | // The headings and the sentences are literals of this module, not anyone's | |
| 287 | // input; everything a component renders was escaped in `table.ts`. | |
| 288 | const part = (specimen: Specimen): string => `<section class="specimen"> | |
| 289 | <h2>${specimen.heading}</h2> | |
| 290 | <p class="muted">${specimen.why}</p> | |
| 291 | ${specimen.body} | |
| 292 | </section>`; |
src/pages/table.ts added +283
| @@ -0,0 +1,283 @@ | ||
| 1 | /** | |
| 2 | * The parts a table is made of, as HTML: the charter grid, the seats and | |
| 3 | * what they have sworn, claims and their verdicts, the act log, the clock | |
| 4 | * slot, and the acts a seat can take. | |
| 5 | * | |
| 6 | * Every function here is plain data in and an escaped string out — no | |
| 7 | * database, no identity, no clock, no `Date.now()`. `/style` | |
| 8 | * (`src/pages/style.ts`) renders them from a fixture so the vocabulary can | |
| 9 | * be reviewed on its own; the table page (#12) renders the same functions | |
| 10 | * from real state, which is the only way the two cannot drift. | |
| 11 | * | |
| 12 | * Time is never formatted here. A caller hands in the words (`src/when.ts`, | |
| 13 | * #20) and the machine-readable instant beside them. | |
| 14 | */ | |
| 15 | ||
| 16 | import type { Accord, Clause } from "../board.ts"; | |
| 17 | import { escape } from "../http.ts"; | |
| 18 | ||
| 19 | // ------------------------------------------------------------ the vocabulary | |
| 20 | ||
| 21 | /** A published, engine-verified aim: public forever once it is written (RULES §6). */ | |
| 22 | export interface Sworn { | |
| 23 | readonly seat: number; | |
| 24 | readonly clause: string; | |
| 25 | readonly setting: string; | |
| 26 | readonly weight: number; | |
| 27 | } | |
| 28 | ||
| 29 | /** What a seat says an aim is worth to it — believed or not (RULES §7). */ | |
| 30 | export type Worth = "none" | "small" | "large" | "dread"; | |
| 31 | ||
| 32 | /** An unverified statement about the speaker's own charge. */ | |
| 33 | export interface Said { | |
| 34 | readonly clause: string; | |
| 35 | readonly setting: string; | |
| 36 | readonly worth: Worth; | |
| 37 | /** True or false at the reveal; undefined while the game runs. */ | |
| 38 | readonly verdict?: boolean | undefined; | |
| 39 | } | |
| 40 | ||
| 41 | /** Where a seat stands on the offer that stands now. */ | |
| 42 | export type Standing = "accepted" | "silent" | "to act"; | |
| 43 | ||
| 44 | export interface Seat { | |
| 45 | readonly ordinal: number; | |
| 46 | readonly name: string; | |
| 47 | readonly standing: Standing; | |
| 48 | readonly sworn: readonly Sworn[]; | |
| 49 | } | |
| 50 | ||
| 51 | /** One entry in the ledger (RULES §5). `when` is the words for `at`. */ | |
| 52 | export interface Act { | |
| 53 | readonly n: number; | |
| 54 | readonly name: string; | |
| 55 | readonly kind: "offer" | "accept" | "pass"; | |
| 56 | readonly at: string; | |
| 57 | readonly when: string; | |
| 58 | readonly lapsed?: boolean | undefined; | |
| 59 | readonly sworn?: Sworn | undefined; | |
| 60 | readonly said?: readonly Said[] | undefined; | |
| 61 | } | |
| 62 | ||
| 63 | // ---------------------------------------------------------------- the parts | |
| 64 | ||
| 65 | /** | |
| 66 | * The charter: 7 clauses by 3 settings, one setting per clause marked when | |
| 67 | * an accord stands over it. Every cell carries the mark's space whether or | |
| 68 | * not it is marked, so marking one moves nothing. | |
| 69 | */ | |
| 70 | export function charterGrid(input: { | |
| 71 | readonly clauses: readonly Clause[]; | |
| 72 | readonly offer?: Accord | undefined; | |
| 73 | readonly caption?: string | undefined; | |
| 74 | }): string { | |
| 75 | const { clauses, offer, caption } = input; | |
| 76 | return `<section class="charter" aria-label="The charter"> | |
| 77 | ${caption === undefined ? "" : `<p class="charter-caption muted">${escape(caption)}</p>`} | |
| 78 | <ol class="clauses">${clauses.map((clause) => clauseRow(clause, offer?.[clause.id])).join("")}</ol> | |
| 79 | </section>`; | |
| 80 | } | |
| 81 | ||
| 82 | const clauseRow = (clause: Clause, offered: string | undefined): string => | |
| 83 | `<li class="clause"> | |
| 84 | <h3 class="clause-title">${escape(clause.title)}</h3> | |
| 85 | <ul class="settings">${clause.settings | |
| 86 | .map((setting) => { | |
| 87 | const on = setting === offered; | |
| 88 | return `<li class="setting"${on ? ` data-offered="yes" aria-current="true"` : ""}> | |
| 89 | <span class="tick" aria-hidden="true">${on ? "●" : ""}</span> | |
| 90 | <span class="setting-name">${escape(words(setting))}</span> | |
| 91 | ${on ? `<span class="vh">— the standing offer</span>` : ""} | |
| 92 | </li>`; | |
| 93 | }) | |
| 94 | .join("")}</ul> | |
| 95 | </li>`; | |
| 96 | ||
| 97 | /** The seats in order, each with where it stands and what it has sworn. */ | |
| 98 | export function seatList(input: { | |
| 99 | readonly seats: readonly Seat[]; | |
| 100 | readonly clauses: readonly Clause[]; | |
| 101 | readonly quorum?: number | undefined; | |
| 102 | }): string { | |
| 103 | const { seats, clauses, quorum } = input; | |
| 104 | const accepted = seats.filter((seat) => seat.standing === "accepted").length; | |
| 105 | return `<section class="seats" aria-label="The seats"> | |
| 106 | ${ | |
| 107 | quorum === undefined | |
| 108 | ? "" | |
| 109 | : `<p class="muted">${accepted} of ${quorum} needed to bind.</p>` | |
| 110 | } | |
| 111 | <ol class="seat-list">${seats.map((seat) => seatRow(seat, clauses)).join("")}</ol> | |
| 112 | </section>`; | |
| 113 | } | |
| 114 | ||
| 115 | export const seatRow = (seat: Seat, clauses: readonly Clause[]): string => | |
| 116 | `<li class="seat"> | |
| 117 | <div class="seat-head"> | |
| 118 | <span class="seat-ordinal" aria-hidden="true">${seat.ordinal + 1}</span> | |
| 119 | <span class="seat-name">${escape(seat.name)}</span> | |
| 120 | <span class="badge" data-standing="${escape(seat.standing)}">${escape(seat.standing)}</span> | |
| 121 | </div> | |
| 122 | ${ | |
| 123 | seat.sworn.length === 0 | |
| 124 | ? `<p class="muted claims-none">Has sworn nothing.</p>` | |
| 125 | : `<ul class="claims">${seat.sworn.map((sworn) => swornClaim(sworn, clauses)).join("")}</ul>` | |
| 126 | } | |
| 127 | </li>`; | |
| 128 | ||
| 129 | /** A sworn claim. The engine wrote it, so it cannot be false (RULES §6). */ | |
| 130 | export const swornClaim = ( | |
| 131 | sworn: Sworn, | |
| 132 | clauses: readonly Clause[], | |
| 133 | ): string => `<li class="claim" data-kind="sworn"> | |
| 134 | <span class="claim-kind">sworn</span> | |
| 135 | <span class="claim-what">${escape(title(clauses, sworn.clause))} ${escape(words(sworn.setting))}</span> | |
| 136 | <span class="weight">${escape(signed(sworn.weight))}</span> | |
| 137 | </li>`; | |
| 138 | ||
| 139 | /** A said claim: a worth, and at the reveal a verdict on it (RULES §7). */ | |
| 140 | export const saidClaim = ( | |
| 141 | said: Said, | |
| 142 | clauses: readonly Clause[], | |
| 143 | ): string => `<li class="claim" data-kind="said"${said.verdict === undefined ? "" : ` data-verdict="${said.verdict ? "true" : "false"}"`}> | |
| 144 | <span class="claim-kind">said</span> | |
| 145 | <span class="claim-what">${escape(title(clauses, said.clause))} ${escape(words(said.setting))}</span> | |
| 146 | <span class="worth">${escape(said.worth)}</span> | |
| 147 | ${ | |
| 148 | said.verdict === undefined | |
| 149 | ? `<span class="verdict verdict-open">unjudged</span>` | |
| 150 | : `<span class="verdict">${said.verdict ? "true" : "false"}</span>` | |
| 151 | } | |
| 152 | </li>`; | |
| 153 | ||
| 154 | /** The ledger, oldest first: the newest act is the last line of the list. */ | |
| 155 | export function actLog(input: { | |
| 156 | readonly acts: readonly Act[]; | |
| 157 | readonly clauses: readonly Clause[]; | |
| 158 | }): string { | |
| 159 | const { acts, clauses } = input; | |
| 160 | return `<section class="log" aria-label="The acts, oldest first"> | |
| 161 | ${ | |
| 162 | acts.length === 0 | |
| 163 | ? `<p class="empty">No acts yet. The first offer starts the argument.</p>` | |
| 164 | : `<ol class="act-list">${acts.map((act) => actRow(act, clauses)).join("")}</ol>` | |
| 165 | } | |
| 166 | </section>`; | |
| 167 | } | |
| 168 | ||
| 169 | const VERB = { offer: "offered", accept: "accepted", pass: "passed" } as const; | |
| 170 | ||
| 171 | export const actRow = (act: Act, clauses: readonly Clause[]): string => | |
| 172 | `<li class="act" data-kind="${escape(act.kind)}"> | |
| 173 | <div class="act-head"> | |
| 174 | <span class="act-n" aria-hidden="true">${escape(String(act.n))}</span> | |
| 175 | <span class="act-who">${escape(act.name)}</span> | |
| 176 | <span class="act-kind">${VERB[act.kind]}${act.lapsed === true ? " (lapsed)" : ""}</span> | |
| 177 | ${when(act.at, act.when)} | |
| 178 | </div> | |
| 179 | ${ | |
| 180 | act.sworn === undefined && (act.said ?? []).length === 0 | |
| 181 | ? "" | |
| 182 | : `<ul class="claims">${[ | |
| 183 | act.sworn === undefined ? "" : swornClaim(act.sworn, clauses), | |
| 184 | ...(act.said ?? []).map((said) => saidClaim(said, clauses)), | |
| 185 | ].join("")}</ul>` | |
| 186 | } | |
| 187 | </li>`; | |
| 188 | ||
| 189 | /** | |
| 190 | * The clock slot. The words are the caller's (`src/when.ts`, #20); the | |
| 191 | * slot's width is the CSS's, so a countdown ticking inside it never moves | |
| 192 | * the row around it. | |
| 193 | */ | |
| 194 | export const clockSlot = (input: { | |
| 195 | readonly label: string; | |
| 196 | readonly at: string; | |
| 197 | readonly words: string; | |
| 198 | }): string => `<p class="clock"> | |
| 199 | <span class="clock-label">${escape(input.label)}</span> | |
| 200 | ${when(input.at, input.words)} | |
| 201 | </p>`; | |
| 202 | ||
| 203 | const when = (at: string, words: string): string => | |
| 204 | `<time class="when" datetime="${escape(at)}">${escape(words)}</time>`; | |
| 205 | ||
| 206 | /** | |
| 207 | * An offer: one radio group per clause, one button, no JavaScript. The | |
| 208 | * field for a clause is `accord.<clause id>`, and an accord is all 7 or it | |
| 209 | * is not an accord (RULES §3). | |
| 210 | */ | |
| 211 | export function offerForm(input: { | |
| 212 | readonly clauses: readonly Clause[]; | |
| 213 | readonly action: string; | |
| 214 | readonly chosen?: Accord | undefined; | |
| 215 | }): string { | |
| 216 | const { clauses, action, chosen } = input; | |
| 217 | return `<form class="offer" method="post" action="${escape(action)}"> | |
| 218 | ${clauses.map((clause) => group(clause, chosen?.[clause.id])).join("")} | |
| 219 | <button class="button offer-submit">Offer this accord</button> | |
| 220 | </form>`; | |
| 221 | } | |
| 222 | ||
| 223 | const group = (clause: Clause, chosen: string | undefined): string => | |
| 224 | `<fieldset class="clause"> | |
| 225 | <legend class="clause-title">${escape(clause.title)}</legend> | |
| 226 | <div class="settings">${clause.settings | |
| 227 | .map( | |
| 228 | (setting) => `<label class="choice"> | |
| 229 | <input type="radio" name="accord.${escape(clause.id)}" value="${escape(setting)}"${setting === chosen ? " checked" : ""} required> | |
| 230 | <span class="setting-name">${escape(words(setting))}</span> | |
| 231 | </label>`, | |
| 232 | ) | |
| 233 | .join("")}</div> | |
| 234 | </fieldset>`; | |
| 235 | ||
| 236 | /** | |
| 237 | * Accept and Pass. Accepting costs a true, fresh aim, so the button says | |
| 238 | * which one it would publish; where the rules forbid accepting the button | |
| 239 | * is not rendered and the refusal says why, in the words the action uses. | |
| 240 | * | |
| 241 | * Both paths are given whole: a component that builds a URL out of pieces | |
| 242 | * is a component that has to know the route table. | |
| 243 | */ | |
| 244 | export function actBar(input: { | |
| 245 | readonly accept?: string | undefined; | |
| 246 | readonly pass: string; | |
| 247 | readonly clauses: readonly Clause[]; | |
| 248 | readonly swears?: Sworn | undefined; | |
| 249 | readonly refused?: string | undefined; | |
| 250 | }): string { | |
| 251 | const { accept, pass, clauses, swears, refused } = input; | |
| 252 | return `<div class="act-bar"> | |
| 253 | ${ | |
| 254 | accept === undefined || swears === undefined | |
| 255 | ? "" | |
| 256 | : `<form method="post" action="${escape(accept)}"> | |
| 257 | <button class="button">Accept</button> | |
| 258 | <p class="cost muted">Publishes ${escape(title(clauses, swears.clause))} ${escape(words(swears.setting))}, weight ${escape(signed(swears.weight))}, forever.</p> | |
| 259 | </form>` | |
| 260 | } | |
| 261 | <form method="post" action="${escape(pass)}"> | |
| 262 | <button class="button button-quiet">Pass</button> | |
| 263 | </form> | |
| 264 | ${refused === undefined ? "" : refusal(refused)} | |
| 265 | </div>`; | |
| 266 | } | |
| 267 | ||
| 268 | /** A named failure, in the sentence the action gave. It never names another seat's aim. */ | |
| 269 | export const refusal = (message: string): string => | |
| 270 | `<p class="refusal" role="status">${escape(message)}</p>`; | |
| 271 | ||
| 272 | // --------------------------------------------------------------- small words | |
| 273 | ||
| 274 | /** A stored id as a person reads it: `to_the_finders` → `to the finders`. */ | |
| 275 | export const words = (id: string): string => id.replace(/_/g, " "); | |
| 276 | ||
| 277 | /** A weight with its sign, the dread's minus included. */ | |
| 278 | export const signed = (weight: number): string => | |
| 279 | weight > 0 ? `+${weight}` : `−${Math.abs(weight)}`; | |
| 280 | ||
| 281 | /** A clause's title, or its id when the charter does not hold it. */ | |
| 282 | const title = (clauses: readonly Clause[], id: string): string => | |
| 283 | clauses.find((clause) => clause.id === id)?.title ?? words(id); |
src/routes.ts +20
| @@ -28,6 +28,7 @@ import { | ||
| 28 | 28 | import { events } from "./live.ts"; |
| 29 | 29 | import { rpc } from "./mcp.ts"; |
| 30 | 30 | import { home } from "./page.ts"; |
| 31 | import { style } from "./pages/style.ts"; | |
| 31 | 32 | import type { Who } from "./who.ts"; |
| 32 | 33 | |
| 33 | 34 | export interface Context { |
| @@ -114,6 +115,25 @@ export const routes: readonly Route[] = [ | ||
| 114 | 115 | handle: (c) => done(leaveTable(c.db, c.who, { id: c.params.id })), |
| 115 | 116 | }, |
| 116 | 117 | |
| 118 | // Every part a table is made of, from a fixture: it reads no table and | |
| 119 | // nobody's identity, so it answers the same bytes to everyone and says so | |
| 120 | // at its call site — the one page that is not `private, no-store`, and it | |
| 121 | // varies on nothing a person carries. A minute, so a deploy shows through. | |
| 122 | { | |
| 123 | method: "GET", | |
| 124 | path: "/style", | |
| 125 | handle: () => | |
| 126 | html(style(), 200, { | |
| 127 | "cache-control": "public, max-age=60", | |
| 128 | vary: "accept-encoding", | |
| 129 | }), | |
| 130 | }, | |
| 131 | // The specimen forms are the real markup, so they really do post. There | |
| 132 | // is no table behind them: this is where they land, it writes nothing, | |
| 133 | // and it carries no notice — a page a shared cache may keep cannot hold | |
| 134 | // a word meant for one person. | |
| 135 | { method: "POST", path: "/style", handle: () => back(undefined, "/style") }, | |
| 136 | ||
| 117 | 137 | // The JSON API: the same actions, the same identity, plain status codes. |
| 118 | 138 | { |
| 119 | 139 | method: "GET", |
src/server.test.ts +108
| @@ -4,6 +4,7 @@ import { mkdtempSync } from "node:fs"; | ||
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| 6 | 6 | import { text } from "./http.ts"; |
| 7 | import { layout } from "./pages/layout.ts"; | |
| 7 | 8 | import { serve } from "./server.ts"; |
| 8 | 9 | import { sign } from "./who.ts"; |
| 9 | 10 | |
| @@ -342,6 +343,113 @@ test("writes are rationed per person", async () => { | ||
| 342 | 343 | expect(codes).toContain(429); |
| 343 | 344 | }); |
| 344 | 345 | |
| 346 | test("/style shows every part a table is made of, from a fixture", async () => { | |
| 347 | const response = await fetch(base + "/style"); | |
| 348 | expect(response.status).toBe(200); | |
| 349 | const page = await response.text(); | |
| 350 | // The charter grid, with one setting marked as the standing offer. | |
| 351 | expect(page).toContain('class="charter"'); | |
| 352 | expect(page.match(/class="setting"/g)).toHaveLength(21); // 7 clauses × 3 | |
| 353 | expect(page.match(/data-offered="yes"/g)).toHaveLength(7); | |
| 354 | // A sworn claim with its weight; a said claim unjudged, true and false. | |
| 355 | expect(page).toContain('data-kind="sworn"'); | |
| 356 | expect(page).toContain("<span class=\"weight\">+3</span>"); | |
| 357 | expect(page).toContain("unjudged"); | |
| 358 | expect(page).toContain('data-verdict="true"'); | |
| 359 | expect(page).toContain('data-verdict="false"'); | |
| 360 | // The act log, newest last. | |
| 361 | expect(page.indexOf("18h ago")).toBeLessThan(page.indexOf("1h ago")); | |
| 362 | // The clock slot, the offer form as 7 radio groups, and a refusal. | |
| 363 | expect(page).toContain('class="clock"'); | |
| 364 | expect(page.match(/type="radio"/g)).toHaveLength(21); | |
| 365 | expect(page).toContain('name="accord.beacon"'); | |
| 366 | expect(page).toContain('class="refusal"'); | |
| 367 | // The header in all three states, including the two the guest's own | |
| 368 | // header can never show: a name, and a name marked admin. | |
| 369 | expect(page.match(/class="row"/g)).toHaveLength(4); // the page's own, and three specimens | |
| 370 | expect(page).toContain("/__forge/signin?to=/"); | |
| 371 | expect(page).toContain("<b>brail</b>"); | |
| 372 | expect(page).toContain("<b>quartermaster</b> · admin"); | |
| 373 | }); | |
| 374 | ||
| 375 | test("a specimen form lands back on /style and writes nothing", async () => { | |
| 376 | const before = await (await fetch(base + "/style")).text(); | |
| 377 | const posted = await api("/style", { method: "POST", redirect: "manual" }); | |
| 378 | expect(posted.status).toBe(303); | |
| 379 | expect(posted.headers.get("location")).toBe("/style"); | |
| 380 | // Nothing to show once and nothing kept: a page a shared cache may hold | |
| 381 | // must not carry a word meant for one person. | |
| 382 | expect(posted.headers.get("set-cookie")).toBeNull(); | |
| 383 | expect(await (await fetch(base + "/style")).text()).toBe(before); | |
| 384 | }); | |
| 385 | ||
| 386 | test("/style reads nobody and nothing: same page for a guest and an admin, in milliseconds", async () => { | |
| 387 | const guest = await (await fetch(base + "/style")).text(); | |
| 388 | expect(await (await api("/style", {}, root)).text()).toBe(guest); | |
| 389 | // Nothing inline to execute: the CSP forbids it and the page does not ask. | |
| 390 | expect(guest).not.toMatch(/<script(?![^>]*\ssrc=)/); | |
| 391 | const floor = await fetch(base + "/style"); | |
| 392 | expect(floor.headers.get("content-security-policy")).toContain( | |
| 393 | "default-src 'self'", | |
| 394 | ); | |
| 395 | expect(floor.headers.get("x-content-type-options")).toBe("nosniff"); | |
| 396 | expect(floor.headers.get("referrer-policy")).toBe( | |
| 397 | "strict-origin-when-cross-origin", | |
| 398 | ); | |
| 399 | // Being the same for everyone, it is the one page that says so at its | |
| 400 | // call site, and it varies on nothing a person carries. | |
| 401 | expect(floor.headers.get("cache-control")).toBe("public, max-age=60"); | |
| 402 | const vary = floor.headers.get("vary")?.toLowerCase() ?? ""; | |
| 403 | for (const name of ["x-forge-identity", "x-forge-signature", "cookie"]) | |
| 404 | expect(vary).not.toContain(name); | |
| 405 | // What the code can do, not what the machine felt like doing: the | |
| 406 | // fastest of ten warm requests. A loaded box makes some request slow | |
| 407 | // whatever the code does, so the slowest says nothing; the fastest is a | |
| 408 | // real floor, and rendering a fixture is string work (~1ms locally). | |
| 409 | const times: number[] = []; | |
| 410 | for (let i = 0; i < 10; i++) { | |
| 411 | const started = performance.now(); | |
| 412 | await (await fetch(base + "/style")).text(); | |
| 413 | times.push(performance.now() - started); | |
| 414 | } | |
| 415 | expect(Math.min(...times)).toBeLessThan(50); | |
| 416 | }); | |
| 417 | ||
| 418 | test("there is one layout: / wears the same document as /style, and reaches it", async () => { | |
| 419 | const home = await (await fetch(base + "/")).text(); | |
| 420 | const styled = await (await fetch(base + "/style")).text(); | |
| 421 | for (const shared of [ | |
| 422 | '<h1><a href="/">parley</a></h1>', | |
| 423 | '<main id="main"', | |
| 424 | '<a href="/style">/style</a>', | |
| 425 | ]) { | |
| 426 | expect(home).toContain(shared); | |
| 427 | expect(styled).toContain(shared); | |
| 428 | } | |
| 429 | // And the notice a redirect leaves is the layout's to show, once. | |
| 430 | const answered = await api("/", { | |
| 431 | headers: { cookie: "notice=Nothing%20was%20written" }, | |
| 432 | }); | |
| 433 | const page = await answered.text(); | |
| 434 | expect(page).toContain('class="toast"'); | |
| 435 | expect(page).toContain("Nothing was written"); | |
| 436 | expect(answered.headers.get("set-cookie")).toContain("notice=;"); | |
| 437 | }); | |
| 438 | ||
| 439 | test("the layout escapes what came from a person", () => { | |
| 440 | const document = layout({ | |
| 441 | title: 'a "title"', | |
| 442 | body: "<p>trusted</p>", | |
| 443 | who: { role: "admin", id: "u1", name: "<script>alert(1)</script>" }, | |
| 444 | live: "act.made", | |
| 445 | }); | |
| 446 | expect(document).not.toContain("<script>alert"); | |
| 447 | expect(document).toContain("<script>alert(1)</script>"); | |
| 448 | expect(document).toContain("<p>trusted</p>"); | |
| 449 | expect(document).toContain('data-live="act.made"'); | |
| 450 | expect(document).toContain("/__forge/signout"); | |
| 451 | }); | |
| 452 | ||
| 345 | 453 | test("stands on the security floor", async () => { |
| 346 | 454 | const response = await fetch(base + "/"); |
| 347 | 455 | expect(response.headers.get("content-security-policy")).toContain( |
static/app.css +355 −3
| @@ -1,13 +1,24 @@ | ||
| 1 | /* Tokens first. Dark follows the system unless <html data-theme="light|dark"> decides. */ | |
| 1 | /* The look of a table. Every class here is rendered at /style from a | |
| 2 | fixture, so what this file styles can be seen on one page. | |
| 3 | ||
| 4 | The rules the whole file keeps: legible at 360px with no horizontal | |
| 5 | scroll, every control at least 44px on a side, nothing that moves when a | |
| 6 | value changes, a visible focus ring on everything the keyboard reaches, | |
| 7 | and dark from the system unless <html data-theme> decides. */ | |
| 8 | ||
| 9 | /* Tokens first. */ | |
| 2 | 10 | :root { |
| 3 | 11 | color-scheme: light dark; |
| 4 | 12 | : #f6f6f3; |
| 5 | 13 | : #ffffff; |
| 14 | : #efefea; | |
| 6 | 15 | : #1c1c1a; |
| 7 | 16 | : #6f6f69; |
| 8 | 17 | : #e3e3dd; |
| 9 | 18 | : #2f5bea; |
| 19 | : #e8edfd; | |
| 10 | 20 | : #ffffff; |
| 21 | : #15803d; | |
| 11 | 22 | : #c2410c; |
| 12 | 23 | : #1c1c1a; |
| 13 | 24 | : #f6f6f3; |
| @@ -21,17 +32,21 @@ | ||
| 21 | 32 | : 0.875rem; |
| 22 | 33 | : 1.125rem; |
| 23 | 34 | : 1.5rem; |
| 35 | : 0.75rem; | |
| 24 | 36 | : 44px; |
| 25 | 37 | } |
| 26 | 38 | @media (prefers-color-scheme: dark) { |
| 27 | 39 | :root:not([data-theme="light"]) { |
| 28 | 40 | : #131312; |
| 29 | 41 | : #1d1d1b; |
| 42 | : #232320; | |
| 30 | 43 | : #ecece7; |
| 31 | 44 | : #9a9a93; |
| 32 | 45 | : #2c2c29; |
| 33 | 46 | : #7c96ff; |
| 47 | : #23294a; | |
| 34 | 48 | : #0f1a4d; |
| 49 | : #4ade80; | |
| 35 | 50 | : #fb923c; |
| 36 | 51 | : #ecece7; |
| 37 | 52 | : #131312; |
| @@ -40,11 +55,14 @@ | ||
| 40 | 55 | :root[data-theme="dark"] { |
| 41 | 56 | : #131312; |
| 42 | 57 | : #1d1d1b; |
| 58 | : #232320; | |
| 43 | 59 | : #ecece7; |
| 44 | 60 | : #9a9a93; |
| 45 | 61 | : #2c2c29; |
| 46 | 62 | : #7c96ff; |
| 63 | : #23294a; | |
| 47 | 64 | : #0f1a4d; |
| 65 | : #4ade80; | |
| 48 | 66 | : #fb923c; |
| 49 | 67 | : #ecece7; |
| 50 | 68 | : #131312; |
| @@ -65,7 +83,7 @@ body { | ||
| 65 | 83 | sans-serif; |
| 66 | 84 | } |
| 67 | 85 | .page { |
| 68 | max-width: 40rem; | |
| 86 | max-width: 44rem; | |
| 69 | 87 | margin: 0 auto; |
| 70 | 88 | padding: var() var() var(); |
| 71 | 89 | } |
| @@ -84,6 +102,20 @@ h1 { | ||
| 84 | 102 | letter-spacing: -0.01em; |
| 85 | 103 | line-height: 1.2; |
| 86 | 104 | } |
| 105 | h1 a { | |
| 106 | color: inherit; | |
| 107 | text-decoration: none; | |
| 108 | } | |
| 109 | h2 { | |
| 110 | margin: 0; | |
| 111 | font-size: var(); | |
| 112 | font-weight: 650; | |
| 113 | line-height: 1.25; | |
| 114 | } | |
| 115 | h3 { | |
| 116 | margin: 0; | |
| 117 | font-size: 1rem; | |
| 118 | } | |
| 87 | 119 | p { |
| 88 | 120 | margin: 0; |
| 89 | 121 | } |
| @@ -108,6 +140,17 @@ code, | ||
| 108 | 140 | color: var(); |
| 109 | 141 | white-space: nowrap; |
| 110 | 142 | } |
| 143 | /* Said to a screen reader, shown to nobody. */ | |
| 144 | .vh { | |
| 145 | position: absolute; | |
| 146 | width: 1px; | |
| 147 | height: 1px; | |
| 148 | margin: -1px; | |
| 149 | padding: 0; | |
| 150 | overflow: hidden; | |
| 151 | clip-path: inset(50%); | |
| 152 | white-space: nowrap; | |
| 153 | } | |
| 111 | 154 | |
| 112 | 155 | /* Blocks. */ |
| 113 | 156 | .card { |
| @@ -117,8 +160,12 @@ code, | ||
| 117 | 160 | padding: var(); |
| 118 | 161 | margin-bottom: var(); |
| 119 | 162 | } |
| 163 | /* A row wraps. It carries the header's pill, commit, name and door, and a | |
| 164 | signed-in name at 360px is exactly where an unwrapped row puts "Sign | |
| 165 | out" off the right edge of the screen. */ | |
| 120 | 166 | .row { |
| 121 | 167 | display: flex; |
| 168 | flex-wrap: wrap; | |
| 122 | 169 | align-items: center; |
| 123 | 170 | gap: var(); |
| 124 | 171 | } |
| @@ -148,7 +195,7 @@ code, | ||
| 148 | 195 | font-size: var(); |
| 149 | 196 | } |
| 150 | 197 | .empty { |
| 151 | padding: var() var(); | |
| 198 | padding: var() var(); | |
| 152 | 199 | text-align: center; |
| 153 | 200 | color: var(); |
| 154 | 201 | border: 1px dashed var(); |
| @@ -156,6 +203,8 @@ code, | ||
| 156 | 203 | } |
| 157 | 204 | footer { |
| 158 | 205 | margin-top: var(); |
| 206 | padding-top: var(); | |
| 207 | border-top: 1px solid var(); | |
| 159 | 208 | font-size: var(); |
| 160 | 209 | display: grid; |
| 161 | 210 | gap: var(); |
| @@ -234,6 +283,308 @@ footer code { | ||
| 234 | 283 | border-color: currentColor; |
| 235 | 284 | } |
| 236 | 285 | |
| 286 | /* The charter: 7 clauses by 3 settings. The three columns are fixed and | |
| 287 | every cell holds the mark's space whether or not it is marked, so a new | |
| 288 | standing offer repaints the grid without moving a pixel of it. */ | |
| 289 | .charter-caption { | |
| 290 | margin-bottom: var(); | |
| 291 | font-size: var(); | |
| 292 | } | |
| 293 | .clauses { | |
| 294 | list-style: none; | |
| 295 | margin: 0; | |
| 296 | padding: 0; | |
| 297 | } | |
| 298 | .clause { | |
| 299 | margin: 0; | |
| 300 | padding: var() 0; | |
| 301 | border: 0; | |
| 302 | border-top: 1px solid var(); | |
| 303 | } | |
| 304 | .clause-title { | |
| 305 | margin: 0 0 var(); | |
| 306 | font-size: var(); | |
| 307 | font-weight: 700; | |
| 308 | letter-spacing: 0.08em; | |
| 309 | text-transform: uppercase; | |
| 310 | color: var(); | |
| 311 | } | |
| 312 | .settings { | |
| 313 | display: grid; | |
| 314 | grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| 315 | gap: var(); | |
| 316 | list-style: none; | |
| 317 | margin: 0; | |
| 318 | padding: 0; | |
| 319 | } | |
| 320 | .setting { | |
| 321 | display: flex; | |
| 322 | align-items: center; | |
| 323 | gap: 0.3rem; | |
| 324 | min-height: var(); | |
| 325 | padding: var(); | |
| 326 | font-size: var(); | |
| 327 | border: 1px solid var(); | |
| 328 | border-radius: var(); | |
| 329 | overflow-wrap: anywhere; | |
| 330 | } | |
| 331 | .setting[data-offered] { | |
| 332 | border-color: var(); | |
| 333 | background: var(); | |
| 334 | font-weight: 650; | |
| 335 | } | |
| 336 | .tick { | |
| 337 | flex: none; | |
| 338 | width: 1ch; | |
| 339 | color: var(); | |
| 340 | line-height: 1; | |
| 341 | } | |
| 342 | ||
| 343 | /* The seats, and what each one has sworn. */ | |
| 344 | .seat-list, | |
| 345 | .act-list { | |
| 346 | list-style: none; | |
| 347 | margin: 0; | |
| 348 | padding: 0; | |
| 349 | } | |
| 350 | .seat, | |
| 351 | .act { | |
| 352 | padding: var() 0; | |
| 353 | border-top: 1px solid var(); | |
| 354 | } | |
| 355 | .seat-head { | |
| 356 | display: flex; | |
| 357 | flex-wrap: wrap; | |
| 358 | align-items: center; | |
| 359 | gap: var(); | |
| 360 | } | |
| 361 | .seat-ordinal { | |
| 362 | flex: none; | |
| 363 | display: inline-flex; | |
| 364 | align-items: center; | |
| 365 | justify-content: center; | |
| 366 | width: 1.6rem; | |
| 367 | height: 1.6rem; | |
| 368 | border-radius: 50%; | |
| 369 | background: var(); | |
| 370 | font-size: var(); | |
| 371 | font-variant-numeric: tabular-nums; | |
| 372 | } | |
| 373 | .seat-name { | |
| 374 | font-weight: 650; | |
| 375 | } | |
| 376 | .badge { | |
| 377 | margin-left: auto; | |
| 378 | padding: 0.1rem 0.6rem; | |
| 379 | font-size: var(); | |
| 380 | white-space: nowrap; | |
| 381 | color: var(); | |
| 382 | border: 1px solid var(); | |
| 383 | border-radius: 999px; | |
| 384 | } | |
| 385 | .badge[data-standing="accepted"] { | |
| 386 | color: var(); | |
| 387 | border-color: currentColor; | |
| 388 | } | |
| 389 | .badge[data-standing="to act"] { | |
| 390 | color: var(); | |
| 391 | border-color: currentColor; | |
| 392 | } | |
| 393 | .claims-none { | |
| 394 | margin-top: var(); | |
| 395 | font-size: var(); | |
| 396 | } | |
| 397 | ||
| 398 | /* A claim. Sworn is the engine's word and cannot be false; said is the | |
| 399 | seat's own, unjudged until the reveal. Both carry the same 3px rule on | |
| 400 | the left so one turning into the other never shifts the text. */ | |
| 401 | .claims { | |
| 402 | list-style: none; | |
| 403 | display: grid; | |
| 404 | gap: var(); | |
| 405 | margin: var() 0 0; | |
| 406 | padding: 0; | |
| 407 | } | |
| 408 | .claim { | |
| 409 | display: flex; | |
| 410 | flex-wrap: wrap; | |
| 411 | align-items: baseline; | |
| 412 | gap: var(); | |
| 413 | padding: var() var(); | |
| 414 | font-size: var(); | |
| 415 | background: var(); | |
| 416 | border-left: 3px solid var(); | |
| 417 | border-radius: 0 var() var() 0; | |
| 418 | } | |
| 419 | .claim[data-kind="sworn"] { | |
| 420 | border-left-color: var(); | |
| 421 | } | |
| 422 | .claim-kind { | |
| 423 | font-size: var(); | |
| 424 | font-weight: 700; | |
| 425 | letter-spacing: 0.08em; | |
| 426 | text-transform: uppercase; | |
| 427 | color: var(); | |
| 428 | } | |
| 429 | .claim-what { | |
| 430 | font-weight: 600; | |
| 431 | overflow-wrap: anywhere; | |
| 432 | } | |
| 433 | .weight { | |
| 434 | font-weight: 700; | |
| 435 | font-variant-numeric: tabular-nums; | |
| 436 | color: var(); | |
| 437 | } | |
| 438 | .worth { | |
| 439 | color: var(); | |
| 440 | } | |
| 441 | .verdict { | |
| 442 | margin-left: auto; | |
| 443 | font-weight: 700; | |
| 444 | } | |
| 445 | .verdict-open { | |
| 446 | font-weight: 400; | |
| 447 | color: var(); | |
| 448 | } | |
| 449 | .claim[data-verdict="true"] .verdict { | |
| 450 | color: var(); | |
| 451 | } | |
| 452 | .claim[data-verdict="false"] .verdict { | |
| 453 | color: var(); | |
| 454 | } | |
| 455 | ||
| 456 | /* The act log, oldest first. */ | |
| 457 | .act-head { | |
| 458 | display: flex; | |
| 459 | align-items: baseline; | |
| 460 | gap: var(); | |
| 461 | } | |
| 462 | .act-n { | |
| 463 | flex: none; | |
| 464 | width: 2ch; | |
| 465 | font-size: var(); | |
| 466 | font-variant-numeric: tabular-nums; | |
| 467 | color: var(); | |
| 468 | } | |
| 469 | .act-who { | |
| 470 | font-weight: 650; | |
| 471 | } | |
| 472 | .act-kind { | |
| 473 | color: var(); | |
| 474 | } | |
| 475 | .act[data-kind="offer"] .act-kind { | |
| 476 | color: var(); | |
| 477 | } | |
| 478 | ||
| 479 | /* The clock slot and the log's times: `src/when.ts` owns `.when` above, | |
| 480 | and these say where it sits. The slot holds 12ch — the widest thing that | |
| 481 | vocabulary says — so a countdown ticking in it never reflows its row. */ | |
| 482 | .clock .when, | |
| 483 | .act-head .when { | |
| 484 | margin-left: auto; | |
| 485 | text-align: right; | |
| 486 | } | |
| 487 | .act-head .when { | |
| 488 | font-size: var(); | |
| 489 | color: var(); | |
| 490 | } | |
| 491 | .clock { | |
| 492 | display: flex; | |
| 493 | align-items: center; | |
| 494 | gap: var(); | |
| 495 | min-height: var(); | |
| 496 | padding: 0 var(); | |
| 497 | border: 1px solid var(); | |
| 498 | border-radius: var(); | |
| 499 | margin-bottom: var(); | |
| 500 | } | |
| 501 | .clock-label { | |
| 502 | font-size: var(); | |
| 503 | color: var(); | |
| 504 | } | |
| 505 | .clock .when { | |
| 506 | min-width: 12ch; | |
| 507 | font-size: 1rem; | |
| 508 | font-weight: 650; | |
| 509 | color: var(); | |
| 510 | } | |
| 511 | ||
| 512 | /* An offer: 7 radio groups, no JavaScript, every choice a 44px target. The | |
| 513 | native radio stays visible — the highlight is the enhancement, not the | |
| 514 | control. */ | |
| 515 | .offer legend { | |
| 516 | float: left; | |
| 517 | width: 100%; | |
| 518 | padding: 0; | |
| 519 | } | |
| 520 | .offer .settings { | |
| 521 | clear: both; | |
| 522 | } | |
| 523 | .choice { | |
| 524 | display: flex; | |
| 525 | align-items: center; | |
| 526 | gap: var(); | |
| 527 | min-height: var(); | |
| 528 | padding: var(); | |
| 529 | font-size: var(); | |
| 530 | border: 1px solid var(); | |
| 531 | border-radius: var(); | |
| 532 | cursor: pointer; | |
| 533 | overflow-wrap: anywhere; | |
| 534 | } | |
| 535 | .choice input { | |
| 536 | flex: none; | |
| 537 | width: 1.1rem; | |
| 538 | height: 1.1rem; | |
| 539 | margin: 0; | |
| 540 | accent-color: var(); | |
| 541 | } | |
| 542 | .choice:has(input:checked) { | |
| 543 | border-color: var(); | |
| 544 | background: var(); | |
| 545 | font-weight: 650; | |
| 546 | } | |
| 547 | .choice:focus-within { | |
| 548 | outline: 2px solid var(); | |
| 549 | outline-offset: 2px; | |
| 550 | } | |
| 551 | .offer-submit { | |
| 552 | margin-top: var(); | |
| 553 | } | |
| 554 | ||
| 555 | /* Accept, pass, and the sentence that says why you may not. */ | |
| 556 | .act-bar { | |
| 557 | display: flex; | |
| 558 | flex-wrap: wrap; | |
| 559 | align-items: flex-start; | |
| 560 | gap: var(); | |
| 561 | } | |
| 562 | .cost { | |
| 563 | margin-top: var(); | |
| 564 | max-width: 24rem; | |
| 565 | font-size: var(); | |
| 566 | } | |
| 567 | .refusal { | |
| 568 | flex-basis: 100%; | |
| 569 | padding: var(); | |
| 570 | font-size: var(); | |
| 571 | background: var(); | |
| 572 | border-left: 3px solid var(); | |
| 573 | border-radius: 0 var() var() 0; | |
| 574 | } | |
| 575 | ||
| 576 | /* /style: one specimen after another, each saying what it is for. */ | |
| 577 | .note-top { | |
| 578 | margin-bottom: var(); | |
| 579 | font-size: var(); | |
| 580 | } | |
| 581 | .specimen { | |
| 582 | margin-bottom: var(); | |
| 583 | } | |
| 584 | .specimen > .muted { | |
| 585 | max-width: 36rem; | |
| 586 | margin: var() 0 var(); | |
| 587 | font-size: var(); | |
| 237 | 588 | /* Time in words (`src/when.ts`). A clock that ticks once a second must not |
| 238 | 589 | move anything: the numbers are tabular, so every digit is the same width, |
| 239 | 590 | and a slot that is re-rendered holds a width of its own in `ch` — wide |
| @@ -285,3 +636,4 @@ footer code { | ||
| 285 | 636 | animation: none; |
| 286 | 637 | } |
| 287 | 638 | } |
| 639 |
new above
offline
Changes requested. Read every changed line, ran
bun testat 083d206 (32 pass, 0 fail), merged the branch against main in a scratch tree (clean, one commit behind #35), and used the preview:/styleanswers 200 in ~90 ms over the wire with the whole security floor on it,/is untouched.The security work is right and I want to say so first.
/stylereads no table, no cookie and no identity, sopublic, max-age=60withvary: accept-encodingoverridingPRIVATEis a correct exception, argued at the call site the wayhttp.tsasks, and the preview confirms it (cache-control: public, max-age=60,vary: accept-encoding, CSP / nosniff / referrer-policy / HSTS all present). Every interpolation intable.tsandlayout.tsgoes throughescape, the fixture is constants, there is no new dependency, and both new tests would fail without the change. Three things stop me approving, in order.1 — Fix this first: the page contradicts itself and contradicts RULES §10, which it claims to be. This line makes act 6 a lapsed
pass, but in §10 act 6 is Brail accepts A₂ and swearsname=changed, weight 1 — and the seat list on the same page (src/pages/style.ts:58) still shows Brail accepted holding sworn The Name changed +1, with "2 of 3 needed to bind" above it that only counts if he did. I pulled the rendered preview to be sure: the seat block saysdata-standing="accepted"with that sworn claim, and the act block for 6 sayspassed (lapsed)with no claim at all. So the one page whose job is to be the truthful reference for #12 and #15 currently shows a sworn claim that no act in its own ledger produced.Concrete fix: restore act 6 to
kind: "accept"withsworn: { seat: 1, clause: "name", setting: "changed", weight: 1 }, and move thelapsedspecimen — keep it, it is a real state (§8) — onto act 8, Dunnage's pass, which §10 already has. While you are there, a lapse 30 minutes after act 5 is shorter than any legal turn window (15m–48h, 6h default), so the timestamps should carry it too. The drift happened because nothing checks the fixture against the rules it cites: one test asserting that everysworninSEATSappears on anacceptact by that seat inACTSwould have caught this and will catch the next one.2 — Two layouts now, against
AGENTS.md's own rule, and they have already drifted inside this diff. The line this PR rewrites used to read "With a second page, move them undersrc/pages/and keep onelayout." This adds the second page and keeps two:src/page.ts:12still has its own copy for/, and the new one is already different — theh1links home, the footer links/styleand says "The terminal is off." whenFORGE_APP_SSHis unset, the toast moved outside.page. The visible cost is in the forge's own pictures:/is where a person lands and it has no way to reach/styleat all, because only the new layout links it. Rewriting the rule to bless the copy is the wrong direction.home()callinglayout({ body, who, live: "note.added note.removed", notice })and deleting the copy is about fifteen lines, leaves/on the demo's own body, and means the next page cannot inherit the stale header.3 — The one header state that breaks at phone width is the one
/stylecan never show.style()hardcodesANON, so the styleguide only ever renders the guest door. The phone picture at this head (390px, signed in) shows the nav running off the right edge with Sign out clipped:.rowinstatic/app.css:163isdisplay: flexwith noflex-wrap, so the pill, the commit, the name and the door cannot wrap — and at 360px it is worse, not better. That CSS predates the PR, but the PR now owns that header and the file's own stated rule is "legible at 360px with no horizontal scroll", so it is yours to close:flex-wrap: wrapon.row, and export the header fromlayout.tsso/stylecan show it as a specimen for a guest, a user and an admin. Otherwise the styleguide will keep proving only the state that already works.Smaller, not blocking: the specimen forms post to
/style,/style/acceptand/style/pass, and all three answer a barenot foundon the preview — the prose says "the forms post nowhere", so make that true with an inert action rather than three dead doors;CLAUSE_POOL.find(...)!atsrc/pages/style.ts:36turns a renamed clause id into a 500 instead of a type error (the/styletest would catch it, so it is a smell, not a bug); andexpect(performance.now() - started).toBeLessThan(50)asserts the runner's mood, not the code — it will flake on a loaded box.The vocabulary itself —
charterGrid,seatList,swornClaim/saidClaim,actLog,clockSlot,offerForm,actBar, with time formatting kept out and handed in as words — is the right shape and the right seam for #12, and the fixture-not-a-database discipline is exactly what makes the page cacheable. Fix 1; 2 and 3 are cheap enough to ride along with it.