diff --git a/package-lock.json b/package-lock.json index 91cf7c3..0a10b0e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3959,6 +3959,7 @@ "routing": { "name": "@araharah/routing", "devDependencies": { + "sharp": "^0.35.5", "typescript": "^5.9.3" } }, diff --git a/routing/package.json b/routing/package.json index 766779b..5df593a 100644 --- a/routing/package.json +++ b/routing/package.json @@ -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" } } diff --git a/routing/scripts/build-glyphs.mjs b/routing/scripts/build-glyphs.mjs new file mode 100644 index 0000000..8316202 --- /dev/null +++ b/routing/scripts/build-glyphs.mjs @@ -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/.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 (!/]*>/.test(source)) { + fail(`invalid source SVG (no 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(/]*>/, (m) => `${m}`); + 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`); diff --git a/web/public/glyphs/arrive.png b/web/public/glyphs/arrive.png new file mode 100644 index 0000000..e655b49 Binary files /dev/null and b/web/public/glyphs/arrive.png differ diff --git a/web/public/glyphs/depart.png b/web/public/glyphs/depart.png new file mode 100644 index 0000000..fee7047 Binary files /dev/null and b/web/public/glyphs/depart.png differ diff --git a/web/public/glyphs/left.png b/web/public/glyphs/left.png new file mode 100644 index 0000000..0f2082e Binary files /dev/null and b/web/public/glyphs/left.png differ diff --git a/web/public/glyphs/right.png b/web/public/glyphs/right.png new file mode 100644 index 0000000..abf53df Binary files /dev/null and b/web/public/glyphs/right.png differ diff --git a/web/public/glyphs/slight-left.png b/web/public/glyphs/slight-left.png new file mode 100644 index 0000000..908bcfc Binary files /dev/null and b/web/public/glyphs/slight-left.png differ diff --git a/web/public/glyphs/slight-right.png b/web/public/glyphs/slight-right.png new file mode 100644 index 0000000..95f845a Binary files /dev/null and b/web/public/glyphs/slight-right.png differ diff --git a/web/public/glyphs/straight.png b/web/public/glyphs/straight.png new file mode 100644 index 0000000..5dcbb64 Binary files /dev/null and b/web/public/glyphs/straight.png differ diff --git a/web/public/glyphs/uturn.png b/web/public/glyphs/uturn.png new file mode 100644 index 0000000..08e5d9d Binary files /dev/null and b/web/public/glyphs/uturn.png differ