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:
@@ -22,7 +22,8 @@
|
|||||||
let ready = $state(false);
|
let ready = $state(false);
|
||||||
let okFlash = $state(false);
|
let okFlash = $state(false);
|
||||||
let copied = $state(false);
|
let copied = $state(false);
|
||||||
let gridEl;
|
let gridEl = $state(null);
|
||||||
|
let loadSeq = 0;
|
||||||
|
|
||||||
const n = $derived(grid.length);
|
const n = $derived(grid.length);
|
||||||
const stateKey = $derived(`goodnews:wordsearch:${size}:${date}`);
|
const stateKey = $derived(`goodnews:wordsearch:${size}:${date}`);
|
||||||
@@ -42,17 +43,21 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
const seq = ++loadSeq; // stale-load guard for rapid size switches
|
||||||
loading = true; ready = false;
|
loading = true; ready = false;
|
||||||
foundWords = []; sel = []; resultMs = 0; startTime = 0;
|
foundWords = []; sel = []; resultMs = 0; startTime = 0;
|
||||||
try {
|
try {
|
||||||
const p = await getJSON('/api/puzzle/wordsearch?variant=' + size);
|
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;
|
theme = p.theme; words = p.words; grid = p.grid; date = p.date;
|
||||||
restore();
|
restore();
|
||||||
if (!startTime) startTime = Date.now();
|
if (!startTime) startTime = Date.now();
|
||||||
try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; }
|
try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; }
|
||||||
} catch {
|
} catch {
|
||||||
|
if (seq !== loadSeq) return;
|
||||||
theme = ''; words = [];
|
theme = ''; words = [];
|
||||||
}
|
}
|
||||||
|
if (seq !== loadSeq) return;
|
||||||
loading = false;
|
loading = false;
|
||||||
requestAnimationFrame(() => (ready = true));
|
requestAnimationFrame(() => (ready = true));
|
||||||
}
|
}
|
||||||
@@ -61,9 +66,14 @@
|
|||||||
try {
|
try {
|
||||||
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
|
||||||
if (s && Array.isArray(s.foundWords)) {
|
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;
|
startTime = s.startTime || 0;
|
||||||
resultMs = s.ms || 0;
|
resultMs = valid.length === words.length ? (s.ms || 0) : 0;
|
||||||
}
|
}
|
||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
onstatus?.(summary());
|
onstatus?.(summary());
|
||||||
@@ -77,9 +87,10 @@
|
|||||||
|
|
||||||
function cellAt(e) {
|
function cellAt(e) {
|
||||||
const rect = gridEl.getBoundingClientRect();
|
const rect = gridEl.getBoundingClientRect();
|
||||||
const cw = rect.width / n;
|
const pad = 7; // grid padding (6) + border (1)
|
||||||
const c = Math.min(n - 1, Math.max(0, Math.floor((e.clientX - rect.left) / cw)));
|
const cw = (rect.width - 2 * pad) / n; // even 1fr columns share the inner width
|
||||||
const r = Math.min(n - 1, Math.max(0, Math.floor((e.clientY - rect.top) / cw)));
|
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];
|
return [r, c];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +155,7 @@
|
|||||||
<p class="theme"><span class="lbl">Today’s theme</span>{theme}</p>
|
<p class="theme"><span class="lbl">Today’s theme</span>{theme}</p>
|
||||||
|
|
||||||
<div class="grid" class:ok={okFlash} class:done={status === 'done'} bind:this={gridEl} style="--n:{n}"
|
<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}>
|
onpointerdown={down} onpointermove={move} onpointerup={up} onpointercancel={up}>
|
||||||
{#each grid as rowStr, r (r)}
|
{#each grid as rowStr, r (r)}
|
||||||
{#each rowStr.split('') as ch, c (c)}
|
{#each rowStr.split('') as ch, c (c)}
|
||||||
@@ -180,24 +192,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<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; }
|
.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 { 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;
|
.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; }
|
font-family: var(--label); color: var(--muted); margin-bottom: 2px; }
|
||||||
.grid {
|
.grid {
|
||||||
/* Constant cell size — the board grows with the grid; on a narrow phone the
|
/* Cells are ~32px on a wide screen (board grows with the grid) and shrink via
|
||||||
biggest grid gently shrinks to fit the width. */
|
1fr to fit a narrow phone — capped by max-width so it can never overflow. */
|
||||||
--cell: min(32px, calc((100vw - 44px) / var(--n)));
|
display: grid; grid-template-columns: repeat(var(--n), 1fr); gap: 2px;
|
||||||
display: grid; grid-template-columns: repeat(var(--n), var(--cell)); gap: 2px;
|
width: 100%; max-width: calc(var(--n) * 32px + 16px); margin: 0 auto 16px;
|
||||||
width: fit-content; max-width: 100%; margin: 0 auto 16px; touch-action: none;
|
touch-action: none; user-select: none; -webkit-user-select: none;
|
||||||
user-select: none; -webkit-user-select: none; border-radius: 10px; padding: 6px;
|
border-radius: 10px; padding: 6px; background: var(--surface); border: 1px solid var(--line);
|
||||||
background: var(--surface); border: 1px solid var(--line);
|
|
||||||
}
|
}
|
||||||
.grid.done { opacity: 0.9; }
|
.grid.done { opacity: 0.9; }
|
||||||
.cell {
|
.cell {
|
||||||
width: var(--cell); height: var(--cell); display: flex; align-items: center; justify-content: center;
|
aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
|
||||||
font-family: var(--label); font-weight: 600; font-size: calc(var(--cell) * 0.5);
|
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;
|
color: var(--ink); border-radius: 5px; background: transparent; text-transform: uppercase;
|
||||||
}
|
}
|
||||||
.cell.sel { background: var(--accent) !important; color: #fff !important; }
|
.cell.sel { background: var(--accent) !important; color: #fff !important; }
|
||||||
|
|||||||
@@ -140,13 +140,10 @@
|
|||||||
box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease;
|
box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease;
|
||||||
}
|
}
|
||||||
.gamecard:hover { border-color: var(--accent); transform: translateY(-1px); }
|
.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-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-body h2 { font-size: 1.2rem; margin: 0 0 3px; }
|
||||||
.gc-sub { color: var(--muted); font-size: 0.86rem; margin: 0 0 8px; }
|
.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; }
|
.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; }
|
.variant { display: flex; gap: 10px; justify-content: center; margin: 0 0 22px; }
|
||||||
.vchip {
|
.vchip {
|
||||||
|
|||||||
+5
-2
@@ -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
|
# stored theme+word list, sampling a different subset per size so each is its own
|
||||||
# puzzle, while all three share the day's theme.
|
# 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_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
|
# Curated fallbacks — calm and neutral everyday scenes, not just upbeat. ~22 words
|
||||||
# each (4–8 letters, uppercase) so every size has a fresh subset to draw from.
|
# each (4–8 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:"):
|
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.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]
|
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
|
return theme, words
|
||||||
except Exception: # noqa: BLE001 — fall back to a curated theme
|
except Exception: # noqa: BLE001 — fall back to a curated theme
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -336,3 +336,28 @@ def test_wordsearch_endpoint(tmp_path, monkeypatch):
|
|||||||
assert len(themes) == 1 # all sizes share the day's one theme
|
assert len(themes) == 1 # all sizes share the day's one theme
|
||||||
# an unknown size falls back to med
|
# an unknown size falls back to med
|
||||||
assert tc.get("/api/puzzle/wordsearch?variant=nope").json()["size"] == "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
|
||||||
|
|||||||
Reference in New Issue
Block a user