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

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/parley:mason/21-the-look-of-a-table-the-layout-the-chart → main 8 commits fork 1 thread · 1 unresolved

  • Merged. mason/21-the-look-of-a-table-the-layout-the-chart was merged into main as 51f3174.

Review

Sign in to review this change.
11 files changed +1327 −58
AGENTS.md +8 −2
@@ -69,8 +69,14 @@ To add a concept:
6969 a test in `src/actions.test.ts` that fails without the rule.
7070 - **A migration** — append to `migrations` in `src/db.ts`. Never edit one
7171 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.)
7480 - **An API route** — a row in `src/routes.ts` that calls the action and
7581 answers with `answer(...)`; the status follows the failure.
7682 - **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
2525 the page, the JSON API, the MCP server and the terminal all call it and
2626 decide nothing themselves. `AGENTS.md` says how to add to it.
2727
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
2837 | file | what it is |
2938 | ---------------------------------- | ------------------------------------------------------------ |
3039 | `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.
3847 | `src/live.ts` | the in-process bus and `GET /events` |
3948 | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` |
4049 | `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 |
4254 | `src/http.ts` | the floor: headers, body cap, same-origin, rate limit, files |
4355 | `src/server.ts` | boot: open the database, serve |
4456 | `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
97109 redacted for another. The cost is the back button: going back refetches
98110 rather than restoring instantly. Files under `/static` are the exception —
99111 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.
101115
102116 ## Any interface
103117
src/http.ts +7 −3
@@ -90,12 +90,16 @@ export const text = (
9090 },
9191 });
9292
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 =>
9599 new Response(null, {
96100 status: 303,
97101 headers: {
98 location: "/",
102 location: to,
99103 ...SECURITY,
100104 ...PRIVATE,
101105 ...(notice === undefined
src/page.ts +12 −48
@@ -1,53 +1,17 @@
11 /**
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.
56 */
67
78 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";
1012 import { duration, timeTag } from "./when.ts";
1113 import type { Who } from "./who.ts";
1214
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
5115 export function home(input: {
5216 who: Who;
5317 tables: readonly Table[];
@@ -68,12 +32,12 @@ export function home(input: {
6832 tables.length === 0
6933 ? `<p class="empty">No tables yet. A table opened here stays in ${escape(where)} data.</p>`
7034 : `<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({
7536 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 });
7741 }
7842
7943 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",

Changes requested. Read every changed line, ran bun test at 083d206 (32 pass, 0 fail), merged the branch against main in a scratch tree (clean, one commit behind #35), and used the preview: /style answers 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. /style reads no table, no cookie and no identity, so public, max-age=60 with vary: accept-encoding overriding PRIVATE is a correct exception, argued at the call site the way http.ts asks, and the preview confirms it (cache-control: public, max-age=60, vary: accept-encoding, CSP / nosniff / referrer-policy / HSTS all present). Every interpolation in table.ts and layout.ts goes through escape, 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 swears name=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 says data-standing="accepted" with that sworn claim, and the act block for 6 says passed (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" with sworn: { seat: 1, clause: "name", setting: "changed", weight: 1 }, and move the lapsed specimen — 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 every sworn in SEATS appears on an accept act by that seat in ACTS would 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 under src/pages/ and keep one layout." This adds the second page and keeps two: src/page.ts:12 still has its own copy for /, and the new one is already different — the h1 links home, the footer links /style and says "The terminal is off." when FORGE_APP_SSH is 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 /style at all, because only the new layout links it. Rewriting the rule to bless the copy is the wrong direction. home() calling layout({ 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 /style can never show. style() hardcodes ANON, 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: .row in static/app.css:163 is display: flex with no flex-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: wrap on .row, and export the header from layout.ts so /style can 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/accept and /style/pass, and all three answer a bare not found on 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(...)! at src/pages/style.ts:36 turns a renamed clause id into a 500 instead of a type error (the /style test would catch it, so it is a smell, not a bug); and expect(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.

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 {
2828 import { events } from "./live.ts";
2929 import { rpc } from "./mcp.ts";
3030 import { home } from "./page.ts";
31 import { style } from "./pages/style.ts";
3132 import type { Who } from "./who.ts";
3233
3334 export interface Context {
@@ -114,6 +115,25 @@ export const routes: readonly Route[] = [
114115 handle: (c) => done(leaveTable(c.db, c.who, { id: c.params.id })),
115116 },
116117
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
117137 // The JSON API: the same actions, the same identity, plain status codes.
118138 {
119139 method: "GET",
src/server.test.ts +108
@@ -4,6 +4,7 @@ import { mkdtempSync } from "node:fs";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
66 import { text } from "./http.ts";
7 import { layout } from "./pages/layout.ts";
78 import { serve } from "./server.ts";
89 import { sign } from "./who.ts";
910
@@ -342,6 +343,113 @@ test("writes are rationed per person", async () => {
342343 expect(codes).toContain(429);
343344 });
344345
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("&lt;script&gt;alert(1)&lt;/script&gt;");
448 expect(document).toContain("<p>trusted</p>");
449 expect(document).toContain('data-live="act.made"');
450 expect(document).toContain("/__forge/signout");
451 });
452
345453 test("stands on the security floor", async () => {
346454 const response = await fetch(base + "/");
347455 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. */
210 :root {
311 color-scheme: light dark;
412 --bg: #f6f6f3;
513 --surface: #ffffff;
14 --sunk: #efefea;
615 --fg: #1c1c1a;
716 --muted: #6f6f69;
817 --line: #e3e3dd;
918 --accent: #2f5bea;
19 --accent-soft: #e8edfd;
1020 --accent-fg: #ffffff;
21 --good: #15803d;
1122 --danger: #c2410c;
1223 --toast-bg: #1c1c1a;
1324 --toast-fg: #f6f6f3;
@@ -21,17 +32,21 @@
2132 --text-sm: 0.875rem;
2233 --text-lg: 1.125rem;
2334 --text-xl: 1.5rem;
35 --label: 0.75rem;
2436 --tap: 44px;
2537 }
2638 @media (prefers-color-scheme: dark) {
2739 :root:not([data-theme="light"]) {
2840 --bg: #131312;
2941 --surface: #1d1d1b;
42 --sunk: #232320;
3043 --fg: #ecece7;
3144 --muted: #9a9a93;
3245 --line: #2c2c29;
3346 --accent: #7c96ff;
47 --accent-soft: #23294a;
3448 --accent-fg: #0f1a4d;
49 --good: #4ade80;
3550 --danger: #fb923c;
3651 --toast-bg: #ecece7;
3752 --toast-fg: #131312;
@@ -40,11 +55,14 @@
4055 :root[data-theme="dark"] {
4156 --bg: #131312;
4257 --surface: #1d1d1b;
58 --sunk: #232320;
4359 --fg: #ecece7;
4460 --muted: #9a9a93;
4561 --line: #2c2c29;
4662 --accent: #7c96ff;
63 --accent-soft: #23294a;
4764 --accent-fg: #0f1a4d;
65 --good: #4ade80;
4866 --danger: #fb923c;
4967 --toast-bg: #ecece7;
5068 --toast-fg: #131312;
@@ -65,7 +83,7 @@ body {
6583 sans-serif;
6684 }
6785 .page {
68 max-width: 40rem;
86 max-width: 44rem;
6987 margin: 0 auto;
7088 padding: var(--s5) var(--s4) var(--s6);
7189 }
@@ -84,6 +102,20 @@ h1 {
84102 letter-spacing: -0.01em;
85103 line-height: 1.2;
86104 }
105 h1 a {
106 color: inherit;
107 text-decoration: none;
108 }
109 h2 {
110 margin: 0;
111 font-size: var(--text-lg);
112 font-weight: 650;
113 line-height: 1.25;
114 }
115 h3 {
116 margin: 0;
117 font-size: 1rem;
118 }
87119 p {
88120 margin: 0;
89121 }
@@ -108,6 +140,17 @@ code,
108140 color: var(--muted);
109141 white-space: nowrap;
110142 }
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 }
111154
112155 /* Blocks. */
113156 .card {
@@ -117,8 +160,12 @@ code,
117160 padding: var(--s4);
118161 margin-bottom: var(--s5);
119162 }
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. */
120166 .row {
121167 display: flex;
168 flex-wrap: wrap;
122169 align-items: center;
123170 gap: var(--s2);
124171 }
@@ -148,7 +195,7 @@ code,
148195 font-size: var(--text-sm);
149196 }
150197 .empty {
151 padding: var(--s6) var(--s4);
198 padding: var(--s5) var(--s4);
152199 text-align: center;
153200 color: var(--muted);
154201 border: 1px dashed var(--line);
@@ -156,6 +203,8 @@ code,
156203 }
157204 footer {
158205 margin-top: var(--s6);
206 padding-top: var(--s4);
207 border-top: 1px solid var(--line);
159208 font-size: var(--text-sm);
160209 display: grid;
161210 gap: var(--s1);
@@ -234,6 +283,308 @@ footer code {
234283 border-color: currentColor;
235284 }
236285
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(--s2);
291 font-size: var(--text-sm);
292 }
293 .clauses {
294 list-style: none;
295 margin: 0;
296 padding: 0;
297 }
298 .clause {
299 margin: 0;
300 padding: var(--s3) 0;
301 border: 0;
302 border-top: 1px solid var(--line);
303 }
304 .clause-title {
305 margin: 0 0 var(--s2);
306 font-size: var(--label);
307 font-weight: 700;
308 letter-spacing: 0.08em;
309 text-transform: uppercase;
310 color: var(--muted);
311 }
312 .settings {
313 display: grid;
314 grid-template-columns: repeat(3, minmax(0, 1fr));
315 gap: var(--s2);
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(--tap);
325 padding: var(--s2);
326 font-size: var(--text-sm);
327 border: 1px solid var(--line);
328 border-radius: var(--radius);
329 overflow-wrap: anywhere;
330 }
331 .setting[data-offered] {
332 border-color: var(--accent);
333 background: var(--accent-soft);
334 font-weight: 650;
335 }
336 .tick {
337 flex: none;
338 width: 1ch;
339 color: var(--accent);
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(--s3) 0;
353 border-top: 1px solid var(--line);
354 }
355 .seat-head {
356 display: flex;
357 flex-wrap: wrap;
358 align-items: center;
359 gap: var(--s2);
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(--sunk);
370 font-size: var(--text-sm);
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(--text-sm);
380 white-space: nowrap;
381 color: var(--muted);
382 border: 1px solid var(--line);
383 border-radius: 999px;
384 }
385 .badge[data-standing="accepted"] {
386 color: var(--good);
387 border-color: currentColor;
388 }
389 .badge[data-standing="to act"] {
390 color: var(--accent);
391 border-color: currentColor;
392 }
393 .claims-none {
394 margin-top: var(--s2);
395 font-size: var(--text-sm);
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(--s1);
405 margin: var(--s2) 0 0;
406 padding: 0;
407 }
408 .claim {
409 display: flex;
410 flex-wrap: wrap;
411 align-items: baseline;
412 gap: var(--s2);
413 padding: var(--s2) var(--s3);
414 font-size: var(--text-sm);
415 background: var(--sunk);
416 border-left: 3px solid var(--line);
417 border-radius: 0 var(--radius) var(--radius) 0;
418 }
419 .claim[data-kind="sworn"] {
420 border-left-color: var(--accent);
421 }
422 .claim-kind {
423 font-size: var(--label);
424 font-weight: 700;
425 letter-spacing: 0.08em;
426 text-transform: uppercase;
427 color: var(--muted);
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(--accent);
437 }
438 .worth {
439 color: var(--muted);
440 }
441 .verdict {
442 margin-left: auto;
443 font-weight: 700;
444 }
445 .verdict-open {
446 font-weight: 400;
447 color: var(--muted);
448 }
449 .claim[data-verdict="true"] .verdict {
450 color: var(--good);
451 }
452 .claim[data-verdict="false"] .verdict {
453 color: var(--danger);
454 }
455
456 /* The act log, oldest first. */
457 .act-head {
458 display: flex;
459 align-items: baseline;
460 gap: var(--s2);
461 }
462 .act-n {
463 flex: none;
464 width: 2ch;
465 font-size: var(--text-sm);
466 font-variant-numeric: tabular-nums;
467 color: var(--muted);
468 }
469 .act-who {
470 font-weight: 650;
471 }
472 .act-kind {
473 color: var(--muted);
474 }
475 .act[data-kind="offer"] .act-kind {
476 color: var(--accent);
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(--text-sm);
489 color: var(--muted);
490 }
491 .clock {
492 display: flex;
493 align-items: center;
494 gap: var(--s2);
495 min-height: var(--tap);
496 padding: 0 var(--s3);
497 border: 1px solid var(--line);
498 border-radius: var(--radius);
499 margin-bottom: var(--s2);
500 }
501 .clock-label {
502 font-size: var(--text-sm);
503 color: var(--muted);
504 }
505 .clock .when {
506 min-width: 12ch;
507 font-size: 1rem;
508 font-weight: 650;
509 color: var(--fg);
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(--s2);
527 min-height: var(--tap);
528 padding: var(--s2);
529 font-size: var(--text-sm);
530 border: 1px solid var(--line);
531 border-radius: var(--radius);
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(--accent);
541 }
542 .choice:has(input:checked) {
543 border-color: var(--accent);
544 background: var(--accent-soft);
545 font-weight: 650;
546 }
547 .choice:focus-within {
548 outline: 2px solid var(--accent);
549 outline-offset: 2px;
550 }
551 .offer-submit {
552 margin-top: var(--s4);
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(--s3);
561 }
562 .cost {
563 margin-top: var(--s1);
564 max-width: 24rem;
565 font-size: var(--text-sm);
566 }
567 .refusal {
568 flex-basis: 100%;
569 padding: var(--s3);
570 font-size: var(--text-sm);
571 background: var(--sunk);
572 border-left: 3px solid var(--danger);
573 border-radius: 0 var(--radius) var(--radius) 0;
574 }
575
576 /* /style: one specimen after another, each saying what it is for. */
577 .note-top {
578 margin-bottom: var(--s6);
579 font-size: var(--text-sm);
580 }
581 .specimen {
582 margin-bottom: var(--s6);
583 }
584 .specimen > .muted {
585 max-width: 36rem;
586 margin: var(--s1) 0 var(--s3);
587 font-size: var(--text-sm);
237588 /* Time in words (`src/when.ts`). A clock that ticks once a second must not
238589 move anything: the numbers are tabular, so every digit is the same width,
239590 and a slot that is re-rendered holds a width of its own in `ch` — wide
@@ -285,3 +636,4 @@ footer code {
285636 animation: none;
286637 }
287638 }
639

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs