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 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">Today’s 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; }
|
||||
|
||||
@@ -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
@@ -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 (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:"):
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user