Public: anyone can read this; 1 person and the crew can change it.
Build www: the site that explains Cheffry's Dungeon #2
Check · Review · Land · Live done — Landed and live in production.
mason/cheffryjames-www:mason/1-build-www-the-site-that-explains-cheffry → main 1 commit fork
14 files
changed +630
−422
AGENTS.md +7 −4
| @@ -30,7 +30,7 @@ preview at its own address, deployed from your branch as you push. | ||
| 30 | 30 | |
| 31 | 31 | One implementation, many doors. Every operation is a function in |
| 32 | 32 | `src/actions.ts` — `(db, who, input)` in, data or a named failure out — and |
| 33 | the page (`src/routes.ts`, `src/page.ts`), the JSON API (`src/routes.ts`), | |
| 33 | the page (`src/routes.ts`, `src/pages/`), the JSON API (`src/routes.ts`), | |
| 34 | 34 | the MCP server (`src/mcp.ts`) and the terminal (`src/tty.ts`) call it. A |
| 35 | 35 | door reads its wire format and shapes the answer; it decides nothing. A |
| 36 | 36 | rule in an action holds at every door at once; a rule anywhere else is a |
| @@ -79,8 +79,11 @@ To add a concept: | ||
| 79 | 79 | and a test in `src/actions.test.ts` for what the table cannot say. |
| 80 | 80 | - **A migration** — append to `migrations` in `src/db.ts`. Never edit one |
| 81 | 81 | that has run. `bun run src/db.ts --check` shows the version. |
| 82 | - **A page** — a row in `src/routes.ts` and a function in `src/page.ts`. | |
| 83 | With a second page, move them under `src/pages/` and keep one `layout`. | |
| 82 | - **A page** — a row in `src/routes.ts` and a file in `src/pages/` that | |
| 83 | renders through `layout` (`src/pages/layout.ts`); add it to `NAV` there | |
| 84 | if the header should link it. A fact about the game is a field in | |
| 85 | `src/game.ts`, read from the game's repository, never written from | |
| 86 | memory; the site's name and words are `src/brand.ts`. | |
| 84 | 87 | - **An API route** — a row in `src/routes.ts` that calls the action and |
| 85 | 88 | answers with `answer(...)`; the status follows the failure. Give it an |
| 86 | 89 | `api` entry beside it (a sentence, the body and answer as JSON Schema) |
| @@ -93,7 +96,7 @@ To add a concept: | ||
| 93 | 96 | - **A terminal command** — a key in `src/tty.ts` that calls the action and |
| 94 | 97 | reports on the status line. |
| 95 | 98 | - **Something live** — `emit(type, data)` in the action, and |
| 96 | `data-live="<types>"` on the region of the page that shows it. | |
| 99 | a listener on `/events` wherever it is shown. | |
| 97 | 100 | - **A colour, a space, a size** — a token in `design/tokens.json` (a |
| 98 | 101 | colour needs its dark value in `$extensions.mode.dark`), used as |
| 99 | 102 | `var(--…)`. `bun run lint:design` refuses a raw `#hex`, `rgb(` or |
README.md +30 −8
| @@ -1,6 +1,28 @@ | ||
| 1 | 1 | # www |
| 2 | 2 | |
| 3 | The site that explains Cheffry's Dungeon, the game at game.cheffryjames.com | |
| 3 | The site that explains Cheffry's Dungeon, the game at | |
| 4 | [](https://game.cheffryjames.com). It is served at | |
| 5 | www.cheffryjames.com. | |
| 6 | ||
| 7 | Two pages, server-rendered, no script, nothing from any other site: | |
| 8 | ||
| 9 | - `/` — what the game is, what exists in it today, and **Play**. | |
| 10 | - `/how-to-play` — getting in, the controls, a run, the enemies, the | |
| 11 | chests, the score and the daily board. | |
| 12 | ||
| 13 | Every fact the pages state about the game lives in `src/game.ts`, read | |
| 14 | from the game's own repository (`cheffryjames/cheffry-s-dungeon` on this | |
| 15 | forge): its `content/*.json` for names and numbers, `src/sim.ts` for the | |
| 16 | rules of a run, its pages and scripts for the controls. Nothing here is | |
| 17 | simulated or promised; when the game changes, change `src/game.ts`. | |
| 18 | ||
| 19 | The branding is a placeholder: the name, tagline and wordmark are | |
| 20 | `src/brand.ts`, the colours `design/tokens.json`. Replace those two and the | |
| 21 | site is rebranded. | |
| 22 | ||
| 23 | The template's notes, its API, MCP server and terminal are still here, | |
| 24 | unchanged and off the pages: they carry the contract (`contract.json`) | |
| 25 | the forge audits. | |
| 4 | 26 | |
| 5 | 27 | This is an app: the forge runs it. Production follows the default branch; |
| 6 | 28 | every open pull gets a preview at its own address with its own copy of the |
| @@ -29,6 +51,9 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 29 | 51 | |
| 30 | 52 | | file | what it is | |
| 31 | 53 | | ---------------------------------- | -------------------------------------------------------------- | |
| 54 | | `src/pages/` | the site: `layout`, `home`, `how-to-play`, rendered on the server | | |
| 55 | | `src/game.ts` | what the game is, as data: every fact the pages state | | |
| 56 | | `src/brand.ts` | the placeholder name, tagline and wordmark | | |
| 32 | 57 | | `src/actions.ts` | list, add, delete; the audit trail, read | |
| 33 | 58 | | `contract.json` | who may do what: roles, and the least role each verb needs | |
| 34 | 59 | | `src/contract.ts`, `src/can.ts` | the table, read at boot; the forge's evaluator, verbatim | |
| @@ -40,13 +65,12 @@ decide nothing themselves. `AGENTS.md` says how to add to it. | ||
| 40 | 65 | | `src/mcp.ts` | the actions as MCP tools at `POST /mcp` | |
| 41 | 66 | | `src/routes.ts` | every path, in one table, each API route described beside it | |
| 42 | 67 | | `src/openapi.ts` | the routes as OpenAPI; committed at `contract/openapi.json` | |
| 43 | | `src/page.ts` | the HTML, rendered on the server, with its share metadata | | |
| 44 | 68 | | `design/tokens.json`, `src/tokens.ts` | the look as data, light and dark; compiled to `/tokens.css` | |
| 45 | 69 | | `src/http.ts` | the floor: headers, body cap, files | |
| 46 | 70 | | `src/server.ts` | boot: open the database, serve | |
| 47 | 71 | | `src/tty.ts` | the terminal, over the same actions | |
| 48 | 72 | | `src/env.ts` | what the forge told this process | |
| 49 | | `static/app.css`, `static/live.js` | the look in the tokens' words, and the list that stays current | | |
| 73 | | `static/app.css` | the look in the tokens' words | | |
| 50 | 74 | |
| 51 | 75 | ## What every app stands on |
| 52 | 76 | |
| @@ -100,18 +124,16 @@ As an MCP server, for Claude Code or Claude Desktop — `list_notes`, | ||
| 100 | 124 | `add_note`, `delete_note`: |
| 101 | 125 | |
| 102 | 126 | sh |
| 103 | claude mcp add --transport http __TITLE__ https://<app>/mcp | |
| 127 | claude mcp add --transport http www https://<app>/mcp | |
| 104 | 128 | |
| 105 | 129 | |
| 106 | 130 | json |
| 107 | 131 | { |
| 108 | "mcpServers": { "__TITLE__": { "type": "http", "url": "https://<app>/mcp" } } | |
| 132 | "mcpServers": { "www": { "type": "http", "url": "https://<app>/mcp" } } | |
| 109 | 133 | } |
| 110 | 134 | |
| 111 | 135 | |
| 112 | The page stays current over `/events`: a note someone else adds or removes | |
| 113 | appears without a reload. Without JavaScript it is the same page, one | |
| 114 | reload behind. | |
| 136 | A client that wants notes as they change listens on `/events`. | |
| 115 | 137 | |
| 116 | 138 | The terminal opens with the exact command in `FORGE_APP_SSH` when the app |
| 117 | 139 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge |
design/tokens.json +3 −1
| @@ -59,6 +59,7 @@ | ||
| 59 | 59 | "radius": { |
| 60 | 60 | "$type": "dimension", |
| 61 | 61 | "md": { "$value": "10px" }, |
| 62 | "lg": { "$value": "16px" }, | |
| 62 | 63 | "pill": { "$value": "999px" } |
| 63 | 64 | }, |
| 64 | 65 | "size": { |
| @@ -85,7 +86,8 @@ | ||
| 85 | 86 | "$description": "Never below 16px: a phone zooms into a smaller input." |
| 86 | 87 | }, |
| 87 | 88 | "lg": { "$value": "1.125rem" }, |
| 88 | "xl": { "$value": "1.5rem" } | |
| 89 | "xl": { "$value": "1.5rem" }, | |
| 90 | "2xl": { "$value": "clamp(2rem, 7vw, 3rem)" } | |
| 89 | 91 | }, |
| 90 | 92 | "leading": { |
| 91 | 93 | "$type": "number", |
src/brand.ts added +13
| @@ -0,0 +1,13 @@ | ||
| 1 | /** | |
| 2 | * The site's name and words, placeholder branding in one place: a new | |
| 3 | * name, tagline or mark is an edit here and in `design/tokens.json`, and | |
| 4 | * nowhere else. | |
| 5 | */ | |
| 6 | ||
| 7 | export const brand = { | |
| 8 | name: "Cheffry's Dungeon", | |
| 9 | tagline: | |
| 10 | "A low-poly 3D dungeon crawl in the browser: meet in the hub, gather up to four chefs, and fight through today's kitchen.", | |
| 11 | /** The wordmark's glyph, drawn as text so there is no image to load. */ | |
| 12 | mark: "CD", | |
| 13 | } as const; |
src/game.ts added +135
| @@ -0,0 +1,135 @@ | ||
| 1 | /** | |
| 2 | * What Cheffry's Dungeon is, as plain data — every fact the site states | |
| 3 | * about the game, in one place. Each one is read from the game's own | |
| 4 | * repository (`cheffryjames/cheffry-s-dungeon` on this forge, at 5d461ee): | |
| 5 | * `content/*.json` for the numbers and names, `src/sim.ts` for the rules | |
| 6 | * of a run, `src/pages/` and `static/*.js` for the controls. When the game | |
| 7 | * changes, change this file, and nothing here that the game does not do. | |
| 8 | */ | |
| 9 | ||
| 10 | /** Where the game is played. */ | |
| 11 | export const playUrl = "https://game.cheffryjames.com"; | |
| 12 | ||
| 13 | /** content/rules.json: 20 ticks a second. */ | |
| 14 | const TICK_HZ = 20; | |
| 15 | const seconds = (ticks: number): number => ticks / TICK_HZ; | |
| 16 | ||
| 17 | export const party = { max: 4 } as const; | |
| 18 | ||
| 19 | export const run = { | |
| 20 | /** rules.roomsPerRun: four drawn by depth, then a final room. */ | |
| 21 | rooms: 5, | |
| 22 | final: "The Pass", | |
| 23 | /** rules.maxTicks: 24000 ticks. */ | |
| 24 | minutes: 24000 / TICK_HZ / 60, | |
| 25 | hearts: 6, | |
| 26 | /** hero.revive: how long a friend stands beside a downed chef to bring them back. */ | |
| 27 | reviveSeconds: seconds(40), | |
| 28 | } as const; | |
| 29 | ||
| 30 | /** content/rooms.json, the non-final ones, in file order. */ | |
| 31 | export const rooms: readonly string[] = [ | |
| 32 | "The Pantry", | |
| 33 | "The Bakery", | |
| 34 | "The Spice Rack", | |
| 35 | "The Cold Room", | |
| 36 | "The Scullery", | |
| 37 | ]; | |
| 38 | ||
| 39 | export interface Monster { | |
| 40 | readonly name: string; | |
| 41 | readonly brain: string; | |
| 42 | readonly hp: number; | |
| 43 | readonly damage: number; | |
| 44 | readonly score: number; | |
| 45 | } | |
| 46 | ||
| 47 | /** content/monsters.json, with what each brain (`src/sim.ts`) does. */ | |
| 48 | export const monsters: readonly Monster[] = [ | |
| 49 | { | |
| 50 | name: "Meatball Slime", | |
| 51 | brain: "Chases you once you come within 7 tiles.", | |
| 52 | hp: 3, | |
| 53 | damage: 1, | |
| 54 | score: 20, | |
| 55 | }, | |
| 56 | { | |
| 57 | name: "Baguette Knight", | |
| 58 | brain: | |
| 59 | "Winds up, then charges in a straight line. After a charge it stands stunned, twice as long if it ran into a wall: that is your opening.", | |
| 60 | hp: 4, | |
| 61 | damage: 2, | |
| 62 | score: 35, | |
| 63 | }, | |
| 64 | { | |
| 65 | name: "Pepper Imp", | |
| 66 | brain: | |
| 67 | "Keeps about 3 tiles away and shoots pepper seeds from up to 6 tiles off.", | |
| 68 | hp: 2, | |
| 69 | damage: 1, | |
| 70 | score: 30, | |
| 71 | }, | |
| 72 | ]; | |
| 73 | ||
| 74 | /** content/traps.json. */ | |
| 75 | export const trap = { | |
| 76 | name: "Skewer Spikes", | |
| 77 | damage: 1, | |
| 78 | upSeconds: seconds(16), | |
| 79 | everySeconds: seconds(50), | |
| 80 | } as const; | |
| 81 | ||
| 82 | export interface Loot { | |
| 83 | readonly name: string; | |
| 84 | readonly does: string; | |
| 85 | /** Out of `lootWeight`. */ | |
| 86 | readonly weight: number; | |
| 87 | } | |
| 88 | ||
| 89 | /** content/loot.json. */ | |
| 90 | export const loot: readonly Loot[] = [ | |
| 91 | { name: "Pile of Coins", does: "+50 gold", weight: 6 }, | |
| 92 | { name: "Golden Truffle", does: "+150 gold", weight: 2 }, | |
| 93 | { name: "Hearty Soup", does: "2 hearts back", weight: 4 }, | |
| 94 | { | |
| 95 | name: "Whisk of Power", | |
| 96 | does: "+1 damage on every swing for the rest of the run", | |
| 97 | weight: 2, | |
| 98 | }, | |
| 99 | ]; | |
| 100 | export const lootWeight = loot.reduce((sum, l) => sum + l.weight, 0); | |
| 101 | ||
| 102 | /** content/rules.json `score`, beside each monster's own. */ | |
| 103 | export const score = { room: 100, victory: 500 } as const; | |
| 104 | ||
| 105 | /** content/rules.json `hub.emotes`. */ | |
| 106 | export const emotes: readonly string[] = [ | |
| 107 | "wave", | |
| 108 | "dance", | |
| 109 | "cheer", | |
| 110 | "taste", | |
| 111 | "bow", | |
| 112 | ]; | |
| 113 | ||
| 114 | export interface Control { | |
| 115 | readonly act: string; | |
| 116 | readonly how: string; | |
| 117 | } | |
| 118 | ||
| 119 | /** static/delve.js and src/pages/delve.ts. */ | |
| 120 | export const controls: readonly Control[] = [ | |
| 121 | { act: "Walk", how: "Click or tap the floor, or WASD / arrow keys." }, | |
| 122 | { | |
| 123 | act: "Fight", | |
| 124 | how: "Click a monster and your chef keeps swinging, or Space / J for the nearest one.", | |
| 125 | }, | |
| 126 | { | |
| 127 | act: "Dodge", | |
| 128 | how: "Shift-click or right-click where to roll, or K / Shift. A roll passes through bites, charges, seeds and spikes.", | |
| 129 | }, | |
| 130 | { act: "Loot", how: "Walk into a chest to open it." }, | |
| 131 | { | |
| 132 | act: "Revive", | |
| 133 | how: "Stand beside a downed friend to bring them back.", | |
| 134 | }, | |
| 135 | ]; |
src/http.ts −44
| @@ -61,39 +61,6 @@ export const text = (body: string, status = 200): Response => | ||
| 61 | 61 | headers: { "content-type": "text/plain; charset=utf-8", ...SECURITY }, |
| 62 | 62 | }); |
| 63 | 63 | |
| 64 | /** Back to the page, with a word for it to show once (`readNotice`). */ | |
| 65 | export const back = (notice?: string): Response => | |
| 66 | new Response(null, { | |
| 67 | status: 303, | |
| 68 | headers: { | |
| 69 | location: "/", | |
| 70 | ...SECURITY, | |
| 71 | ...(notice === undefined | |
| 72 | ? {} | |
| 73 | : { "set-cookie": cookie("notice", encodeURIComponent(notice), 30) }), | |
| 74 | }, | |
| 75 | }); | |
| 76 | ||
| 77 | /** The notice a redirect left, and the header that clears it. */ | |
| 78 | export function readNotice(request: Request): { | |
| 79 | notice?: string; | |
| 80 | clear: Record<string, string>; | |
| 81 | } { | |
| 82 | const raw = /(?:^|;\s*)notice=([^;]*)/.exec( | |
| 83 | request.headers.get("cookie") ?? "", | |
| 84 | )?.[1]; | |
| 85 | if (raw === undefined) return { clear: {} }; | |
| 86 | return { | |
| 87 | notice: decodeURIComponent(raw), | |
| 88 | clear: { "set-cookie": cookie("notice", "", 0) }, | |
| 89 | }; | |
| 90 | } | |
| 91 | ||
| 92 | // Every cookie the app sets itself is HttpOnly and SameSite=Lax, and Secure | |
| 93 | // wherever the public address is https. | |
| 94 | const cookie = (name: string, value: string, maxAge: number): string => | |
| 95 | `${name}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${publicOrigin.startsWith("https:") ? "; Secure" : ""}`; | |
| 96 | ||
| 97 | 64 | // A request body is read up to a cap and no further, counting the bytes as |
| 98 | 65 | // they arrive. A declared Content-Length is a claim: a chunked request has |
| 99 | 66 | // none, and a lie is free. Undefined means "too large" — refuse it. Past the |
| @@ -134,17 +101,6 @@ export async function readBody( | ||
| 134 | 101 | return body; |
| 135 | 102 | } |
| 136 | 103 | |
| 137 | /** The form fields of a capped body, decoded the way the browser sent them. */ | |
| 138 | export async function readForm(request: Request) { | |
| 139 | const body = await readBody(request); | |
| 140 | if (body === undefined) return undefined; | |
| 141 | return new Request(request.url, { | |
| 142 | method: "POST", | |
| 143 | headers: { "content-type": request.headers.get("content-type") ?? "" }, | |
| 144 | body: new Uint8Array(body), | |
| 145 | }).formData(); | |
| 146 | } | |
| 147 | ||
| 148 | 104 | /** The JSON of a capped body: `undefined` when too large, `null` when not JSON. */ |
| 149 | 105 | export async function readJson(request: Request): Promise<unknown> { |
| 150 | 106 | const body = await readBody(request); |
src/page.ts deleted −122
| @@ -1,122 +0,0 @@ | ||
| 1 | /** | |
| 2 | * The page, rendered on the server: the first paint is complete HTML, and | |
| 3 | * `static/live.js` keeps the notes fresh afterwards. When a second page | |
| 4 | * arrives, move `layout` to `src/pages/layout.ts` and give each page a file. | |
| 5 | */ | |
| 6 | ||
| 7 | import { canDelete, type Note } from "./actions.ts"; | |
| 8 | import { | |
| 9 | commit, | |
| 10 | description, | |
| 11 | env, | |
| 12 | publicOrigin, | |
| 13 | ssh, | |
| 14 | title, | |
| 15 | where, | |
| 16 | } from "./env.ts"; | |
| 17 | import { asset, escape } from "./http.ts"; | |
| 18 | import { token, tokensHref } from "./tokens.ts"; | |
| 19 | import type { Who } from "./who.ts"; | |
| 20 | ||
| 21 | /** | |
| 22 | * The frame every page shares: the tokens and the look, who you are, and | |
| 23 | * what a link to the page says when it is shared — a title, a sentence | |
| 24 | * and the address, for Open Graph and the card a chat app unfurls. | |
| 25 | */ | |
| 26 | export function layout(body: string, who: Who, path = "/"): string { | |
| 27 | const door = | |
| 28 | who.role === "anon" | |
| 29 | ? `<a class="button button-quiet" href="/__forge/signin?to=/">Sign in</a>` | |
| 30 | : `<span class="you"><b>${escape(who.name)}</b>${who.role === "admin" ? " · admin" : ""}</span> | |
| 31 | <a class="button button-quiet" href="/__forge/signout">Sign out</a>`; | |
| 32 | return `<!doctype html> | |
| 33 | <html lang="en"> | |
| 34 | <meta charset="utf-8"> | |
| 35 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 36 | <meta name="color-scheme" content="light dark"> | |
| 37 | <meta name="theme-color" content="${token("color-bg")}" media="(prefers-color-scheme: light)"> | |
| 38 | <meta name="theme-color" content="${token("color-bg", "dark")}" media="(prefers-color-scheme: dark)"> | |
| 39 | <link rel="icon" href="data:,"> | |
| 40 | <title>${escape(title)}</title> | |
| 41 | <meta name="description" content="${escape(description)}"> | |
| 42 | <meta property="og:type" content="website"> | |
| 43 | <meta property="og:site_name" content="${escape(title)}"> | |
| 44 | <meta property="og:title" content="${escape(title)}"> | |
| 45 | <meta property="og:description" content="${escape(description)}"> | |
| 46 | <meta property="og:url" content="${escape(publicOrigin + path)}"> | |
| 47 | <meta name="twitter:card" content="summary"> | |
| 48 | <meta name="twitter:title" content="${escape(title)}"> | |
| 49 | <meta name="twitter:description" content="${escape(description)}"> | |
| 50 | <link rel="stylesheet" href="${tokensHref}"> | |
| 51 | <link rel="stylesheet" href="${asset("app.css")}"> | |
| 52 | <script type="module" src="${asset("live.js")}"></script> | |
| 53 | <body> | |
| 54 | <div class="page"> | |
| 55 | <header class="head"> | |
| 56 | <div> | |
| 57 | <h1>${escape(title)}</h1> | |
| 58 | <p class="muted">${escape(description)}</p> | |
| 59 | </div> | |
| 60 | <nav class="row"> | |
| 61 | <span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? `<span class="muted mono">${escape(commit)}</span>` : ""} | |
| 62 | ${door} | |
| 63 | </nav> | |
| 64 | </header> | |
| 65 | <main>${body}</main> | |
| 66 | <footer class="muted"> | |
| 67 | ${ssh === undefined ? "" : `<p>Terminal: <code>${escape(ssh)}</code></p>`} | |
| 68 | <p>Also at <a href="/api/notes">/api/notes</a> (<a href="/openapi.json">described</a>) and <code>POST /mcp</code>, with the same rules.</p> | |
| 69 | </footer> | |
| 70 | </div> | |
| 71 | </body> | |
| 72 | </html> | |
| 73 | `; | |
| 74 | } | |
| 75 | ||
| 76 | export function home(input: { | |
| 77 | who: Who; | |
| 78 | notes: readonly Note[]; | |
| 79 | notice?: string | undefined; | |
| 80 | }): string { | |
| 81 | const { who, notes, notice } = input; | |
| 82 | const composer = | |
| 83 | who.role === "anon" | |
| 84 | ? `<p class="muted">Reading as a guest. <a href="/__forge/signin?to=/">Sign in</a> to add a note.</p>` | |
| 85 | : `<form class="row" method="post" action="/notes"> | |
| 86 | <input class="input" name="body" placeholder="Write a note" aria-label="New note" required maxlength="500" autocomplete="off" autofocus> | |
| 87 | <button class="button">Add</button> | |
| 88 | </form>`; | |
| 89 | const list = | |
| 90 | notes.length === 0 | |
| 91 | ? `<p class="empty">No notes yet. Notes written here stay in ${escape(where)} data.</p>` | |
| 92 | : `<ul class="list">${notes.map((note) => item(note, who)).join("")}</ul>`; | |
| 93 | return layout( | |
| 94 | `<section class="card">${composer}</section> | |
| 95 | <section id="notes" data-live="note.added note.removed">${list}</section> | |
| 96 | ${notice === undefined ? "" : `<p class="toast" role="status">${escape(notice)}</p>`}`, | |
| 97 | who, | |
| 98 | ); | |
| 99 | } | |
| 100 | ||
| 101 | const item = (note: Note, who: Who): string => `<li class="note"> | |
| 102 | <div> | |
| 103 | <p>${escape(note.body)}</p> | |
| 104 | <small class="muted">${escape(note.author_name)} · <time datetime="${escape(note.created_at)}">${ago(note.created_at)}</time></small> | |
| 105 | </div> | |
| 106 | ${ | |
| 107 | canDelete(who, note) | |
| 108 | ? `<form method="post" action="/notes/${note.id}/delete"><button class="button button-quiet" aria-label="Remove this note">Remove</button></form>` | |
| 109 | : "" | |
| 110 | } | |
| 111 | </li>`; | |
| 112 | ||
| 113 | const ago = (iso: string): string => { | |
| 114 | const s = Math.max(0, (Date.now() - Date.parse(iso)) / 1000); | |
| 115 | if (s < 60) return "just now"; | |
| 116 | if (s < 3600) return `${Math.floor(s / 60)}m ago`; | |
| 117 | if (s < 86400) return `${Math.floor(s / 3600)}h ago`; | |
| 118 | return new Date(iso).toLocaleDateString("en", { | |
| 119 | month: "short", | |
| 120 | day: "numeric", | |
| 121 | }); | |
| 122 | }; |
src/pages/home.ts added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | /** | |
| 2 | * The home page: what Cheffry's Dungeon is, what exists in it today, and | |
| 3 | * the way in. Every claim is the game's own (`src/game.ts`). | |
| 4 | */ | |
| 5 | ||
| 6 | import { brand } from "../brand.ts"; | |
| 7 | import { emotes, monsters, party, rooms, run } from "../game.ts"; | |
| 8 | import { escape } from "../http.ts"; | |
| 9 | import { layout, play } from "./layout.ts"; | |
| 10 | ||
| 11 | export function home(): string { | |
| 12 | return layout( | |
| 13 | `<section class="hero"> | |
| 14 | <h1>${escape(brand.name)}</h1> | |
| 15 | <p class="lede">${escape(brand.tagline)}</p> | |
| 16 | <div class="row wrap">${play("Play now")}<a class="button button-quiet" href="/how-to-play">How to play</a></div> | |
| 17 | <p class="muted">Sign in on the game to play. Anyone can walk in and watch.</p> | |
| 18 | </section> | |
| 19 | ||
| 20 | <section> | |
| 21 | <h2>What it is</h2> | |
| 22 | <div class="cards"> | |
| 23 | <article class="card"> | |
| 24 | <h3>The hub</h3> | |
| 25 | <p>A shared low-poly square where every signed-in chef stands. Walk around, emote, chat, and scrawl on the graffiti wall. Only positions, emotes and chat cross the wire.</p> | |
| 26 | </article> | |
| 27 | <article class="card"> | |
| 28 | <h3>The delve</h3> | |
| 29 | <p>Open a table or join one: up to ${party.max} chefs a party. Fight through ${run.rooms} rooms of the kitchen's rejects, ending at ${escape(run.final)}, in melee with a dodge roll. The server owns every fight, twenty ticks a second.</p> | |
| 30 | </article> | |
| 31 | <article class="card"> | |
| 32 | <h3>The daily board</h3> | |
| 33 | <p>Everyone gets the same dungeon each UTC day. Finished runs land on the leaderboard with a ghost of their path, and your next party races the best one on screen.</p> | |
| 34 | </article> | |
| 35 | </div> | |
| 36 | </section> | |
| 37 | ||
| 38 | <section> | |
| 39 | <h2>What exists today</h2> | |
| 40 | <p class="muted">Version one, the foundation. This is all of it, nothing promised:</p> | |
| 41 | <ul class="ticks"> | |
| 42 | <li>An explorable hub with ${emotes.length} emotes (${emotes.map(escape).join(", ")}), chat and the graffiti wall.</li> | |
| 43 | <li>Melee combat with a dodge roll that passes through hits.</li> | |
| 44 | <li>Three enemies with three different brains: ${monsters.map((m) => `<b>${escape(m.name)}</b>`).join(", ")}.</li> | |
| 45 | <li>Skewer Spikes in the floor, and loot chests.</li> | |
| 46 | <li>Party play for up to ${party.max}, with downed chefs revived by a friend.</li> | |
| 47 | <li>${rooms.length} rooms and a final one, dealt from the day's seed.</li> | |
| 48 | <li>Daily seeded runs, a leaderboard and ghosts.</li> | |
| 49 | </ul> | |
| 50 | </section> | |
| 51 | ||
| 52 | <section> | |
| 53 | <h2>How it is built</h2> | |
| 54 | <p>Small on purpose. The server runs each delve as a small authoritative instance on a fixed tick, and clients only send orders: move, attack, dodge, stop. Rooms, monsters, traps and loot are plain data files, so a new room is an edit rather than code. The scenes are drawn by a small hand-written WebGL renderer, in low poly, which keeps downloads and bandwidth tiny.</p> | |
| 55 | </section> | |
| 56 | ||
| 57 | <section class="card cta"> | |
| 58 | <h2>Today's kitchen is open</h2> | |
| 59 | <p>Grab an apron and ${party.max - 1} friends.</p> | |
| 60 | ${play()} | |
| 61 | </section>`, | |
| 62 | { path: "/" }, | |
| 63 | ); | |
| 64 | } |
src/pages/how-to-play.ts added +105
| @@ -0,0 +1,105 @@ | ||
| 1 | /** | |
| 2 | * How to play: getting in, the hub, the controls, a run from the first | |
| 3 | * room to the last, what is in the dungeon and how a score is made. Every | |
| 4 | * number is the game's own (`src/game.ts`). | |
| 5 | */ | |
| 6 | ||
| 7 | import { | |
| 8 | controls, | |
| 9 | emotes, | |
| 10 | loot, | |
| 11 | lootWeight, | |
| 12 | monsters, | |
| 13 | party, | |
| 14 | rooms, | |
| 15 | run, | |
| 16 | score, | |
| 17 | trap, | |
| 18 | } from "../game.ts"; | |
| 19 | import { escape } from "../http.ts"; | |
| 20 | import { layout, play } from "./layout.ts"; | |
| 21 | ||
| 22 | const hearts = (n: number): string => `${n} ${n === 1 ? "heart" : "hearts"}`; | |
| 23 | ||
| 24 | export function howToPlay(): string { | |
| 25 | return layout( | |
| 26 | `<section class="hero"> | |
| 27 | <h1>How to play</h1> | |
| 28 | <p class="lede">Meet in the hub, sit at a table with up to ${party.max - 1} friends, and cook your way through the day's ${run.rooms} rooms.</p> | |
| 29 | </section> | |
| 30 | ||
| 31 | <section> | |
| 32 | <h2>Getting in</h2> | |
| 33 | <ol class="steps"> | |
| 34 | <li>Open the game and sign in. Without signing in you can still watch the hub, the open tables and the board.</li> | |
| 35 | <li>You step into the hub with an apron on. Tap the floor or use the arrow keys to walk; ${emotes.map((e) => `<b>${escape(e)}</b>`).join(", ")} are your emotes, and there is chat and a graffiti wall.</li> | |
| 36 | <li>Open a table, or join one that is open. A table seats ${party.max} chefs; anyone else can watch.</li> | |
| 37 | </ol> | |
| 38 | </section> | |
| 39 | ||
| 40 | <section> | |
| 41 | <h2>Controls in a delve</h2> | |
| 42 | <dl class="keys"> | |
| 43 | ${controls.map((c) => `<dt>${escape(c.act)}</dt><dd>${escape(c.how)}</dd>`).join("")} | |
| 44 | </dl> | |
| 45 | </section> | |
| 46 | ||
| 47 | <section> | |
| 48 | <h2>A run</h2> | |
| 49 | <ul class="ticks"> | |
| 50 | <li>Every chef starts with ${hearts(run.hearts)}.</li> | |
| 51 | <li>A room's exit opens once every monster in it is down; whoever steps through takes the whole party to the next room.</li> | |
| 52 | <li>${run.rooms - 1} rooms are dealt from ${rooms.map(escape).join(", ")}, deeper ones later. The last room is always ${escape(run.final)}.</li> | |
| 53 | <li>At zero hearts a chef is downed, not gone: a friend standing beside them for ${run.reviveSeconds} seconds brings them back, and anyone still down when the party leaves a room gets up in the next one with 1 heart.</li> | |
| 54 | <li>The run ends when ${escape(run.final)} is cleared, when every chef is down at once, or after ${run.minutes} minutes.</li> | |
| 55 | </ul> | |
| 56 | </section> | |
| 57 | ||
| 58 | <section> | |
| 59 | <h2>The kitchen's rejects</h2> | |
| 60 | <div class="cards"> | |
| 61 | ${monsters | |
| 62 | .map( | |
| 63 | (m) => `<article class="card"> | |
| 64 | <h3>${escape(m.name)}</h3> | |
| 65 | <p>${escape(m.brain)}</p> | |
| 66 | <p class="muted">${m.hp} hit points · hits for ${hearts(m.damage)} · ${m.score} points</p> | |
| 67 | </article>`, | |
| 68 | ) | |
| 69 | .join("")} | |
| 70 | <article class="card"> | |
| 71 | <h3>${escape(trap.name)}</h3> | |
| 72 | <p>Spikes in the floor, up for ${trap.upSeconds} of every ${trap.everySeconds} seconds. Roll across them.</p> | |
| 73 | <p class="muted">hits for ${hearts(trap.damage)}, unless you are dodging</p> | |
| 74 | </article> | |
| 75 | </div> | |
| 76 | </section> | |
| 77 | ||
| 78 | <section> | |
| 79 | <h2>Chests</h2> | |
| 80 | <p>Each chest holds one of these:</p> | |
| 81 | <ul class="ticks"> | |
| 82 | ${loot.map((l) => `<li><b>${escape(l.name)}</b>: ${escape(l.does)} <span class="muted">(${Math.round((l.weight / lootWeight) * 100)}%)</span></li>`).join("")} | |
| 83 | </ul> | |
| 84 | </section> | |
| 85 | ||
| 86 | <section> | |
| 87 | <h2>Score and the daily board</h2> | |
| 88 | <ul class="ticks"> | |
| 89 | <li>Points for every monster down, ${score.room} for every room cleared, gold from chests, and ${score.victory} more for clearing ${escape(run.final)}.</li> | |
| 90 | <li>Everyone gets the same dungeon on the same UTC day. The hub shows the day's rooms before you go in.</li> | |
| 91 | <li>A finished run goes on the day's board with a ghost of its path. Your next party races the best run's ghost on screen.</li> | |
| 92 | </ul> | |
| 93 | </section> | |
| 94 | ||
| 95 | <section class="card cta"> | |
| 96 | <h2>Ready?</h2> | |
| 97 | ${play()} | |
| 98 | </section>`, | |
| 99 | { | |
| 100 | path: "/how-to-play", | |
| 101 | title: "How to play", | |
| 102 | description: `Controls, the three enemies, chests, and how a run of ${run.rooms} rooms is scored in Cheffry's Dungeon.`, | |
| 103 | }, | |
| 104 | ); | |
| 105 | } |
src/pages/layout.ts added +77
| @@ -0,0 +1,77 @@ | ||
| 1 | /** | |
| 2 | * The frame every page shares: the tokens and the look, the wordmark and | |
| 3 | * the way around, the one Play button, and what a link to the page says | |
| 4 | * when it is shared — a title, a sentence and the address, for Open Graph | |
| 5 | * and the card a chat app unfurls. No script: every page is complete HTML. | |
| 6 | */ | |
| 7 | ||
| 8 | import { brand } from "../brand.ts"; | |
| 9 | import { commit, env, publicOrigin, where } from "../env.ts"; | |
| 10 | import { playUrl } from "../game.ts"; | |
| 11 | import { asset, escape } from "../http.ts"; | |
| 12 | import { token, tokensHref } from "../tokens.ts"; | |
| 13 | ||
| 14 | export interface PageMeta { | |
| 15 | /** The page's own address, for `og:url` and to mark where you are. */ | |
| 16 | readonly path: string; | |
| 17 | /** The page's name, before the site's; the home page has none. */ | |
| 18 | readonly title?: string; | |
| 19 | /** The sentence a search result or a shared link shows. */ | |
| 20 | readonly description?: string; | |
| 21 | } | |
| 22 | ||
| 23 | /** The pages the header links to, in order. */ | |
| 24 | const NAV: readonly { readonly path: string; readonly label: string }[] = [ | |
| 25 | { path: "/", label: "The game" }, | |
| 26 | { path: "/how-to-play", label: "How to play" }, | |
| 27 | ]; | |
| 28 | ||
| 29 | /** The one call to action, the same on every page. */ | |
| 30 | export const play = (label = "Play"): string => | |
| 31 | `<a class="button button-play" href="${escape(playUrl)}">${escape(label)}</a>`; | |
| 32 | ||
| 33 | export function layout(body: string, meta: PageMeta): string { | |
| 34 | const title = | |
| 35 | meta.title === undefined ? brand.name : `${meta.title} · ${brand.name}`; | |
| 36 | const description = meta.description ?? brand.tagline; | |
| 37 | const nav = NAV.map( | |
| 38 | (n) => | |
| 39 | `<a href="${n.path}"${n.path === meta.path ? ' aria-current="page"' : ""}>${escape(n.label)}</a>`, | |
| 40 | ).join(""); | |
| 41 | return `<!doctype html> | |
| 42 | <html lang="en"> | |
| 43 | <meta charset="utf-8"> | |
| 44 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 45 | <meta name="color-scheme" content="light dark"> | |
| 46 | <meta name="theme-color" content="${token("color-bg")}" media="(prefers-color-scheme: light)"> | |
| 47 | <meta name="theme-color" content="${token("color-bg", "dark")}" media="(prefers-color-scheme: dark)"> | |
| 48 | <link rel="icon" href="data:,"> | |
| 49 | <title>${escape(title)}</title> | |
| 50 | <meta name="description" content="${escape(description)}"> | |
| 51 | <meta property="og:type" content="website"> | |
| 52 | <meta property="og:site_name" content="${escape(brand.name)}"> | |
| 53 | <meta property="og:title" content="${escape(title)}"> | |
| 54 | <meta property="og:description" content="${escape(description)}"> | |
| 55 | <meta property="og:url" content="${escape(publicOrigin + meta.path)}"> | |
| 56 | <meta name="twitter:card" content="summary"> | |
| 57 | <meta name="twitter:title" content="${escape(title)}"> | |
| 58 | <meta name="twitter:description" content="${escape(description)}"> | |
| 59 | <link rel="stylesheet" href="${tokensHref}"> | |
| 60 | <link rel="stylesheet" href="${asset("app.css")}"> | |
| 61 | <body> | |
| 62 | <a class="skip" href="#main">Skip to the page</a> | |
| 63 | <div class="page"> | |
| 64 | <header class="head"> | |
| 65 | <a class="brand" href="/"><span class="mark" aria-hidden="true">${escape(brand.mark)}</span>${escape(brand.name)}</a> | |
| 66 | <nav class="nav" aria-label="Site">${nav}${play()}</nav> | |
| 67 | </header> | |
| 68 | <main id="main">${body}</main> | |
| 69 | <footer class="muted"> | |
| 70 | <p>${escape(brand.name)} is played at <a href="${escape(playUrl)}">${escape(new URL(playUrl).host)}</a>, in the browser: nothing to install.</p> | |
| 71 | ${env === "production" ? "" : `<p><span class="pill" data-env="${escape(env)}">${escape(where)}</span>${commit ? ` <span class="mono">${escape(commit)}</span>` : ""}</p>`} | |
| 72 | </footer> | |
| 73 | </div> | |
| 74 | </body> | |
| 75 | </html> | |
| 76 | `; | |
| 77 | } |
src/routes.ts +8 −37
| @@ -27,20 +27,18 @@ import { | ||
| 27 | 27 | } from "./actions.ts"; |
| 28 | 28 | import { commit, env, forgeUrl, startedAt, version } from "./env.ts"; |
| 29 | 29 | import { |
| 30 | back, | |
| 31 | 30 | fields, |
| 32 | 31 | html, |
| 33 | 32 | json, |
| 34 | readForm, | |
| 35 | 33 | readJson, |
| 36 | readNotice, | |
| 37 | 34 | SECURITY, |
| 38 | 35 | staticFile, |
| 39 | 36 | text, |
| 40 | 37 | } from "./http.ts"; |
| 41 | 38 | import { events } from "./live.ts"; |
| 42 | 39 | import { rpc } from "./mcp.ts"; |
| 43 | import { home } from "./page.ts"; | |
| 40 | import { home } from "./pages/home.ts"; | |
| 41 | import { howToPlay } from "./pages/how-to-play.ts"; | |
| 44 | 42 | import { tokensCss } from "./tokens.ts"; |
| 45 | 43 | import type { Who } from "./who.ts"; |
| 46 | 44 | |
| @@ -144,9 +142,9 @@ const answer = <T>(result: Result<T>, status = 200): Response => | ||
| 144 | 142 | STATUS[result.error], |
| 145 | 143 | ); |
| 146 | 144 | |
| 147 | /** A result for a form: back to the page, with what went wrong if it did. */ | |
| 148 | const done = <T>(result: Result<T>): Response => | |
| 149 | result.ok ? back() : back(result.message); | |
| 145 | /** A page: the same HTML for everyone, so a browser may keep it a minute. */ | |
| 146 | const page = (body: string): Response => | |
| 147 | html(body, 200, { "cache-control": "public, max-age=60" }); | |
| 150 | 148 | |
| 151 | 149 | /** A whole number from the query, or undefined. */ |
| 152 | 150 | const whole = (value: string | null): number | undefined => |
| @@ -157,36 +155,9 @@ const OPENAPI = new URL("../contract/openapi.json", import.meta.url); | ||
| 157 | 155 | const openapi = existsSync(OPENAPI) ? readFileSync(OPENAPI, "utf8") : undefined; |
| 158 | 156 | |
| 159 | 157 | export const routes: readonly Route[] = [ |
| 160 | // The page. Forms post and come back; the list stays live over /events. | |
| 161 | { | |
| 162 | method: "GET", | |
| 163 | path: "/", | |
| 164 | handle: (c) => { | |
| 165 | const { notice, clear } = readNotice(c.request); | |
| 166 | const notes = listNotes(c.db, c.who); | |
| 167 | return html( | |
| 168 | home({ who: c.who, notes: notes.ok ? notes.value : [], notice }), | |
| 169 | 200, | |
| 170 | clear, | |
| 171 | ); | |
| 172 | }, | |
| 173 | }, | |
| 174 | { | |
| 175 | method: "POST", | |
| 176 | path: "/notes", | |
| 177 | handle: async (c) => { | |
| 178 | const form = await readForm(c.request); | |
| 179 | if (form === undefined) return text("too large", 413); | |
| 180 | return done( | |
| 181 | addNote(c.db, c.who, { body: String(form.get("body") ?? "") }), | |
| 182 | ); | |
| 183 | }, | |
| 184 | }, | |
| 185 | { | |
| 186 | method: "POST", | |
| 187 | path: "/notes/:id/delete", | |
| 188 | handle: (c) => done(deleteNote(c.db, c.who, { id: Number(c.params.id) })), | |
| 189 | }, | |
| 158 | // The pages: complete HTML, the same for everyone, no script. | |
| 159 | { method: "GET", path: "/", handle: () => page(home()) }, | |
| 160 | { method: "GET", path: "/how-to-play", handle: () => page(howToPlay()) }, | |
| 190 | 161 | |
| 191 | 162 | // The JSON API: the same actions, the same identity, plain status codes. |
| 192 | 163 | { |
src/server.test.ts +51 −40
| @@ -48,41 +48,55 @@ const rpc = (message: unknown, who: Record<string, string> = {}) => | ||
| 48 | 48 | (r) => r.json() as Promise<any>, |
| 49 | 49 | ); |
| 50 | 50 | |
| 51 | test("the page knows who you are and offers the right door", async () => { | |
| 52 | const guest = await (await fetch(base + "/")).text(); | |
| 53 | expect(guest).toContain("<h1>"); | |
| 54 | expect(guest).toContain("/__forge/signin?to=/"); | |
| 55 | expect(guest).not.toContain('action="/notes"'); | |
| 56 | const signed = await (await api("/", {}, alice)).text(); | |
| 57 | expect(signed).toContain("<b>alice</b>"); | |
| 58 | expect(signed).toContain("/__forge/signout"); | |
| 59 | expect(signed).toContain('action="/notes"'); | |
| 51 | test("the home page explains the game and leads to Play", async () => { | |
| 52 | const response = await fetch(base + "/"); | |
| 53 | expect(response.status).toBe(200); | |
| 54 | const page = await response.text(); | |
| 55 | expect(page).toContain("<h1>Cheffry's Dungeon</h1>"); | |
| 56 | expect(page).toContain('href="https://game.cheffryjames.com"'); | |
| 57 | expect(page).toContain('class="button button-play"'); | |
| 58 | for (const name of ["Meatball Slime", "Baguette Knight", "Pepper Imp"]) | |
| 59 | expect(page).toContain(name); | |
| 60 | expect(page).toContain('href="/how-to-play"'); | |
| 61 | expect(page).toContain('aria-current="page"'); | |
| 60 | 62 | }); |
| 61 | 63 | |
| 62 | test("a form post adds as the person; a guest is sent to sign in", async () => { | |
| 63 | const form = new FormData(); | |
| 64 | form.set("body", "hello from a form"); | |
| 65 | const refused = await api("/notes", { | |
| 66 | method: "POST", | |
| 67 | body: form, | |
| 68 | redirect: "manual", | |
| 69 | }); | |
| 70 | expect(refused.status).toBe(303); | |
| 71 | expect(refused.headers.get("set-cookie")).toContain("notice="); | |
| 72 | expect(refused.headers.get("set-cookie")).toContain("SameSite=Lax"); | |
| 73 | expect(await (await fetch(base + "/")).text()).not.toContain( | |
| 74 | "hello from a form", | |
| 64 | test("how to play says the controls, the enemies, the chests and the score", async () => { | |
| 65 | const response = await fetch(base + "/how-to-play"); | |
| 66 | expect(response.status).toBe(200); | |
| 67 | const page = await response.text(); | |
| 68 | expect(page).toContain("<title>How to play · Cheffry's Dungeon</title>"); | |
| 69 | expect(page).toContain( | |
| 70 | 'property="og:url" content="http://localhost/how-to-play"', | |
| 75 | 71 | ); |
| 76 | const posted = await api( | |
| 77 | "/notes", | |
| 78 | { method: "POST", body: form, redirect: "manual" }, | |
| 79 | alice, | |
| 80 | ); | |
| 81 | expect(posted.status).toBe(303); | |
| 82 | expect(posted.headers.get("set-cookie")).toBeNull(); | |
| 83 | const page = await (await api("/", {}, alice)).text(); | |
| 84 | expect(page).toContain("hello from a form"); | |
| 85 | expect(page).toContain("alice ·"); | |
| 72 | for (const words of [ | |
| 73 | "Dodge", | |
| 74 | "Skewer Spikes", | |
| 75 | "Whisk of Power", | |
| 76 | "The Pass", | |
| 77 | "6 hearts", | |
| 78 | "20 minutes", | |
| 79 | ]) | |
| 80 | expect([words, page.includes(words)]).toEqual([words, true]); | |
| 81 | expect(page).toContain('href="https://game.cheffryjames.com"'); | |
| 82 | }); | |
| 83 | ||
| 84 | test("the pages run no script, and link nothing but this site and the game", async () => { | |
| 85 | for (const path of ["/", "/how-to-play"]) { | |
| 86 | const page = await (await fetch(base + path)).text(); | |
| 87 | expect(page).not.toContain("<script"); | |
| 88 | const hrefs = [...page.matchAll(/(?:href|src)="([^"]*)"/g)].map( | |
| 89 | (m) => m[1]!, | |
| 90 | ); | |
| 91 | for (const href of hrefs) | |
| 92 | expect([ | |
| 93 | href, | |
| 94 | href.startsWith("/") || | |
| 95 | href.startsWith("#") || | |
| 96 | href.startsWith("data:") || | |
| 97 | href === "https://game.cheffryjames.com", | |
| 98 | ]).toEqual([href, true]); | |
| 99 | } | |
| 86 | 100 | }); |
| 87 | 101 | |
| 88 | 102 | test("the JSON API is the same actions with the same identity", async () => { |
| @@ -229,11 +243,10 @@ test("stands on the security floor", async () => { | ||
| 229 | 243 | |
| 230 | 244 | test("a body is capped by the bytes read, whatever it claims", async () => { |
| 231 | 245 | const response = await api( |
| 232 | "/notes", | |
| 246 | "/api/notes", | |
| 233 | 247 | { |
| 234 | 248 | method: "POST", |
| 235 | headers: { "content-type": "application/x-www-form-urlencoded" }, | |
| 236 | body: "body=" + "x".repeat(1024 * 1024), | |
| 249 | ...jsonBody({ body: "x".repeat(1024 * 1024) }), | |
| 237 | 250 | }, |
| 238 | 251 | alice, |
| 239 | 252 | ); |
| @@ -247,15 +260,13 @@ test("HEAD sees the app up", async () => { | ||
| 247 | 260 | }); |
| 248 | 261 | |
| 249 | 262 | test("refuses a cross-site post", async () => { |
| 250 | const form = new FormData(); | |
| 251 | form.set("body", "from another site"); | |
| 252 | 263 | const posted = await api( |
| 253 | "/notes", | |
| 264 | "/api/notes", | |
| 254 | 265 | { |
| 255 | 266 | method: "POST", |
| 256 | body: form, | |
| 257 | redirect: "manual", | |
| 267 | body: JSON.stringify({ body: "from another site" }), | |
| 258 | 268 | headers: { |
| 269 | "content-type": "application/json", | |
| 259 | 270 | origin: "https://evil.example", |
| 260 | 271 | "sec-fetch-site": "cross-site", |
| 261 | 272 | }, |
static/app.css +137 −98
| @@ -15,27 +15,29 @@ body { | ||
| 15 | 15 | font: var() / var() var(); |
| 16 | 16 | } |
| 17 | 17 | .page { |
| 18 | max-width: 40rem; | |
| 18 | max-width: 52rem; | |
| 19 | 19 | margin: 0 auto; |
| 20 | padding: var() var() var(); | |
| 20 | padding: var() var() var(); | |
| 21 | 21 | } |
| 22 | .head { | |
| 23 | display: flex; | |
| 24 | flex-wrap: wrap; | |
| 25 | justify-content: space-between; | |
| 26 | align-items: flex-start; | |
| 27 | gap: var() var(); | |
| 28 | margin-bottom: var(); | |
| 29 | } | |
| 30 | h1 { | |
| 31 | margin: 0; | |
| 32 | font-size: var(); | |
| 22 | h1, | |
| 23 | h2, | |
| 24 | h3 { | |
| 25 | margin: 0 0 var(); | |
| 33 | 26 | font-weight: 650; |
| 34 | 27 | letter-spacing: -0.01em; |
| 35 | 28 | line-height: var(); |
| 36 | 29 | } |
| 30 | h1 { | |
| 31 | font-size: var(); | |
| 32 | } | |
| 33 | h2 { | |
| 34 | font-size: var(); | |
| 35 | } | |
| 36 | h3 { | |
| 37 | font-size: var(); | |
| 38 | } | |
| 37 | 39 | p { |
| 38 | margin: 0; | |
| 40 | margin: 0 0 var(); | |
| 39 | 41 | } |
| 40 | 42 | a { |
| 41 | 43 | color: var(); |
| @@ -54,79 +56,141 @@ code, | ||
| 54 | 56 | .muted { |
| 55 | 57 | color: var(); |
| 56 | 58 | } |
| 57 | .you { | |
| 59 | main > section { | |
| 60 | margin-bottom: var(); | |
| 61 | } | |
| 62 | ||
| 63 | /* Past the header for whoever arrives by keyboard. */ | |
| 64 | .skip { | |
| 65 | position: absolute; | |
| 66 | left: var(); | |
| 67 | top: -100px; | |
| 68 | padding: var() var(); | |
| 69 | background: var(); | |
| 70 | border-radius: var(); | |
| 71 | } | |
| 72 | .skip:focus { | |
| 73 | top: var(); | |
| 74 | } | |
| 75 | ||
| 76 | /* The header: the wordmark, the way around, Play. */ | |
| 77 | .head { | |
| 78 | display: flex; | |
| 79 | flex-wrap: wrap; | |
| 80 | justify-content: space-between; | |
| 81 | align-items: center; | |
| 82 | gap: var() var(); | |
| 83 | margin-bottom: var(); | |
| 84 | } | |
| 85 | .brand { | |
| 86 | display: inline-flex; | |
| 87 | align-items: center; | |
| 88 | gap: var(); | |
| 89 | min-height: var(); | |
| 90 | font-weight: 700; | |
| 91 | color: var(); | |
| 92 | text-decoration: none; | |
| 93 | } | |
| 94 | .mark { | |
| 95 | display: inline-grid; | |
| 96 | place-items: center; | |
| 97 | width: 2rem; | |
| 98 | height: 2rem; | |
| 99 | font-size: var(); | |
| 100 | color: var(); | |
| 101 | background: var(); | |
| 102 | border-radius: var(); | |
| 103 | } | |
| 104 | .nav { | |
| 105 | display: flex; | |
| 106 | flex-wrap: wrap; | |
| 107 | align-items: center; | |
| 108 | gap: var() var(); | |
| 109 | } | |
| 110 | .nav a:not(.button) { | |
| 111 | display: inline-flex; | |
| 112 | align-items: center; | |
| 113 | min-height: var(); | |
| 114 | padding: 0 var(); | |
| 58 | 115 | color: var(); |
| 59 | white-space: nowrap; | |
| 116 | text-decoration: none; | |
| 117 | } | |
| 118 | .nav a:not(.button):hover, | |
| 119 | .nav a[aria-current="page"] { | |
| 120 | color: var(); | |
| 121 | } | |
| 122 | .nav a[aria-current="page"] { | |
| 123 | text-decoration: underline; | |
| 124 | } | |
| 125 | ||
| 126 | /* The opening of a page. */ | |
| 127 | .hero { | |
| 128 | padding: var() 0; | |
| 129 | } | |
| 130 | .lede { | |
| 131 | max-width: 36rem; | |
| 132 | font-size: var(); | |
| 133 | } | |
| 134 | .hero .row { | |
| 135 | margin: var() 0 var(); | |
| 60 | 136 | } |
| 61 | 137 | |
| 62 | 138 | /* Blocks. */ |
| 63 | 139 | .card { |
| 64 | 140 | background: var(); |
| 65 | 141 | border: 1px solid var(); |
| 66 | border-radius: var(); | |
| 142 | border-radius: var(); | |
| 67 | 143 | padding: var(); |
| 68 | margin-bottom: var(); | |
| 144 | } | |
| 145 | .card > :last-child { | |
| 146 | margin-bottom: 0; | |
| 147 | } | |
| 148 | .cards { | |
| 149 | display: grid; | |
| 150 | grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr)); | |
| 151 | gap: var(); | |
| 152 | } | |
| 153 | .cta { | |
| 154 | text-align: center; | |
| 155 | padding: var() var(); | |
| 69 | 156 | } |
| 70 | 157 | .row { |
| 71 | 158 | display: flex; |
| 72 | 159 | align-items: center; |
| 73 | 160 | gap: var(); |
| 74 | 161 | } |
| 75 | .list { | |
| 76 | list-style: none; | |
| 162 | .wrap { | |
| 163 | flex-wrap: wrap; | |
| 164 | } | |
| 165 | .ticks, | |
| 166 | .steps { | |
| 77 | 167 | margin: 0; |
| 78 | padding: 0; | |
| 168 | padding-left: var(); | |
| 79 | 169 | } |
| 80 | .note { | |
| 81 | display: flex; | |
| 82 | justify-content: space-between; | |
| 83 | align-items: flex-start; | |
| 84 | gap: var(); | |
| 85 | padding: var() 0; | |
| 86 | border-top: 1px solid var(); | |
| 170 | .ticks li, | |
| 171 | .steps li { | |
| 172 | margin-bottom: var(); | |
| 87 | 173 | } |
| 88 | .note:first-child { | |
| 89 | border-top: 0; | |
| 174 | .keys { | |
| 175 | display: grid; | |
| 176 | grid-template-columns: max-content 1fr; | |
| 177 | gap: var() var(); | |
| 178 | margin: 0; | |
| 90 | 179 | } |
| 91 | .note p { | |
| 92 | overflow-wrap: anywhere; | |
| 180 | .keys dt { | |
| 181 | font-weight: 650; | |
| 93 | 182 | } |
| 94 | .note small { | |
| 95 | font-size: var(); | |
| 96 | } | |
| 97 | .empty { | |
| 98 | padding: var() var(); | |
| 99 | text-align: center; | |
| 100 | color: var(); | |
| 101 | border: 1px dashed var(); | |
| 102 | border-radius: var(); | |
| 183 | .keys dd { | |
| 184 | margin: 0; | |
| 103 | 185 | } |
| 104 | 186 | footer { |
| 105 | 187 | margin-top: var(); |
| 188 | padding-top: var(); | |
| 189 | border-top: 1px solid var(); | |
| 106 | 190 | font-size: var(); |
| 107 | display: grid; | |
| 108 | gap: var(); | |
| 109 | } | |
| 110 | footer code { | |
| 111 | overflow-wrap: anywhere; | |
| 112 | 191 | } |
| 113 | 192 | |
| 114 | 193 | /* Controls: at least 44px on a side, keyboard and mouse alike. */ |
| 115 | .input { | |
| 116 | flex: 1; | |
| 117 | min-width: 0; | |
| 118 | min-height: var(); | |
| 119 | padding: 0 var(); | |
| 120 | font: inherit; | |
| 121 | font-size: max(16px, var()); | |
| 122 | color: inherit; | |
| 123 | background: var(); | |
| 124 | border: 1px solid var(); | |
| 125 | border-radius: var(); | |
| 126 | } | |
| 127 | .input::placeholder { | |
| 128 | color: var(); | |
| 129 | } | |
| 130 | 194 | .button { |
| 131 | 195 | min-height: var(); |
| 132 | 196 | padding: 0 var(); |
| @@ -141,58 +205,36 @@ footer code { | ||
| 141 | 205 | text-decoration: none; |
| 142 | 206 | display: inline-flex; |
| 143 | 207 | align-items: center; |
| 208 | justify-content: center; | |
| 144 | 209 | } |
| 145 | 210 | .button:hover { |
| 146 | 211 | filter: brightness(1.08); |
| 147 | 212 | } |
| 213 | .button-play { | |
| 214 | padding: 0 var(); | |
| 215 | } | |
| 216 | .hero .button-play, | |
| 217 | .cta .button-play { | |
| 218 | font-size: var(); | |
| 219 | min-height: calc(var() * 1.25); | |
| 220 | } | |
| 148 | 221 | .button-quiet { |
| 149 | color: var(); | |
| 222 | color: var(); | |
| 150 | 223 | background: transparent; |
| 151 | padding: 0 var(); | |
| 224 | border-color: var(); | |
| 152 | 225 | } |
| 153 | 226 | .button-quiet:hover { |
| 154 | color: var(); | |
| 155 | border-color: var(); | |
| 227 | border-color: var(); | |
| 156 | 228 | filter: none; |
| 157 | 229 | } |
| 158 | 230 | .pill { |
| 159 | 231 | display: inline-block; |
| 160 | 232 | padding: 0.1rem 0.6rem; |
| 161 | font-size: var(); | |
| 162 | 233 | font-weight: 600; |
| 163 | color: var(); | |
| 164 | 234 | border: 1px solid var(); |
| 165 | 235 | border-radius: var(); |
| 166 | 236 | } |
| 167 | .pill[data-env="production"] { | |
| 168 | color: var(); | |
| 169 | border-color: currentColor; | |
| 170 | } | |
| 171 | 237 | |
| 172 | /* A word from the last request, shown once, gone in a moment. */ | |
| 173 | .toast { | |
| 174 | position: fixed; | |
| 175 | left: 50%; | |
| 176 | bottom: var(); | |
| 177 | transform: translateX(-50%); | |
| 178 | max-width: calc(100% - 2 * var()); | |
| 179 | padding: var() var(); | |
| 180 | color: var(); | |
| 181 | background: var(); | |
| 182 | border-radius: var(); | |
| 183 | box-shadow: 0 8px 24px var(); | |
| 184 | animation: toast 6s forwards; | |
| 185 | } | |
| 186 | @keyframes toast { | |
| 187 | 0%, | |
| 188 | 85% { | |
| 189 | opacity: 1; | |
| 190 | } | |
| 191 | 100% { | |
| 192 | opacity: 0; | |
| 193 | visibility: hidden; | |
| 194 | } | |
| 195 | } | |
| 196 | 238 | /* Motion is a courtesy: whoever asked for less gets none. */ |
| 197 | 239 | @media (prefers-reduced-motion: reduce) { |
| 198 | 240 | *, |
| @@ -203,7 +245,4 @@ footer code { | ||
| 203 | 245 | transition-duration: 0.01ms ; |
| 204 | 246 | scroll-behavior: auto ; |
| 205 | 247 | } |
| 206 | .toast { | |
| 207 | animation: none; | |
| 208 | } | |
| 209 | 248 | } |
static/live.js deleted −68
| @@ -1,68 +0,0 @@ | ||
| 1 | // The page stays current without a reload. /events is a stream of what | |
| 2 | // changed; each event becomes `live` and `live:<type>` on `document`, and | |
| 3 | // any element with data-live="<types>" is refetched and swapped when one of | |
| 4 | // its types arrives — server-rendered, so the page never renders twice. | |
| 5 | // Without this file the page is the same page, one reload behind. | |
| 6 | ||
| 7 | let since = ""; | |
| 8 | let tries = 0; | |
| 9 | let source; | |
| 10 | ||
| 11 | function connect() { | |
| 12 | source = new EventSource(since ? `/events?since=${since}` : "/events"); | |
| 13 | source.onopen = () => { | |
| 14 | if (tries > 0) refresh(); // Reconnected: whatever was missed, catch up. | |
| 15 | tries = 0; | |
| 16 | }; | |
| 17 | source.onmessage = (message) => { | |
| 18 | since = message.lastEventId; | |
| 19 | const { type, data } = JSON.parse(message.data); | |
| 20 | document.dispatchEvent(new CustomEvent("live", { detail: { type, data } })); | |
| 21 | document.dispatchEvent(new CustomEvent(`live:${type}`, { detail: data })); | |
| 22 | }; | |
| 23 | // The browser retries a dropped connection itself; a refused one it gives | |
| 24 | // up on, and then we try again, later each time. | |
| 25 | source.onerror = () => { | |
| 26 | if (source.readyState !== EventSource.CLOSED) return; | |
| 27 | source.close(); | |
| 28 | setTimeout(connect, Math.min(30_000, 1000 * 2 ** tries++)); | |
| 29 | }; | |
| 30 | } | |
| 31 | ||
| 32 | let refreshing = false; | |
| 33 | let again = false; | |
| 34 | async function refresh(types) { | |
| 35 | const regions = [...document.querySelectorAll("[data-live]")].filter( | |
| 36 | (el) => | |
| 37 | types === undefined || | |
| 38 | el.dataset.live.split(" ").some((t) => types.includes(t)), | |
| 39 | ); | |
| 40 | if (regions.length === 0) return; | |
| 41 | if (refreshing) { | |
| 42 | again = true; | |
| 43 | return; | |
| 44 | } | |
| 45 | refreshing = true; | |
| 46 | try { | |
| 47 | const response = await fetch(location.href, { | |
| 48 | headers: { accept: "text/html" }, | |
| 49 | }); | |
| 50 | const fresh = new DOMParser().parseFromString( | |
| 51 | await response.text(), | |
| 52 | "text/html", | |
| 53 | ); | |
| 54 | for (const el of regions) { | |
| 55 | const next = fresh.getElementById(el.id); | |
| 56 | if (next) el.replaceWith(next); | |
| 57 | } | |
| 58 | } finally { | |
| 59 | refreshing = false; | |
| 60 | if (again) { | |
| 61 | again = false; | |
| 62 | refresh(); | |
| 63 | } | |
| 64 | } | |
| 65 | } | |
| 66 | ||
| 67 | document.addEventListener("live", (event) => refresh([event.detail.type])); | |
| 68 | connect(); |