From 1cef5d4272e29f7d65ad37bc6e2e8017c5048e47 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Wed, 10 Jun 2026 08:06:33 -0700 Subject: [PATCH] nom: whole words, scale 1, bigger boards + sw: lib/ goes network-first MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit nom.mjs β€” words are sacred: word cells render the whole word on ONE line at scale 1, never split/hyphenated/scaled (fitText). Word boards go near edge-to-edge (4px margins, no zoom-punch headroom) so cells get the width; grid paints in two passes (backgrounds, then labels) so a long word on a tight screen bleeds over the tile seam instead of being overpainted; the collected-answers gutter skips when there's no gutter and stays at scale 1. sw.js β€” /lib/ + /systems/ modules now network-first (conditional revalidate, cache = offline fallback), cache bumped to v9. Pieces under /disks/ are never cached but import from /lib/ β€” stale-while-revalidate could hand a fresh piece a one-version-old lib (catnom.mjs vs cached nom.mjs missing makeMeta). Network-first preserves the piece↔lib lockstep; common case is a cheap 304. --- system/public/aesthetic.computer/lib/nom.mjs | 114 ++++++++++--------- system/public/sw.js | 24 +++- 2 files changed, 84 insertions(+), 54 deletions(-) diff --git a/system/public/aesthetic.computer/lib/nom.mjs b/system/public/aesthetic.computer/lib/nom.mjs index 6885554465..4bc9508dde 100644 --- a/system/public/aesthetic.computer/lib/nom.mjs +++ b/system/public/aesthetic.computer/lib/nom.mjs @@ -167,7 +167,7 @@ function resolveMode(params) { // 🎲 Round generation ──────────────────────────────────────────────────────── -const { abs, floor, min, max, ceil, round, sin, cos, pow } = Math; +const { abs, floor, min, max, round, sin, cos, pow } = Math; function isPrime(n) { if (n < 2) return false; @@ -247,27 +247,18 @@ function cellFont() { return mode === "word" ? "unifont" : null; // null β†’ default font_1 } -// Fit a label inside a cell: pick a size and, when a word is wider than the cell -// even at size 1, wrap it onto two balanced lines (unifont is 16px tall, so two -// lines still fit a square cell). All from a fixed nominal advance β†’ stable, no -// async jitter. Returns the line list + the chosen size and line height. +// Fit a label inside a cell. Words are sacred: word mode always renders the +// whole word on ONE line at scale 1 β€” never split / hyphenated, never scaled β€” +// and computeLayout gives word boards the biggest cells the screen allows so +// the longest word fits. Numbers/notes (short strings) still bump to size 2 +// when roomy. All from a fixed nominal advance β†’ stable, no async jitter. function fitText(str, cell, font) { const { adv, h } = fontMetrics(font); + if (mode === "word") + return { lines: [str], size: 1, lineH: h, width: str.length * adv, height: h, font }; const inner = max(8, cell - CELL_MARGIN * 2); - const perLine = max(1, floor(inner / adv)); // chars per line at size 1 - let lines, size; - if (str.length <= perLine) { - // Fits one line β€” bump to size 2 if both width and height still allow it. - size = str.length * adv * 2 <= inner && h * 2 <= inner ? 2 : 1; - lines = [str]; - } else { - size = 1; // too wide for one line β†’ split into two balanced lines - const half = ceil(str.length / 2); - lines = [str.slice(0, half), str.slice(half)]; - } - const lineH = h * size; - const width = Math.max(...lines.map((l) => l.length * adv * size)); - return { lines, size, lineH, width, height: lines.length * lineH, font }; + const size = str.length * adv * 2 <= inner && h * 2 <= inner ? 2 : 1; + return { lines: [str], size, lineH: h * size, width: str.length * adv * size, height: h * size, font }; } // Draw a (possibly two-line) cell label, centered in the cell with an optional @@ -1445,9 +1436,10 @@ let layout = { x: 0, y: 0, cell: 32, top: 40 }; function computeLayout(screen) { screenH = screen.height; - // Modest side margins β€” bigger cells give word editions (esp. Cyrillic in the - // 8px-wide unifont) the room to render legibly without crowding the edges. - const sideMargin = max(14, floor(screen.width * 0.06)); + // Word boards go near edge-to-edge: whole words render on one line at + // scale 1 (never split β€” see fitText), so cells get every pixel the screen + // has. Number/note boards keep the roomier framing. + const sideMargin = mode === "word" ? 4 : max(14, floor(screen.width * 0.06)); const top = 54; // header band (rule title + stats) const bottomPad = 30; // HUD band (lives) const availW = screen.width - sideMargin * 2; @@ -1457,8 +1449,10 @@ function computeLayout(screen) { // … but never so large that the camera's max zoom punch overflows the full // viewport β€” mobile screens are tight, so keep the whole board on-screen even // at peak zoom (we still reserve a hair for lean + shake via the clamp below). + // Word mode skips that reservation (cells need the width for whole words); + // applyCamera just centers the board during the brief zoom punches instead. const zoomFit = min(screen.width / COLS, availH / ROWS) / MAX_ZOOM; - const cell = max(18, floor(min(restFit, zoomFit))); + const cell = max(18, floor(mode === "word" ? restFit : min(restFit, zoomFit))); const gw = cell * COLS; const gh = cell * ROWS; layout = { @@ -1542,7 +1536,11 @@ function paint({ wipe, ink, screen, write, box, line, text }) { undefined, undefined, false, labelFont || undefined, ); - // Grid. + // Grid β€” two passes: every tile background/outline first, THEN every label. + // Words always render whole at scale 1 (see fitText), so on a very tight + // screen a long word can be a touch wider than its cell; painting all the + // backgrounds first lets it bleed over the tile seam instead of getting + // overpainted by its neighbour's background. const gameOver = state === "over"; for (let r = 0; r < ROWS; r += 1) { for (let c = 0; c < COLS; c += 1) { @@ -1577,32 +1575,40 @@ function paint({ wipe, ink, screen, write, box, line, text }) { ink(255, 255, 255, 28).box(cx + 1, cy + 1, cell - 2, cell - 2); ink(235, 242, 255).box(cx + 1, cy + 1, cell - 2, cell - 2, "outline"); } - - // Value β€” ONE color per answer (color-words use their hue + contrast). - // Centered in the cell with margin; word editions use unifont and wrap - // long words onto two lines so they never spill into neighbouring cells. - if (!cd.eaten) { - const font = cellFont(); - let jx = 0, jy = 0; - if (cd.known && !cd.failed) { - jx = sin(frames * 0.9 + c * 1.7) * 1.6; // afraid jitter - jy = cos(frames * 1.1 + r * 1.3) * 1.2; - } - let col; - if (cd.failed) col = [120, 95, 100]; - else if (cd.known) col = [10, 40, 18]; // dark on the green known square - else if (missed) col = [228, 230, 238]; - else if (mode === "note") col = noteColor(cd.letter); // note's keyboard hue - else if (colorWord) col = luma(colorWord) > 140 ? [25, 25, 30] : [245, 245, 255]; - else col = charColor(txt[0]); - drawCellText(ink, txt, cx, cy, cell, font, col, 255, jx, jy); - - // βœ– X out tried-and-wrong cells (can't be tried again). - if (cd.failed) { - ink(225, 70, 70); - line(cx + 4, cy + 4, cx + cell - 4, cy + cell - 4); - line(cx + cell - 4, cy + 4, cx + 4, cy + cell - 4); - } + } + } + for (let r = 0; r < ROWS; r += 1) { + for (let c = 0; c < COLS; c += 1) { + const cd = grid[idx(c, r)]; + if (cd.eaten) continue; + const cx = x + c * cell; + const cy = y + r * cell; + const txt = String(cd.value); + const colorWord = COLOR_WORDS[txt]; + const missed = gameOver && cd.correct && !cd.eaten; + + // Value β€” ONE color per answer (color-words use their hue + contrast), + // centered in the cell. Word editions: unifont, whole word, scale 1. + const font = cellFont(); + let jx = 0, jy = 0; + if (cd.known && !cd.failed) { + jx = sin(frames * 0.9 + c * 1.7) * 1.6; // afraid jitter + jy = cos(frames * 1.1 + r * 1.3) * 1.2; + } + let col; + if (cd.failed) col = [120, 95, 100]; + else if (cd.known) col = [10, 40, 18]; // dark on the green known square + else if (missed) col = [228, 230, 238]; + else if (mode === "note") col = noteColor(cd.letter); // note's keyboard hue + else if (colorWord) col = luma(colorWord) > 140 ? [25, 25, 30] : [245, 245, 255]; + else col = charColor(txt[0]); + drawCellText(ink, txt, cx, cy, cell, font, col, 255, jx, jy); + + // βœ– X out tried-and-wrong cells (can't be tried again). + if (cd.failed) { + ink(225, 70, 70); + line(cx + 4, cy + 4, cx + cell - 4, cy + cell - 4); + line(cx + cell - 4, cy + 4, cx + 4, cy + cell - 4); } } } @@ -1709,14 +1715,18 @@ function paint({ wipe, ink, screen, write, box, line, text }) { ink(140, 165, 195).write("LEFT", { x: max(4, leftX - 28), y: screen.height - 14 }); // 🍽️ Collected answers β€” stack down the left, measured to fit the margin. + // Word boards run near edge-to-edge, so on narrow screens there's no gutter + // at all β€” skip the list rather than stack it over the board. let fcy = layout.top + 52; // below the big timer const leftRoom = layout.x - 6; const font = cellFont(); const { adv: fAdv, h: fH } = fontMetrics(font); for (const v of foundValues) { + if (leftRoom < 28) break; if (fcy > lifeY - 16) break; - // Largest size whose nominal width fits the left gutter (stable, no async). - const size = v.length * fAdv * 2 + 4 <= leftRoom ? 2 : 1; + // Largest size whose nominal width fits the left gutter (stable, no + // async) β€” words stay at scale 1, like the board (see fitText). + const size = mode !== "word" && v.length * fAdv * 2 + 4 <= leftRoom ? 2 : 1; const fit = { size, width: v.length * fAdv * size, height: fH * size }; const w = fit.width + 4; const ltr = mode === "note" ? v.slice(0, -1) : null; // "C4" β†’ "C", "F#5" β†’ "F#" diff --git a/system/public/sw.js b/system/public/sw.js index adf1c6b3b2..5925c04d20 100644 --- a/system/public/sw.js +++ b/system/public/sw.js @@ -1,7 +1,7 @@ // Aesthetic Computer Service Worker // Caches JavaScript modules for faster subsequent loads -const CACHE_NAME = 'ac-modules-v8'; // Bump: pull bios.mjs/disk.mjs out of the cache pipeline while we iterate on camera + telemetry +const CACHE_NAME = 'ac-modules-v9'; // Bump: lib/ + systems/ go network-first (piece↔lib lockstep), flush caches that hold pre-catnom nom.mjs const CACHE_DURATION = 60 * 60 * 1000; // 1 hour in ms (dev-friendly) // Critical modules to precache on install. NOTE: bios.mjs and lib/disk.mjs @@ -39,6 +39,20 @@ const CACHEABLE_PATTERNS = [ /\/aesthetic\.computer\/systems\/.*\.mjs$/, ]; +// Network-first (conditional revalidate; cache is only an offline/failure +// fallback). Pieces under /disks/ are never cached, but they import shared +// modules from /lib/ β€” when a commit changes a piece AND its lib together, +// stale-while-revalidate hands the fresh piece a one-version-old lib and the +// import explodes (e.g. catnom.mjs vs a cached nom.mjs missing an export). +// Keeping piece-importable modules network-first preserves that lockstep; +// the `cache: 'no-cache'` conditional fetch below makes the common case a +// cheap 304. /systems/ rides along for the same reason (disk.mjs, itself +// never cached, imports them). +const NETWORK_FIRST_PATTERNS = [ + /\/aesthetic\.computer\/lib\/.*\.mjs$/, + /\/aesthetic\.computer\/systems\/.*\.mjs$/, +]; + // Never cache these (always network) const NEVER_CACHE = [ /\/api\//, @@ -99,6 +113,9 @@ self.addEventListener('fetch', (event) => { // Check if this is a cacheable request const isCacheable = CACHEABLE_PATTERNS.some((pattern) => pattern.test(url.pathname)); + + // Piece-importable modules prefer the network (cache = fallback only) + const preferNetwork = NETWORK_FIRST_PATTERNS.some((pattern) => pattern.test(url.pathname)); // For core modules, create a clean cache key without query params // This ensures disk.mjs?session-aesthetic=... matches cached /aesthetic.computer/lib/disk.mjs @@ -160,7 +177,10 @@ self.addEventListener('fetch', (event) => { throw err; }); - // Return cached response immediately, or wait for network + // Network-first modules wait for the (conditional, usually-304) + // fetch β€” its catch already falls back to cachedResponse offline. + // Everything else returns the cached response immediately (SWR). + if (preferNetwork) return fetchPromise; return cachedResponse || fetchPromise; }); }) -- 2.51.2