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 `