Word Search bug-fixes + Codex polish

Two reported bugs, same root cause: the fixed-cell grid overflowed its wrapper
on Large, so (a) the last column spilled past the border and (b) the pointer→cell
math drifted across the row, recording finds "off by a letter".
* Grid now uses 1fr columns with max-width = n·32px: the board grows with the
  grid and can never overflow (shrinks to fit a narrow phone instead).
* cellAt() accounts for the grid padding/border, so selection is exact edge-to-edge.
* restore() now validates each saved find against the CURRENT grid and drops any
  whose cells no longer spell the word — clears stale highlights if the day's
  puzzle changed.

Codex follow-ups:
* _ws_propose now requires >= large.count + 4 valid words before accepting an LLM
  proposal (else falls back to a curated theme), so a thin LLM result can't
  underfill Large. Added a thin-LLM fallback test.
* Cleaned Svelte warnings: removed the now-unused .gamecard.soon CSS, added an
  ARIA role/label to the grid, declared gridEl with $state. Build is warning-clean.
* Added a stale-load guard in WordSearchGame.load() so rapid size switches can't
  let an older request overwrite the newer selection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-10 21:09:33 -04:00
parent 9f7eb11155
commit b909b7e64b
4 changed files with 57 additions and 21 deletions
@@ -22,7 +22,8 @@
let ready = $state(false);
let okFlash = $state(false);
let copied = $state(false);
let gridEl;
let gridEl = $state(null);
let loadSeq = 0;
const n = $derived(grid.length);
const stateKey = $derived(`goodnews:wordsearch:${size}:${date}`);
@@ -42,17 +43,21 @@
});
async function load() {
const seq = ++loadSeq; // stale-load guard for rapid size switches
loading = true; ready = false;
foundWords = []; sel = []; resultMs = 0; startTime = 0;
try {
const p = await getJSON('/api/puzzle/wordsearch?variant=' + size);
if (seq !== loadSeq) return; // a newer size was selected — abandon
theme = p.theme; words = p.words; grid = p.grid; date = p.date;
restore();
if (!startTime) startTime = Date.now();
try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; }
} catch {
if (seq !== loadSeq) return;
theme = ''; words = [];
}
if (seq !== loadSeq) return;
loading = false;
requestAnimationFrame(() => (ready = true));
}
@@ -61,9 +66,14 @@
try {
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
if (s && Array.isArray(s.foundWords)) {
foundWords = s.foundWords;
// Keep only finds whose stored cells still spell their word in the CURRENT
// grid — guards against stale highlights if the day's puzzle changed.
const valid = s.foundWords.filter((fw) =>
fw && Array.isArray(fw.cells) && words.includes(fw.word) &&
fw.cells.map(([r, c]) => (grid[r] && grid[r][c]) || '').join('') === fw.word);
foundWords = valid;
startTime = s.startTime || 0;
resultMs = s.ms || 0;
resultMs = valid.length === words.length ? (s.ms || 0) : 0;
}
} catch { /* ignore */ }
onstatus?.(summary());
@@ -77,9 +87,10 @@
function cellAt(e) {
const rect = gridEl.getBoundingClientRect();
const cw = rect.width / n;
const c = Math.min(n - 1, Math.max(0, Math.floor((e.clientX - rect.left) / cw)));
const r = Math.min(n - 1, Math.max(0, Math.floor((e.clientY - rect.top) / cw)));
const pad = 7; // grid padding (6) + border (1)
const cw = (rect.width - 2 * pad) / n; // even 1fr columns share the inner width
const c = Math.min(n - 1, Math.max(0, Math.floor((e.clientX - rect.left - pad) / cw)));
const r = Math.min(n - 1, Math.max(0, Math.floor((e.clientY - rect.top - pad) / cw)));
return [r, c];
}
@@ -144,6 +155,7 @@
<p class="theme"><span class="lbl">Todays theme</span>{theme}</p>
<div class="grid" class:ok={okFlash} class:done={status === 'done'} bind:this={gridEl} style="--n:{n}"
role="application" aria-label="Word search grid — drag across letters to find words"
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up}>
{#each grid as rowStr, r (r)}
{#each rowStr.split('') as ch, c (c)}
@@ -180,24 +192,23 @@
</div>
<style>
.wordsearch { max-width: 460px; margin: 0 auto; opacity: 0; transform: translateY(6px); }
.wordsearch { max-width: 520px; margin: 0 auto; opacity: 0; transform: translateY(6px); }
.wordsearch.ready { opacity: 1; transform: none; transition: opacity 0.3s ease, transform 0.3s ease; }
.theme { text-align: center; font-family: var(--serif); font-size: 1.3rem; color: var(--accent-deep); margin: 0 0 14px; }
.theme .lbl { display: block; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.64rem;
font-family: var(--label); color: var(--muted); margin-bottom: 2px; }
.grid {
/* Constant cell size — the board grows with the grid; on a narrow phone the
biggest grid gently shrinks to fit the width. */
--cell: min(32px, calc((100vw - 44px) / var(--n)));
display: grid; grid-template-columns: repeat(var(--n), var(--cell)); gap: 2px;
width: fit-content; max-width: 100%; margin: 0 auto 16px; touch-action: none;
user-select: none; -webkit-user-select: none; border-radius: 10px; padding: 6px;
background: var(--surface); border: 1px solid var(--line);
/* Cells are ~32px on a wide screen (board grows with the grid) and shrink via
1fr to fit a narrow phone — capped by max-width so it can never overflow. */
display: grid; grid-template-columns: repeat(var(--n), 1fr); gap: 2px;
width: 100%; max-width: calc(var(--n) * 32px + 16px); margin: 0 auto 16px;
touch-action: none; user-select: none; -webkit-user-select: none;
border-radius: 10px; padding: 6px; background: var(--surface); border: 1px solid var(--line);
}
.grid.done { opacity: 0.9; }
.cell {
width: var(--cell); height: var(--cell); display: flex; align-items: center; justify-content: center;
font-family: var(--label); font-weight: 600; font-size: calc(var(--cell) * 0.5);
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
font-family: var(--label); font-weight: 600; font-size: clamp(0.58rem, 2.5vw, 1rem);
color: var(--ink); border-radius: 5px; background: transparent; text-transform: uppercase;
}
.cell.sel { background: var(--accent) !important; color: #fff !important; }
-3
View File
@@ -140,13 +140,10 @@
box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease;
}
.gamecard:hover { border-color: var(--accent); transform: translateY(-1px); }
.gamecard.soon { cursor: default; opacity: 0.6; box-shadow: none; }
.gamecard.soon:hover { border-color: var(--line); transform: none; }
.gc-icon { font-size: 2rem; color: var(--accent); line-height: 1; flex-shrink: 0; }
.gc-body h2 { font-size: 1.2rem; margin: 0 0 3px; }
.gc-sub { color: var(--muted); font-size: 0.86rem; margin: 0 0 8px; }
.gc-status { font-size: 0.84rem; color: var(--accent-deep); font-weight: 600; margin: 0; }
.gamecard.soon .gc-status { color: var(--muted); font-weight: 400; font-style: italic; }
.variant { display: flex; gap: 10px; justify-content: center; margin: 0 0 22px; }
.vchip {