From 192c78e5aad36813744ac0f19af0f0ed367c5582 Mon Sep 17 00:00:00 2001 From: Vladimir Lazarev Date: Thu, 13 Aug 2026 15:13:49 +0100 Subject: [PATCH 01/17] Serve the built bundle in development, not the HTML entry point Development ran Bun's HTML dev server, a second implementation of the bundler `bun run build` uses. Serving `dist/` in both means the page under e2e is the bundle production ships, and a defect the bundler introduces is under the browser suite rather than only under build.test.ts. It also unblocks CSS modules: the dev server cannot emit their class-name mapping (oven-sh/bun#18258, open, fix PR #33405 unmerged), so a component importing one would read undefined off a binding nothing defined. The cost is hot module replacement, traded for a rebuild on change. The asset lookup is its own module so its listing guard can be exercised without starting a server. Co-Authored-By: Claude Opus 5 --- PLAN.md | 6 +++-- README.md | 15 ++++++----- build.test.ts | 36 +++++++++++++++++++++++++++ dist-routes.ts | 40 ++++++++++++++++++++++++++++++ package.json | 2 +- playwright.config.ts | 7 +++--- scripts/dev.ts | 59 ++++++++++++++++++++++++++++++++++++++++++++ server.ts | 35 +++++++++++++++++--------- 8 files changed, 174 insertions(+), 26 deletions(-) create mode 100644 dist-routes.ts create mode 100644 scripts/dev.ts diff --git a/PLAN.md b/PLAN.md index c4784c4..37f297b 100644 --- a/PLAN.md +++ b/PLAN.md @@ -171,8 +171,10 @@ Kept because no single file in the tree is where a reader would look for them. - **camelCase in every JSON payload** — `types.ts`, the fixture, the generator and the bundle contract all take renamed keys on import. - **Bun's native bundler, no Vite** — `bun run build` is `bun build - ./index.html --outdir=dist` plus the copy steps; `bun run dev` is `server.ts` - under `--hot`. + ./index.html --outdir=dist` plus the copy steps; `bun run dev` is + `scripts/dev.ts`, which runs the same bundle, watches it and serves it. + Bun's HTML dev server is not used: it never defines a CSS module's + class-name mapping (oven-sh/bun#18258). - **Dependabot, not Renovate** — first-party, so no third-party GitHub App gets write access to a hardening-focused repository. The trade is no dependency dashboard and no lockfile maintenance; the nightly `bun audit` diff --git a/README.md b/README.md index 651c96f..845114a 100644 --- a/README.md +++ b/README.md @@ -28,14 +28,13 @@ One fact lives in exactly one file; everything else links to it. ## Running it -- `bun run dev` — `server.ts` under `--hot` on http://localhost:3000. It - bundles `index.html` on request and serves the two things the bundler - cannot: `/fonts/*` and `/snapshot.json`. -- `bun run build` — bundles into `dist/`, then copies the fonts and the - snapshot in. `dist/` is fully static: `cd dist && python3 -m http.server` - serves a working app. `server.ts` is the other way to run in production — - it bundles from source instead of serving `dist/`, so the two are - alternatives, not a pipeline. +- `bun run dev` — `scripts/dev.ts` on http://localhost:3000: it bundles into + `dist/`, rebuilds on a change under `src/`, and starts `server.ts` over the + result. The page in development is the bundle production ships. +- `bun run build` — the same bundle minified, plus the fonts and the snapshot + copied in. `dist/` is fully static: `cd dist && python3 -m http.server` + serves a working app. `server.ts` serves `dist/` too, adding the two routes + a static server cannot give their headers: `/fonts/*` and `/snapshot.json`. - `bun test` — the whole unit suite. It shells out to `bun run build` once, so a broken copy step or a bundler upgrade that starts inlining the fonts fails here rather than in the browser. `e2e/` is excluded (`pathIgnorePatterns` in diff --git a/build.test.ts b/build.test.ts index d5c3d27..454fd8e 100644 --- a/build.test.ts +++ b/build.test.ts @@ -1,9 +1,13 @@ import { beforeAll, describe, expect, test } from "bun:test"; +import { distFile } from "./dist-routes.ts"; /** * The build is a bundler call plus two `cp` steps, and the font arrangement * rests on Bun leaving an inline ` -
diff --git a/src/app/main.tsx b/src/app/main.tsx index 595e8ff..40de1ce 100644 --- a/src/app/main.tsx +++ b/src/app/main.tsx @@ -1,5 +1,8 @@ import { render } from "preact"; import { App } from "./app.tsx"; +// The stylesheet index.html used to link. Delivering it through the bundle is +// what lets component rules travel with the components that import them. +import "./styles/styles.css"; const root = document.getElementById("app"); if (!root) throw new Error("mount point #app is missing from index.html"); diff --git a/src/css.d.ts b/src/css.d.ts new file mode 100644 index 0000000..50be7c3 --- /dev/null +++ b/src/css.d.ts @@ -0,0 +1,7 @@ +// Bun's bundler loads stylesheets; TypeScript has to be told they resolve. +// A `.module.css` default export is the bundler's class-name mapping, whose +// keys exist only after it runs — so the type is the shape, not the names. +declare module "*.css" { + const classes: Record; + export default classes; +} From c3fc1fc6ddd0fec5d28d948f4d2472e74a5ea36a Mon Sep 17 00:00:00 2001 From: Vladimir Lazarev Date: Thu, 13 Aug 2026 15:16:19 +0100 Subject: [PATCH 04/17] Assert the build emits a stylesheet and the document links it Co-Authored-By: Claude Opus 5 --- build.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/build.test.ts b/build.test.ts index a34a740..d10f78c 100644 --- a/build.test.ts +++ b/build.test.ts @@ -44,6 +44,17 @@ describe("build output", () => { expect(html).toContain('@import url("/fonts/fonts.css")'); }); + // Styles reach the page through the entry point now, so nothing in the + // source `index.html` names them: a stylesheet that stopped being bundled + // is a missing import rather than a 404, and this is what would notice. + test("emits one stylesheet and links it from the document", async () => { + const sheets = [...new Bun.Glob("*.css").scanSync(dist)]; + const html = await Bun.file(`${dist}/index.html`).text(); + + expect(sheets).toHaveLength(1); + expect(html).toContain(`href="./${sheets[0]}"`); + }); + test("leaves no font inlined as a data URI", async () => { const [css] = [...new Bun.Glob("*.css").scanSync(dist)]; const text = await Bun.file(`${dist}/${css}`).text(); From ff708644f298cb870dd939d58d8bad159ae64d46 Mon Sep 17 00:00:00 2001 From: Vladimir Lazarev Date: Thu, 13 Aug 2026 15:16:19 +0100 Subject: [PATCH 05/17] Move the hero tile, the re-pick marker and the dialogs into modules One commit for the move: the picker rings the tile Enter would take and fades the tile of a hero already drafted, so its rules reach into the tile's own. Splitting it would ship a commit with both broken. The reach is now two inherited custom properties, since a scoped class name cannot cross a module boundary. The confirm dialog is app.tsx's, so its rules go to app.module.css rather than the picker's; the bare panel both share has no class to scope and goes to base.css. Co-Authored-By: Claude Opus 5 --- src/app/app.module.css | 44 +++++ src/app/app.tsx | 7 +- src/app/board/board.tsx | 5 +- src/app/board/hero-tile.module.css | 57 +++++++ src/app/board/hero-tile.tsx | 17 +- src/app/cx.test.ts | 18 ++ src/app/cx.ts | 8 + src/app/picker/picker.module.css | 140 ++++++++++++++++ src/app/picker/picker.tsx | 27 +-- src/app/styles/app.css | 253 ----------------------------- src/app/styles/base.css | 14 ++ 11 files changed, 321 insertions(+), 269 deletions(-) create mode 100644 src/app/app.module.css create mode 100644 src/app/board/hero-tile.module.css create mode 100644 src/app/cx.test.ts create mode 100644 src/app/cx.ts create mode 100644 src/app/picker/picker.module.css diff --git a/src/app/app.module.css b/src/app/app.module.css new file mode 100644 index 0000000..f502cd5 --- /dev/null +++ b/src/app/app.module.css @@ -0,0 +1,44 @@ +/* The shell's own styles — currently the reset dialog (screens-spec §4). + Every value is a token reference — literals belong in tokens/ only. */ + +.confirm { + width: min(320px, 100% - var(--sp-8)); + padding: var(--sp-7); +} + +.confirm h2 { + margin-bottom: var(--sp-2); + font-size: var(--fs-body); + font-weight: 600; +} + +.confirm p { + margin-bottom: var(--sp-6); + color: var(--text-4); + font-size: var(--fs-meta); +} + +.confirmActions { + display: flex; + gap: var(--sp-3); + justify-content: flex-end; +} + +.confirmActions button { + padding: var(--sp-2) var(--sp-5); + border: 1px solid var(--border-3); + border-radius: var(--r-control); + background: var(--bg-2); + color: var(--text-2); + font-family: var(--font-ui); + font-size: var(--fs-control); + font-weight: 600; +} + +/* The design tints this button with its own colour rather than a new one, so + the tint is mixed from the token instead of restating the palette here. */ +.confirmActions .danger { + border-color: color-mix(in srgb, var(--dire) 45%, transparent); + background: color-mix(in srgb, var(--dire) 8%, transparent); + color: var(--dire); +} diff --git a/src/app/app.tsx b/src/app/app.tsx index 87efe75..74b949a 100644 --- a/src/app/app.tsx +++ b/src/app/app.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from "preact/hooks"; import { computeModel } from "../model.ts"; import type { HeroId, SnapshotBundle } from "../types.ts"; +import s from "./app.module.css"; import { Board } from "./board/board.tsx"; import { Header } from "./header.tsx"; import { Picker } from "./picker/picker.tsx"; @@ -213,7 +214,7 @@ function ResetDialog({ onAnswer }: { onAnswer: (confirmed: boolean) => void }) { return ( onAnswer(dialog.current?.returnValue === "reset")} @@ -221,13 +222,13 @@ function ResetDialog({ onAnswer }: { onAnswer: (confirmed: boolean) => void }) {

Reset draft?

Bans and all picks will be cleared. Side and role stay.

-
+
{/* `showModal()` honours `autofocus`, which is what makes `Enter` confirm — without it the first control, Cancel, would answer. */} -
diff --git a/src/app/board/board.tsx b/src/app/board/board.tsx index 9925ae2..7c6ce03 100644 --- a/src/app/board/board.tsx +++ b/src/app/board/board.tsx @@ -25,6 +25,9 @@ import { scoreTone, topRoles, } from "./format.ts"; +// The re-pick marker is the tile's other half: it stands in for a hero the +// snapshot dropped, so its rule lives beside the tile it replaces. +import s from "./hero-tile.module.css"; import { HeroTile } from "./hero-tile.tsx"; type Apply = (action: Action) => void; @@ -107,7 +110,7 @@ function RemoveButton({ /** A hero the loaded snapshot no longer carries (screens-spec §6.4): the entry * stays put, says so, and waits to be replaced. */ const RepickBadge = ({ hero }: { hero: HeroEntry | undefined }) => - hero === undefined ? re-pick : null; + hero === undefined ? re-pick : null; const ThinBadge = ({ hero }: { hero: HeroEntry | undefined }) => hero?.sufficient === false ? ( diff --git a/src/app/board/hero-tile.module.css b/src/app/board/hero-tile.module.css new file mode 100644 index 0000000..be9f5e1 --- /dev/null +++ b/src/app/board/hero-tile.module.css @@ -0,0 +1,57 @@ +/* The hero tile, and the marker for a hero the snapshot no longer carries. + Every value is a token reference — literals belong in tokens/ only. */ + +.tile { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + border-radius: var(--r-tile); + /* Both knobs belong to whoever places the tile: the picker rings the one + `Enter` would take and fades the one already in the draft. A scoped class + name cannot cross a module boundary; an inherited custom property can. */ + box-shadow: var(--tile-ring, var(--tile-inset)); + opacity: var(--tile-fade, 1); + font-family: var(--font-mono); + font-weight: 700; +} + +.tileLg { + width: var(--tile-lg); + height: var(--tile-lg); + font-size: var(--fs-tile-lg); +} + +.tileMd { + width: var(--tile-md); + height: var(--tile-md); + border-radius: var(--r-control); + font-size: var(--fs-tile-md); +} + +.tileSm { + width: var(--tile-sm); + height: var(--tile-sm); + border-radius: var(--r-tile-sm); + font-size: var(--fs-tile-sm); +} + +.tileInkDark { + color: var(--tile-ink-dark); +} + +.tileInkLight { + color: var(--tile-ink-light); +} + +/* re-pick marker: the hero left the snapshot (screens-spec §6.4) */ + +.repickBadge { + padding: 2px var(--sp-2); + border: 1px solid var(--accent-border); + border-radius: var(--r-kbd); + background: var(--accent-banner-bg); + color: var(--accent); + font-family: var(--font-mono); + font-size: var(--fs-kbd); +} diff --git a/src/app/board/hero-tile.tsx b/src/app/board/hero-tile.tsx index 930008b..3d83e17 100644 --- a/src/app/board/hero-tile.tsx +++ b/src/app/board/hero-tile.tsx @@ -1,8 +1,23 @@ import type { HeroEntry } from "../../types.ts"; +import { cx } from "../cx.ts"; import { heroAbbr, type Ink, tileInk } from "./format.ts"; +import s from "./hero-tile.module.css"; type TileSize = "lg" | "md" | "sm"; +/** The bundler owns the class names, so a variant is looked up rather than + * spelled out from the prop — a name assembled here would match nothing. */ +const SIZE: Record = { + lg: s.tileLg, + md: s.tileMd, + sm: s.tileSm, +}; + +const INK: Record = { + dark: s.tileInkDark, + light: s.tileInkLight, +}; + /** * The palette lives in `tokens/colors.css` and is never restated here: the * background is a `var()` with the design's own fallback, and the lettering @@ -47,7 +62,7 @@ export function HeroTile({ return ( { + test("a name the stylesheet does not carry leaves nothing behind", () => { + expect(cx(undefined, undefined)).toBe(""); + }); + + test("names keep the order they were given", () => { + expect(cx("tile", "tileLg")).toBe("tile tileLg"); + }); + + // The reason the helper exists: a plain join writes "tile tileLg", and a + // template literal writes "tile undefined tileLg". + test("an absent name between two present ones leaves no gap", () => { + expect(cx("tile", undefined, "tileLg")).toBe("tile tileLg"); + }); +}); diff --git a/src/app/cx.ts b/src/app/cx.ts new file mode 100644 index 0000000..c457be5 --- /dev/null +++ b/src/app/cx.ts @@ -0,0 +1,8 @@ +/** + * Joins the class names a CSS module hands back. A name the stylesheet does + * not carry drops out of the attribute instead of reaching the DOM as the + * string "undefined" — the mapping is built by the bundler, so its keys are + * unknown to the type checker and a stale name is only ever a missing rule. + */ +export const cx = (...names: (string | undefined)[]) => + names.filter(Boolean).join(" "); diff --git a/src/app/picker/picker.module.css b/src/app/picker/picker.module.css new file mode 100644 index 0000000..c1f0989 --- /dev/null +++ b/src/app/picker/picker.module.css @@ -0,0 +1,140 @@ +/* The hero picker (screens-spec §3). Every value is a token reference — + literals belong in tokens/ only. */ + +.picker { + width: min(640px, 100% - var(--sp-8)); + padding: 0; + overflow: hidden; +} + +.pickerHead { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--sp-5) var(--sp-6) var(--sp-4); +} + +.pickerTitle { + font-size: var(--fs-body); + font-weight: 600; +} + +.pickerClose { + width: var(--tile-sm); + height: var(--tile-sm); + border: 1px solid var(--border-3); + border-radius: var(--r-tile-sm); + background: transparent; + color: var(--text-4); + font-size: var(--fs-meta); +} + +.pickerSearch { + display: block; + padding: 0 var(--sp-6) var(--sp-4); +} + +.pickerSearch input { + width: 100%; + padding: var(--sp-3) var(--sp-4); + border: 1px solid var(--border-3); + border-radius: var(--r-control); + background: var(--bg-0); + color: var(--text-1); + font-family: var(--font-ui); + font-size: var(--fs-body); +} + +.pickerEmpty { + padding: 0 var(--sp-6) var(--sp-5); + color: var(--text-4); + font-size: var(--fs-control); +} + +.pickerGrid { + display: grid; + grid-template-columns: repeat(8, 1fr); + gap: var(--sp-3) var(--sp-2); + max-height: 50vh; + padding: var(--sp-1) var(--sp-6) var(--sp-5); + overflow-y: auto; +} + +.pickerTile { + display: flex; + flex-direction: column; + align-items: center; + gap: 5px; + padding: var(--sp-2) 2px; + border: 0; + border-radius: var(--r-tile); + background: transparent; + color: var(--text-4); + font-family: var(--font-ui); + font-size: var(--fs-kbd); + text-align: center; +} + +.pickerFirst { + background: var(--bg-3); + --tile-ring: var(--tile-inset), 0 0 0 2px var(--accent); +} + +.pickerTile[aria-disabled="true"] { + cursor: not-allowed; + /* The design fades the whole cell; fading the words with it would leave the + one thing a taken tile has to say — where the hero already sits — below any + readable contrast. Only the artwork dims, so the fade is handed to the + tile alone and the name is coloured on its own. */ + --tile-fade: 0.35; +} + +.pickerTile[aria-disabled="true"] .pickerName { + color: var(--text-5); +} + +.pickerUsed { + color: var(--accent); +} + +.pickerHints { + display: flex; + flex-wrap: wrap; + gap: var(--sp-5); + padding: var(--sp-3) var(--sp-6); + border-top: 1px solid var(--border-0); + color: var(--text-5); + font-family: var(--font-mono); + font-size: var(--fs-kbd); +} + +/* One column: the board has to fit a phone held beside the game (US-25). */ + +@media (max-width: 720px) { + /* Full-screen picker: the search field has to be reachable with a thumb + and the grid still has to show whole rows (screens-spec §3). */ + .picker { + display: flex; + flex-direction: column; + width: 100%; + max-width: none; + height: 100%; + max-height: none; + margin: 0; + border: 0; + border-radius: 0; + } + + /* The grid is the only part that may shrink, so the hint bar stays on + screen and the heroes scroll inside instead of being clipped away. */ + .pickerGrid { + grid-template-columns: repeat(4, 1fr); + min-height: 0; + max-height: none; + } + + .pickerHints { + margin-top: auto; + gap: var(--sp-4); + } +} diff --git a/src/app/picker/picker.tsx b/src/app/picker/picker.tsx index 93ad93a..dab9a42 100644 --- a/src/app/picker/picker.tsx +++ b/src/app/picker/picker.tsx @@ -9,8 +9,10 @@ import { useEffect, useRef, useState } from "preact/hooks"; import type { HeroEntry, HeroId, SnapshotBundle } from "../../types.ts"; import { HeroTile } from "../board/hero-tile.tsx"; +import { cx } from "../cx.ts"; import type { PickTarget, Used } from "../session.ts"; import { ROLE_UI } from "../session-controls.tsx"; +import s from "./picker.module.css"; import { matchHeroes } from "./search.ts"; /** What the header says we are picking for. */ @@ -123,19 +125,19 @@ export function Picker({ return ( -
-

+
+

Pick for: {targetLabel(target)}

-