Files
upbeatBytes/frontend/src/lib/components/WordSearchGame.svelte
T
thejayman77 98441fae15 Extract + unit-test the padding-aware cell geometry (Codex nice-to-have)
Pulled the pointer→cell math out of cellAt() into a pure cellFromPoint(rect, x,
y, n) in $lib/wordsearch.js (only getBoundingClientRect stays in the component),
and covered it with vitest — including the last-column case that was drifting
under the old overflowing layout, plus clamping and a scrolled-origin rect.
11 vitest tests now; real-device testing remains the final validator.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 21:43:28 -04:00

228 lines
9.8 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { getJSON } from '$lib/api.js';
import { lineFrom, matchWord, cellFromPoint } from '$lib/wordsearch.js';
let { size = 'med', onstatus } = $props();
// A calm, distinct palette — each found word gets its own colour.
const PALETTE = ['#a7d3c2', '#f4cf88', '#a9c8ec', '#eab0c0', '#cdb8e6',
'#f3a98a', '#c2d99a', '#e3b0d0', '#9fd0d8', '#d9c79a'];
let theme = $state('');
let words = $state([]);
let grid = $state([]); // array of row strings
let date = $state('');
let foundWords = $state([]); // {word, cells:[[r,c]], ci}
let sel = $state([]); // current selection cells
let selecting = false;
let startTime = 0;
let resultMs = $state(0);
let best = $state(0);
let loading = $state(true);
let ready = $state(false);
let okFlash = $state(false);
let copied = $state(false);
let gridEl = $state(null);
let loadSeq = 0;
const n = $derived(grid.length);
const stateKey = $derived(`goodnews:wordsearch:${size}:${date}`);
const bestKey = $derived(`goodnews:wordsearch:best:${size}`);
const found = $derived(foundWords.map((w) => w.word));
const status = $derived(words.length && foundWords.length === words.length ? 'done' : 'playing');
const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c)));
const cellColor = $derived.by(() => {
const m = new Map();
for (const w of foundWords) for (const [r, c] of w.cells) m.set(r + ',' + c, w.ci);
return m;
});
const wordColor = $derived.by(() => {
const m = new Map();
for (const w of foundWords) m.set(w.word, w.ci);
return m;
});
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));
}
function restore() {
try {
const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
if (s && Array.isArray(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 = valid.length === words.length ? (s.ms || 0) : 0;
}
} catch { /* ignore */ }
onstatus?.(summary());
}
function persist() {
try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, startTime, ms: resultMs, status })); }
catch { /* ignore */ }
onstatus?.(summary());
}
function summary() { return { game: 'wordsearch', size, date, status, found: foundWords.length, total: words.length, ms: resultMs }; }
function cellAt(e) {
return cellFromPoint(gridEl.getBoundingClientRect(), e.clientX, e.clientY, n);
}
function down(e) {
if (status === 'done') return;
selecting = true;
if (!startTime) startTime = Date.now();
sel = [cellAt(e)];
gridEl.setPointerCapture?.(e.pointerId);
e.preventDefault();
}
function move(e) {
if (!selecting) return;
sel = lineFrom(sel[0], cellAt(e), n);
}
function up() {
if (!selecting) return;
selecting = false;
evaluate(sel);
sel = [];
}
function evaluate(cells) {
const hit = matchWord(cells, grid, words, found);
if (!hit) return;
foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }];
okFlash = true; setTimeout(() => (okFlash = false), 500);
if (foundWords.length === words.length) finish();
persist();
}
function finish() {
resultMs = startTime ? Date.now() - startTime : 0;
if (resultMs && (!best || resultMs < best)) {
best = resultMs;
try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ }
}
}
function fmt(ms) {
const s = Math.round(ms / 1000);
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
function share() {
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\nupbeatbytes.com/play`;
if (navigator.share) navigator.share({ text }).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
}
// Load on mount and whenever the size changes. Tracks ONLY `size`.
$effect(() => { size; load(); });
</script>
<div class="wordsearch" class:ready>
{#if loading}
<p class="muted">Loading todays word search…</p>
{:else if !words.length}
<p class="muted">Could not load todays word search.</p>
{:else}
<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)}
{@const key = r + ',' + c}
<div class="cell" class:sel={selSet.has(key)}
style={cellColor.has(key) && !selSet.has(key) ? `background:${PALETTE[cellColor.get(key)]};color:#2a2f36` : ''}>{ch}</div>
{/each}
{/each}
</div>
<div class="palette">
<p class="plabel">Find these · {foundWords.length}/{words.length}</p>
<ul class="words">
{#each words as w (w)}
<li class:got={found.includes(w)}
style={wordColor.has(w) ? `background:${PALETTE[wordColor.get(w)]};color:#2a2f36` : ''}>{w}</li>
{/each}
</ul>
</div>
{#if status === 'done'}
<div class="result rise">
<p class="rmark">✦ all found ✦</p>
<div class="times">
<div><span class="n">{fmt(resultMs)}</span><span class="l">your time</span></div>
{#if best}<div><span class="n">{fmt(best)}</span><span class="l">best</span></div>{/if}
</div>
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
</div>
{:else}
<p class="hint">{foundWords.length} / {words.length} found · drag across the letters</p>
{/if}
{/if}
</div>
<style>
.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 {
/* 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 {
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; }
.palette { background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
padding: 12px 14px 14px; margin: 0 auto 16px; max-width: 440px; box-shadow: var(--shadow); }
.plabel { text-transform: uppercase; letter-spacing: 0.12em; font-size: 0.62rem; font-family: var(--label);
color: var(--muted); text-align: center; margin: 0 0 10px; }
.words { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; padding: 0; margin: 0; }
.words li { font-family: var(--label); font-size: 0.82rem; letter-spacing: 0.04em; color: var(--ink);
padding: 4px 11px; border-radius: 999px; background: var(--accent-soft); transition: background 0.2s ease; }
.hint { text-align: center; color: var(--muted); font-size: 0.84rem; margin: 0; }
.result { text-align: center; }
.rmark { font-family: var(--serif); font-style: italic; color: var(--accent-deep); font-size: 1.2rem; margin: 0 0 12px; }
.times { display: flex; justify-content: center; gap: 26px; margin: 0 0 16px; }
.times .n { display: block; font-size: 1.5rem; font-weight: 700; font-family: var(--label); }
.times .l { color: var(--muted); font-size: 0.78rem; }
.share { background: var(--accent); color: #fff; border: none; border-radius: 999px;
padding: 11px 26px; font: inherit; font-weight: 600; cursor: pointer; }
.share:hover { background: var(--accent-deep); }
.muted { color: var(--muted); text-align: center; }
</style>