Word Search mobile: focused viewport, theme placement, unique-per-size words

Per field feedback.
* Each day is now THREE distinct puzzles: the three sizes draw DISJOINT word
  slices from a date-shuffled pool (small/med/large = 6/9/13, sum 28 unique).
  Curated fallback themes expanded to 30 words each; LLM proposals accepted only
  if they supply >= 28 unique words, else fall back. No more repeats across sizes.
* Word Search is now a focused game screen on mobile (same as Daily Word): body
  scroll locked + footer hidden (generalized .playing-game), and the grid sizes
  to the largest square that fits between the theme and the palette (container
  query) — the whole puzzle is on screen, no page scroll.
* Theme placement: full "Today's theme · <name>" on the size-selection screen;
  just the theme name on the puzzle itself, saving vertical space for Large.
* cosy → cozy. 🇺🇸

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-11 09:15:06 -04:00
parent 1dda91fd96
commit 52a8bc5326
5 changed files with 101 additions and 57 deletions
+2 -2
View File
@@ -71,6 +71,6 @@ button { font-family: inherit; cursor: pointer; }
the page's scroll). The .playing-word class is toggled by /play and always
removed on navigation via effect cleanup. Mobile only — desktop is unaffected. */
@media (max-width: 720px) {
html.playing-word, html.playing-word body { overflow: hidden; }
html.playing-word footer.site { display: none; }
html.playing-game, html.playing-game body { overflow: hidden; }
html.playing-game footer.site { display: none; }
}
@@ -147,18 +147,20 @@
{: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>
<p class="theme">{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>
<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}
{/each}
</div>
</div>
<div class="palette">
@@ -189,9 +191,8 @@
<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; }
.theme { text-align: center; font-family: var(--serif); font-size: 1.4rem; color: var(--accent-deep); margin: 0 0 12px; }
.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. */
@@ -224,4 +225,21 @@
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.2rem; 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>
+15 -6
View File
@@ -101,24 +101,27 @@
}
});
// Daily Word on mobile = a focused viewport: lock scroll + hide footer. Cleanup
// Any puzzle on mobile = a focused viewport: lock scroll + hide footer. Cleanup
// ALWAYS removes the class (re-run or unmount), so leaving /play can't strand it.
$effect(() => {
if (typeof document === 'undefined') return;
if (view === 'play' && game === 'word') {
document.documentElement.classList.add('playing-word');
return () => document.documentElement.classList.remove('playing-word');
if (view === 'play') {
document.documentElement.classList.add('playing-game');
return () => document.documentElement.classList.remove('playing-game');
}
});
const WS_OPTS = [
['small', 'Small', 'cosy · 8×8'],
['small', 'Small', 'cozy · 8×8'],
['med', 'Medium', 'balanced · 11×11'],
['large', 'Large', 'a longer sit · 14×14'],
];
let wsTheme = $state('');
onMount(async () => {
try { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ }
try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ }
refreshStatus();
});
// Refresh hub/selection statuses whenever we land on a screen (incl. Back).
@@ -140,7 +143,7 @@
</div>
</header>
<main class="container page" class:gameview={view === 'play' && game === 'word'}>
<main class="container page" class:gameview={view === 'play'}>
{#if view === 'hub'}
<h1>Play</h1>
<p class="sub">A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.</p>
@@ -165,6 +168,9 @@
{:else if view === 'select'}
<h1 class="seltitle">{game === 'word' ? 'Daily Word' : 'Word Search'}</h1>
{#if game === 'wordsearch' && wsTheme}
<p class="wstheme"><span>Todays theme</span>{wsTheme}</p>
{/if}
<p class="sub">{game === 'word' ? 'Pick your length.' : 'Pick your size.'}</p>
<div class="opts">
{#if game === 'word'}
@@ -206,6 +212,9 @@
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 6px; }
.seltitle { font-size: clamp(1.7rem, 4.5vw, 2.2rem); }
.sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; }
.wstheme { font-family: var(--serif); font-size: 1.6rem; color: var(--accent-deep); margin: 4px 0 18px; }
.wstheme span { display: block; text-transform: uppercase; letter-spacing: 0.1em; font-size: 0.66rem;
font-family: var(--label); color: var(--muted); margin-bottom: 3px; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
.gamecard {