Public: anyone can read this; the crew can change it.
Every page says what it is: its own title, description and link preview #36
API Error: Can't reach the API server — check your internet or DNS (EAI_AGAIN)
Interrupted, not the work (outage; strike cleared 2026-10-02) — was: Parked by the crew: the run ended without a pull: no changes pushed: API Error: Can't reach the API server — check your internet or DNS (EAI_AGAIN) (api_error)
API Error: Can't reach the API server — check your internet or DNS (EAI_AGAIN)
Interrupted, not the work (outage; strike cleared 2026-10-02) — was: Parked by the crew: the run ended without a pull: no changes pushed: API Error: Can't reach the API server — check your internet or DNS (EAI_AGAIN) (api_error)
Amendment from the planner: the issue stands — every page on production is still titled quorum — with two things settled since it was filed.
src/pages/layout.ts now exists with the signature the issue guessed at, and #40 is editing its nav row at the moment: rebase onto that rather than racing it.
Added criterion: /group (#28) and any page added later must be able to name themselves without editing layout again — the parameter is optional and a page that names nothing renders exactly as it does today, which is what lets this land in any order.
Narrowed, to keep it one run: drop the Twitter card. og:title, og:description, og:url, og:type and the canonical are what a paste into a chat actually uses; twitter:card=summary with no image adds a line to every page's head and nothing to the preview.
Every page this app serves is titled
quorum:src/pages/layout.tsrenders<title>${escape(title)}</title>frompackage.jsonand nothing else, so eight open proposals are eight identical browser tabs, a bookmark says nothing, and a decision pasted into a chat is a bare URL. Give each page its own name and one line about itself.What done looks like
layouttakes what the page is called and what it is about — one extra, defaulted parameter in the shape the pages already pass (layout(body, who, path, head?)) — and renders<title>as<page> · quorumwhen a page names itself andquorumwhen it does not. The app's name stays read frompackage.jsonthroughsrc/env.ts; it is not spelled a second time./is “Proposals”,/aboutis “How this group decides”,/p/:idis the proposal's own title, and the 404 throughmissing()is “Not found”.<meta name="description">and<link rel="canonical">. The canonical URL is built frompublicOrigininsrc/env.tsand the path the route already knows, never from a request header a client controls.og:title,og:description,og:url,og:type=websiteandtwitter:card=summary, from those same two values. No image, no new asset, nothing fetched while rendering.</title><script>alert(1)</script>and a paragraph carrying",',<and&render as text in the title, the description and every og tag. The test asserts it by fetching the page, not by calling the escaper.src/server.test.ts: the three pages and the 404 each answer with their own<title>and their own canonical; the escape case above; a 120-character proposal title still ends with the app's name; the description of a proposal with a blank line in it contains no newline./still answers in single-digit milliseconds.Files I expect to change:
src/pages/layout.ts,src/pages/home.ts,src/pages/about.ts,src/pages/proposal.ts,src/routes.ts(themissing()call sites),src/server.test.ts.Risks: Keep the new parameter optional so no page is left half-converted — a page that names nothing must render exactly as it does today. Do not add
og:image: it needs a static asset, a size and a CSP that allows it, and none of that is this issue. The one-line/truncate helper belongs beside the layout that uses it, not insrc/http.ts, which knows nothing about proposals. Watch that the app's name is not appended twice whenmissing()renders inside a page that already named itself.Fit: Hearth — the same hand that wrote the first-visit page in #20; this is the rest of that first impression, the part that travels when a link leaves the app. A person assigns; I do not.