Files
upbeatBytes/frontend/src/lib/components/WordSearchGame.svelte
T
thejayman77 39d682f353 Polish: plain unfound word-search chips + no signed-out flash on refresh
- Word Search: unfound words were tinted (accent-soft background) like found
  ones, so the remaining words were hard to spot as the board filled. Unfound
  chips are now plain (transparent + a light outline); found words keep their
  grid colour. Easy to see what's left.
- Auth: a refresh briefly flashed the signed-out header until /api/auth/me
  returned. Now the last-known user is cached and hydrated immediately, so the
  signed-in UI paints at once; the session is still revalidated every load (a
  stale/expired one corrects within a beat) and the cache is cleared on logout.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 12:21:43 -04:00

258 lines
11 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">{theme}</p>
<div class="gridwrap">
<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>
<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-weight: 600; font-size: 1.6rem;
color: var(--accent-deep); margin: 0 0 14px; }
.gridwrap { display: flex; justify-content: center; }
.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.7rem, 2.6vw, 1.05rem);
color: var(--ink); border-radius: 5px; background: transparent; text-transform: uppercase;
}
.cell.sel { background: var(--accent) !important; color: #fff !important; }
/* Scale each letter to ~42% of its cell so the letter-to-spacing ratio is uniform
across Small/Med/Large (Large is the reference). Guarded so older browsers keep
the clamp() fallback above. */
@supports (width: 1cqw) {
.grid { container-type: inline-size; }
.cell { font-size: calc(100cqw / var(--n) * 0.42); }
}
.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; }
/* Unfound words are plain (transparent + outline) so it's easy to see what's
left as the board fills; found words get their grid colour via inline style. */
.words li { font-family: var(--label); font-size: 0.82rem; letter-spacing: 0.04em; color: var(--ink);
padding: 4px 11px; border-radius: 999px; background: transparent;
border: 1px solid var(--line); transition: background 0.2s ease, border-color 0.2s ease; }
.words li.got { border-color: transparent; }
.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; }
/* Mobile: a focused game screen (matches Daily Word). The grid sizes to the
largest square that fits the space left between the theme and the palette,
so the whole puzzle is on screen with no page scroll. */
@media (max-width: 720px) {
.wordsearch { display: flex; flex-direction: column; height: 100%; max-width: 100%; }
.theme { flex-shrink: 0; font-size: 1.5rem; margin: 4px 0 8px; }
.gridwrap { flex: 1; min-height: 0; container-type: size; align-items: center; padding: 2px 0; }
.grid { width: min(100%, 100cqh, calc(var(--n) * 40px)); max-width: none; margin: 0; }
.palette { flex-shrink: 0; margin: 10px auto 0; padding: 9px 12px 10px; max-width: 100%;
width: 100%; box-sizing: border-box; }
.plabel { margin-bottom: 7px; }
.words { gap: 6px; }
.words li { font-size: 0.78rem; padding: 3px 9px; }
.hint { flex-shrink: 0; padding: 9px 0 calc(env(safe-area-inset-bottom) + 6px); }
.result { flex-shrink: 0; padding-bottom: calc(env(safe-area-inset-bottom) + 6px); }
}
</style>