Content-hash glemy-website's built static assets: style.css per-file, the play-demo build as one renamed directory
Context
docs/technical-architecture.md §3.3 (decision 0042) required built static assets to be published under content-derived filenames, to close the risk of a visitor's browser serving a stale cached asset against a freshly deployed page during the several-second window a deploy is in flight -- tracked as roadmap item RM-024. The site has two kinds of build output to consider: a single committed file (style.css) and a whole directory glemy-website doesn't author (glemy's own JS build, copied in wholesale at build time), which needed different treatment.
Options considered
Decision
Added glemy_website/asset_hash.gleam: a pure, file-I/O-free module (hash(BitArray) -> 8-hex-character SHA-256 prefix via gleam_crypto; hashed_filename(name, hash) -> name with the hash inserted before the extension) -- deliberately separated from all the actual file reading so it stays fully unit-testable without touching the filesystem. style.css's source moved from static/ (copied verbatim by add_static_dir under its original name) to a new assets/ directory; build.gleam reads it, computes its hash, and publishes it via add_static_asset under the hashed name, with layout.gleam's new stylesheet_url building the one canonical reference every page uses. glemy's play-demo build, copied into static/play-demo/ by the existing CI/local-dev copy step (unchanged), is hashed as a whole directory by build.gleam (every file's bytes concatenated, then hashed) and renamed in place to static/play-demo-<hash>/ before lustre_ssg's add_static_dir runs; game_card.gleam's play button now links to that discovered path. Tolerant of re-running locally without recopying the demo: if the unhashed directory is gone, it looks for whatever play-demo-* directory already exists instead of failing.
Verification
New unit tests (glemy_website/asset_hash_test.gleam) cover determinism, distinctness for different content, output shape, filename insertion including a multi-dot and a no-extension case, and pin the algorithm/encoding/truncation against a real, independently verifiable external reference (the well-known SHA-256 digest of "hello") -- 17/17 tests pass. A full local build with real data produced consistent, correctly cross-referenced hashes on every page (style.4774e509.css referenced identically on /, /devlog, /roadmap, /play; the game card linking to /play-demo-3948ec22/index.html). Re-running the build a second time without recopying the demo reused the same already-hashed directory and produced an identical hash, confirming idempotency. A real headless-browser pass against the renamed directory directly showed 150 real WebGPU frames rendering and zero errors beyond the same pre-existing, already-diagnosed glemy favicon 404 (confirmed unrelated) -- proving the internal relative .mjs imports inside the renamed directory still resolve correctly, the actual risk this design was built to avoid.
Consequences
Closes RM-024. Every deployed page now references assets by a filename that changes whenever the asset's actual content does, closing the stale-cache-during-deploy risk technical-architecture.md §3.3 identified. static/play-demo-<hash>/ directories can accumulate harmlessly across repeated local builds with changing content (each gitignored, cleaned by deleting static/ if it matters) -- not cleaned up automatically, a deliberate simplicity trade-off rather than added directory-management logic for a purely local, low-stakes cosmetic issue. The README's description of the live demo as iframe-embedded was also found stale (from the earlier game-card redesign) and corrected while touching this area.