feat(06-07): committed 8-glyph PNG set rasterized from the app's SVG sources (D-12)

- routing/scripts/build-glyphs.mjs: maintainer shell (Usage/Exit codes/
  collect-then-write — a missing/invalid source exits 1 with the kind named
  and nothing written); recolors currentColor → #EB8008 in-memory and
  injects a full-bleed #1A1A1A plate (stroke="none" — it must not inherit
  the root's orange stroke); the committed SVGs are THE single source and
  stay untouched
- sharp 0.35.5 as a routing devDependency (maintainer-only, zero runtime
  deps); 'glyphs' script added
- 8× 256×256 PNGs committed (web/public/glyphs/) — the chat turn-card's
  sendPhoto URL rides the Worker's static handler; two render bugs fixed
  en route: density multiplied the explicit size attrs into 2048² (dropped),
  and the plate inherited the root stroke (orange frame — stroke="none")
- SVG sources verified unchanged after the run
This commit is contained in:
araharah-executor
2026-10-24 19:53:09 +08:00
parent ad612299ca
commit 9c50fb033e
11 changed files with 119 additions and 1 deletions
+3 -1
View File
@@ -6,9 +6,11 @@
"test": "node test/runner.mjs",
"typecheck": "tsc --noEmit",
"fixtures": "node scripts/fetch-route-fixtures.mjs",
"corpus": "node scripts/route-corpus.mjs"
"corpus": "node scripts/route-corpus.mjs",
"glyphs": "node scripts/build-glyphs.mjs"
},
"devDependencies": {
"sharp": "^0.35.5",
"typescript": "^5.9.3"
}
}
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env node
/*
* build-glyphs.mjs — rasterize the 8 turn-card glyphs from the app's SVG sources (06-07, D-12).
* ------------------------------------------------------------------------------------------------
* A MAINTAINER-TIME REPO TOOL in the fetch-route-fixtures.mjs / upload-road-graph.mjs pattern:
* it is NOT a runtime dependency. Nothing in bot/src/, web/src/, or routing/src/ imports it;
* no deploy, test, CI run, or vite build ever runs it. sharp is a devDependency of the routing
* workspace only (RESEARCH Installation — zero runtime deps added).
*
* D-12 BOUNDARY + SINGLE-SOURCE LAW: the committed web/src/guidance/glyphs/*.svg are THE
* source — the SAME paths the app renders inline (banner 32×32, list rows 24×24). The PNGs
* are their rasterization: committed once, reused everywhere (the chat turn-card's sendPhoto
* fetches web/public/glyphs/<kind>.png from the Worker's static handler). The recolor is
* IN-MEMORY ONLY — the committed SVGs keep their currentColor law (theme-following in-app
* rendering) and are never rewritten by this tool.
*
* Colors are DSGN-01 build constants copied verbatim from theme/src/tokens.css (--color-signal-orange,
* --color-matte-black) — the script is not runtime CSS, hex literals are its interface.
*
* Mechanically, per kind: read the SVG → substitute currentColor → STROKE → inject a full-bleed
* 32×32 PLATE rect as the FIRST child (glyph strokes paint over it) → set width/height SIZE
* attributes → render 256×256 PNG via sharp. COLLECT-THEN-WRITE: every source is read and
* rendered BEFORE any file is written — a missing/invalid source or a failed render exits 1
* with the kind named and NO partial writes (the fixture-precompute precedent).
*
* Usage:
* npm run glyphs -w routing # rebuild all 8 web/public/glyphs/*.png
* node scripts/build-glyphs.mjs # same, direct
*
* Exit codes: 0 all 8 PNGs rendered and written; 1 missing/invalid source SVG, failed render,
* or a size/plate sanity-check failure (no file written for a failed batch).
*/
import { mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url)); // routing/scripts
const SRC_DIR = resolve(here, "..", "..", "web", "src", "guidance", "glyphs");
const OUT_DIR = resolve(here, "..", "..", "web", "public", "glyphs");
/** The 8 turn-card kinds — the GlyphKindName union in bot/src/guidance/cron.ts
* (structurally RouteStep.kind, one authority with engine A). */
const GLYPH_KINDS = ["depart", "straight", "left", "right", "slight-left", "slight-right", "uturn", "arrive"];
/** DSGN-01 tokens, verbatim: signal-orange stroke on a matte-black full-bleed plate. */
const STROKE = "#EB8008";
const PLATE = "#1A1A1A";
const SIZE = 256; // output pixels — chat renders it ~48–96px; the oversize keeps edges crisp
const SOURCE_GRID = 32; // the committed sources' viewBox (3px stroke, square caps)
const MIN_BYTES = 500; // a flat-color 256×256 PNG is ≫ 500 B; smaller means a broken render
function fail(message) {
console.error(`build-glyphs: ${message}`);
process.exit(1);
}
/* ————— collect: read + transform + render EVERYTHING before any write ————— */
let sharp;
try {
sharp = (await import("sharp")).default;
} catch {
fail("sharp is not importable — run `npm install -D -w routing sharp` (maintainer devDependency only)");
}
const rendered = [];
for (const kind of GLYPH_KINDS) {
const srcPath = resolve(SRC_DIR, `${kind}.svg`);
let source;
try {
source = readFileSync(srcPath, "utf8");
} catch {
fail(`missing source SVG for kind "${kind}": ${srcPath} — nothing written`);
}
if (!/<svg\b[^>]*>/.test(source)) {
fail(`invalid source SVG (no <svg> root) for kind "${kind}": ${srcPath} — nothing written`);
}
if (!source.includes("currentColor")) {
fail(`source SVG for kind "${kind}" lost its currentColor law (theme-following rendering) — refusing to rasterize; nothing written`);
}
// Recolor + plate + size — in-memory only; the committed source is untouched.
const svg = source
.replaceAll("currentColor", STROKE)
.replace(/<svg\b/, `<svg width="${SIZE}" height="${SIZE}"`)
.replace(/<svg\b[^>]*>/, (m) => `${m}<rect width="${SOURCE_GRID}" height="${SOURCE_GRID}" fill="${PLATE}" stroke="none"/>`);
if (svg.includes("currentColor")) {
fail(`recolor incomplete for kind "${kind}" — nothing written`);
}
let png;
try {
// The width/height SIZE attributes already govern the raster size — an
// explicit density here would MULTIPLY with them (256 × 576/72 = 2048),
// so none is set.
png = await sharp(Buffer.from(svg)).png().toBuffer();
} catch (err) {
fail(`sharp render failed for kind "${kind}": ${err?.message ?? err} — nothing written`);
}
if (png.length < MIN_BYTES) {
fail(`render for kind "${kind}" is ${png.length} bytes (< ${MIN_BYTES}) — a broken/empty plate; nothing written`);
}
rendered.push({ kind, png, srcPath });
}
/* ————— write: all 8 rendered — now commit the batch ————— */
mkdirSync(OUT_DIR, { recursive: true });
for (const { kind, png } of rendered) {
const outPath = resolve(OUT_DIR, `${kind}.png`);
writeFileSync(outPath, png);
console.log(`build-glyphs: wrote ${outPath} (${statSync(outPath).size} bytes)`);
}
console.log(`build-glyphs: ${rendered.length}/${GLYPH_KINDS.length} glyphs rasterized from the committed SVG sources`);