diff --git a/CLAUDE.md b/CLAUDE.md
index 251f8ee..75b8aaa 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -229,7 +229,10 @@ describes is rewritten, the rule is a candidate for deletion.
- Scope a scan by what it exempts, never by an enumeration of what it covers.
- State a scan's exemptions in the scan; never inherit them from another tool's
configuration.
-- Scan source left to right carrying string and comment state, never line by line.
+- Scan source left to right carrying string, comment, template-expression and
+ regex-literal state, and name which of those the language being scanned has.
+- Read a literal's contents from the source at the offset the scan reached,
+ never from the copy the scan blanked.
- Comment what a reader would otherwise "fix": a deliberate departure from the
obvious implementation, or a precondition the code does not check.
diff --git a/PLAN.md b/PLAN.md
index f97e31c..573a7dc 100644
--- a/PLAN.md
+++ b/PLAN.md
@@ -96,7 +96,7 @@ change decided lives in its archived proposal under `openspec/changes/archive/`.
its entries means anything about.
### Open
-- [ ] **`file-size-cap`** — proposed, eight steps, not yet applied. The
+- [ ] **`file-size-cap`** — proposed, eight steps, the first applied. The
file-size cap half of "reverse two non-goals": 300 lines for `.ts`/`.tsx`,
200 for `.css`, adopted with no exemption list because the same change
decomposes all nine files currently over the line. `app.css` (943 lines)
@@ -104,7 +104,10 @@ change decided lives in its archived proposal under `openspec/changes/archive/`.
JavaScript bundle. Not an `/opsx:update` on `reviewable-diff-gates` as
this entry used to ask: that change is archived, and the growth protocol
above forbids editing an archive to receive a fact discovered later, so
- the cap lands in the living `change-slicing` spec.
+ the cap lands in the living `change-slicing` spec. Step 1 cost a change
+ to how the application is served, which shipped beside it: Bun's HTML dev
+ server cannot emit a CSS module's class-name mapping, so development
+ builds and serves `dist/` — the constraint below carries it.
- [ ] **The comment scan goes quiet on a regex literal.** A backtick inside one
— `` /[`]/ `` — opens what `scripts/mutation-floor.ts:183-199` takes for a
template literal and runs to end of input, so every comment below it is
@@ -113,7 +116,11 @@ change decided lives in its archived proposal under `openspec/changes/archive/`.
nothing is passing wrongly yet. Either teach the scanner that `/` in
expression position starts one, or assert that the file contains none —
the second is a line and fails loudly, the first ends the family of bugs
- that produced five holes in one session.
+ that produced five holes in one session. The first is now done and lives
+ in `scripts/scan.ts`: it treats a `/` whose last token opens a value as a
+ regex literal and stops it at a newline rather than at end of input, and
+ it tells the two languages apart, CSS having neither `//` nor a regex
+ literal. What is left here is switching `mutation-floor.ts` to it.
- [ ] **The rule of two** — the other half, still outstanding and **not yet
written anywhere**. Lift a helper on the second consumer, never the
first. `reviewable-diff-gates` prescribed its vehicle when it deferred it
@@ -124,7 +131,9 @@ change decided lives in its archived proposal under `openspec/changes/archive/`.
in both `scripts/spec-coverage.test.ts` and `scripts/mutation-floor.ts`,
the second is strictly better, and the Code rule the first implements was
replaced on 2026-08-13. What that costs the older copy is commented at the
- line it costs it.
+ line it costs it. `scripts/scan.ts` is where that lift lands: extracted
+ to bring its file under the cap, and already the module the older copy
+ should switch to — which is what makes it a lift and not speculation.
- [ ] **Task 7** — Docker + VPS deploy (open decisions: registry GHCR or Docker
Hub, same VPS or a new one). Carries `ui-foundation` **(e2e)** 1.5, which
Task 4 deferred here: serving `dist/` under a plain static server is
diff --git a/build.test.ts b/build.test.ts
index fd286be..b940b13 100644
--- a/build.test.ts
+++ b/build.test.ts
@@ -1,4 +1,5 @@
import { beforeAll, describe, expect, test } from "bun:test";
+import { symlinkSync, unlinkSync } from "node:fs";
import { distFile } from "./dist-routes.ts";
/**
@@ -44,6 +45,21 @@ 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);
+ // The element, not the string: an `href` anywhere in the document would
+ // satisfy a substring match without the browser loading anything.
+ expect(html).toMatch(
+ new RegExp(`]+rel="stylesheet"[^>]+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();
@@ -70,6 +86,18 @@ describe("serving the build output", () => {
expect((await response?.text())?.length).toBeGreaterThan(0);
});
+ // `Bun.file` follows a symlink, so the listing has to resolve one.
+ test("refuses an entry that resolves outside dist/", async () => {
+ const planted = `${dist}/escape.js`;
+ symlinkSync(`${import.meta.dir}/package.json`, planted);
+
+ try {
+ expect(distFile("/escape.js")).toBeNull();
+ } finally {
+ unlinkSync(planted);
+ }
+ });
+
// The listing is cached, so what it costs is a stale answer after a rebuild.
test("follows an asset appearing and disappearing", async () => {
const probe = `${dist}/probe-listing.js`;
diff --git a/dist-routes.ts b/dist-routes.ts
index 77f9e2c..aad787f 100644
--- a/dist-routes.ts
+++ b/dist-routes.ts
@@ -44,6 +44,10 @@ let listing = new Set();
function listed(): Set {
const at = statSync(distDir, { bigint: true }).mtimeNs;
if (at !== listedAt) {
+ // `scanSync` does not follow symlinks unless asked to, measured against
+ // Bun 1.3.14, so a link planted in `dist/` is not listed and `Bun.file`
+ // never receives one. That default is what the containment above rests
+ // on, and `build.test.ts` pins it.
listing = new Set(new Bun.Glob("*").scanSync(distDir.pathname));
listedAt = at;
}
diff --git a/index.html b/index.html
index a23b152..3ca4272 100644
--- a/index.html
+++ b/index.html
@@ -9,7 +9,6 @@
-
diff --git a/openspec/changes/file-size-cap/design.md b/openspec/changes/file-size-cap/design.md
index 2f8d273..abec36b 100644
--- a/openspec/changes/file-size-cap/design.md
+++ b/openspec/changes/file-size-cap/design.md
@@ -85,6 +85,42 @@ Bun's bundler detects `.module.css` with no configuration and rewrites locally
scoped class names to unique identifiers — checked in Bun's bundler
documentation, not recalled.
+### Development serves the built bundle
+
+The documentation describes `bun build`. Bun's *other* implementation — the
+HTML entry point served by `Bun.serve` — emits the scoped stylesheet correctly
+and never defines the class-name mapping the components import, so every
+component reading one throws and the page renders nothing (oven-sh/bun#18258,
+open since March 2025; fix PR #33405 unmerged as of Bun 1.3.14). Only the dev
+path is affected.
+
+So `bun run dev` is `scripts/dev.ts`: it bundles into `dist/`, rebuilds on a
+change under `src/`, and starts `server.ts` over the result. `server.ts` no
+longer routes the HTML entry point; it serves `dist/` in development and in
+production alike. It ships ahead of step 1 rather than inside it: it changes
+how the application is served, which is its own reviewable unit.
+
+That costs hot module replacement and buys a development page that is the
+bundle production ships — so a defect the bundler introduces is under the e2e
+suite rather than only under `build.test.ts`. The asset lookup is
+`dist-routes.ts` rather than inline in `server.ts`, for the reason
+`static-routes.ts` is its own file: its listing guard, which is what keeps a
+request from naming a path outside `dist/`, can then be exercised without
+starting a server.
+
+### A picker rule that reaches into the hero tile becomes a custom property
+
+Two rules crossed what became a module boundary: the picker rings the tile
+`Enter` would take and fades the tile of a hero already drafted. A scoped class
+name cannot be written from another module's stylesheet, so the tile reads
+`box-shadow: var(--tile-ring, …)` and `opacity: var(--tile-fade, 1)`, and the
+picker sets those two on its own classes.
+
+Custom properties inherit, so nothing crosses the boundary and specificity
+never enters it. The alternative — a `class` prop on `HeroTile` carrying one of
+the picker's classes — puts two single-class selectors on the same declaration
+and lets emission order decide, which the source does not state.
+
Fonts are the exception and do not move at all. `index.html` owns
`@import url("/fonts/fonts.css")` in an inline `