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 {
+5 -2
View File
@@ -162,7 +162,10 @@ _DIRS = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
# stored theme+word list, sampling a different subset per size so each is its own
# puzzle, while all three share the day's theme.
WS_TIERS = {"small": {"grid": 8, "count": 6}, "med": {"grid": 11, "count": 10}, "large": {"grid": 14, "count": 14}}
WS_TARGET = 28 # words to gather per theme, so there's variety to sample from
WS_TARGET = 28 # words to ask the LLM for, so there's variety to sample from
# Only accept an LLM proposal with enough words to fill Large with room to spare;
# otherwise fall back to a curated theme (which always has plenty).
WS_MIN_ACCEPT = WS_TIERS["large"]["count"] + 4
# Curated fallbacks — calm and neutral everyday scenes, not just upbeat. ~22 words
# each (48 letters, uppercase) so every size has a fresh subset to draw from.
@@ -217,7 +220,7 @@ def _ws_propose(client) -> tuple[str, list[str]] | None:
elif s.upper().startswith("WORDS:"):
words = [w.strip().upper() for w in re.split(r"[,\s]+", s.split(":", 1)[1]) if w.strip()]
words = [w for w in dict.fromkeys(words) if w.isalpha() and 4 <= len(w) <= 8]
if theme and len(words) >= 6:
if theme and len(words) >= WS_MIN_ACCEPT: # enough to fill Large + spare
return theme, words
except Exception: # noqa: BLE001 — fall back to a curated theme
pass
+25
View File
@@ -336,3 +336,28 @@ def test_wordsearch_endpoint(tmp_path, monkeypatch):
assert len(themes) == 1 # all sizes share the day's one theme
# an unknown size falls back to med
assert tc.get("/api/puzzle/wordsearch?variant=nope").json()["size"] == "med"
def test_wordsearch_thin_llm_falls_back(tmp_path, monkeypatch):
from goodnews.db import connect, init_db
from goodnews import games
class FakeClient:
def __init__(self, text):
self.text = text
def chat_text(self, msg):
return self.text
c = connect(":memory:"); init_db(c)
# Thin proposal (only ~6 valid words) must be REJECTED so Large can't underfill.
thin = FakeClient("THEME: Thin Theme\nWORDS: ONE, TWO, FOUR, FIVE, SEVEN, EIGHT, THREE")
p = games.generate_wordsearch_puzzle(c, "2026-07-01", client=thin)
assert p["theme"] != "Thin Theme" # fell back to a curated theme
large = games.wordsearch_response(c, "2026-07-01", "large")
assert len(large["words"]) == games.WS_TIERS["large"]["count"] # still full
# A rich proposal (>= WS_MIN_ACCEPT valid words) is accepted.
rich = FakeClient("THEME: Rich Theme\nWORDS: " + ", ".join(chr(65 + i) * 5 for i in range(20)))
p2 = games.generate_wordsearch_puzzle(c, "2026-07-02", client=rich)
assert p2["theme"] == "Rich Theme" and len(p2["words"]) >= games.WS_MIN_ACCEPT