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 {
+45 -33
View File
@@ -158,42 +158,52 @@ def adjudicate_word_guess(conn: sqlite3.Connection, date: str, variant: str, gue
_DIRS = [(-1, -1), (-1, 0), (-1, 1), (0, -1), (0, 1), (1, -1), (1, 0), (1, 1)]
# Size tiers: bigger grid → more words → a longer sit. Built per-request from one
# 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 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
# Size tiers. The three sizes draw DISJOINT word slices from the day's pool, so
# each is its own fresh puzzle (no repeats across sizes). small+med+large counts
# sum to WS_NEEDED, the minimum unique words a theme must supply.
WS_TIERS = {"small": {"grid": 8, "count": 6}, "med": {"grid": 11, "count": 9}, "large": {"grid": 14, "count": 13}}
_WS_ORDER = ["small", "med", "large"]
WS_NEEDED = sum(t["count"] for t in WS_TIERS.values()) # 28 unique words across the three
WS_TARGET = 32 # words to ask the LLM for
# Accept an LLM proposal only if it supplies enough UNIQUE words for all three
# disjoint puzzles; otherwise fall back to a curated theme (which always has enough).
WS_MIN_ACCEPT = WS_NEEDED
# Curated fallbacks — calm and neutral everyday scenes, not just upbeat. ~22 words
# each (48 letters, uppercase) so every size has a fresh subset to draw from.
# Curated fallbacks — calm, neutral everyday scenes (48 letters, uppercase). Each
# has >= WS_NEEDED words so the three sizes get fully distinct sets.
_WS_FALLBACKS = [
("Around the house", ["TABLE", "CHAIR", "CLOCK", "SHELF", "COUCH", "PILLOW", "WINDOW", "CARPET",
"MIRROR", "CANDLE", "KETTLE", "DRAWER", "CLOSET", "CURTAIN", "CUSHION",
"BASKET", "BOTTLE", "TOWEL", "BROOM", "LADDER", "STAIRS", "PANTRY", "BLANKET"]),
("At the beach", ["WAVES", "SHELL", "SANDY", "TIDE", "SHORE", "TOWEL", "BREEZE", "SUNSET", "PEBBLE",
"CORAL", "OCEAN", "SAILS", "SURF", "SEAGULL", "BUCKET", "SPADE", "DUNES", "LAGOON",
"DRIFT", "SALTY", "SUNNY", "HORIZON", "COVE", "PIER"]),
("In the kitchen", ["BREAD", "SPOON", "PLATE", "KETTLE", "FLOUR", "APRON", "WHISK", "SUGAR", "BUTTER",
"RECIPE", "SIMMER", "PANTRY", "TEAPOT", "SAUCER", "LADLE", "KNIFE", "BOWL", "GRATER",
"SKILLET", "PEPPER", "GARLIC", "HONEY", "TOAST"]),
("In the garden", ["BLOOM", "PETAL", "ROOTS", "LEAF", "GARDEN", "FLOWER", "SUNNY", "SEEDS", "MEADOW",
"SPROUT", "HEDGE", "TROWEL", "VINES", "SOIL", "SHRUB", "BUDS", "STALK", "DAISY",
"TULIP", "FERNS", "SHOVEL", "BRANCH", "BREEZE"]),
("Around the house", ["TABLE", "CHAIR", "CLOCK", "SHELF", "COUCH", "PILLOW", "WINDOW", "CARPET", "MIRROR",
"CANDLE", "KETTLE", "DRAWER", "CLOSET", "CURTAIN", "CUSHION", "BASKET", "BOTTLE",
"TOWEL", "BROOM", "LADDER", "STAIRS", "PANTRY", "BLANKET", "VASE", "HALLWAY",
"DOORWAY", "MANTEL", "HAMPER", "GARAGE", "ATTIC"]),
("At the beach", ["WAVES", "SHELL", "SANDY", "TIDE", "SHORE", "TOWEL", "BREEZE", "SUNSET", "PEBBLE", "CORAL",
"OCEAN", "SAILS", "SURF", "SEAGULL", "BUCKET", "SPADE", "DUNES", "LAGOON", "DRIFT", "SALTY",
"SUNNY", "HORIZON", "COVE", "PIER", "SEAWEED", "FLIPPER", "PADDLE", "MARINA", "BREAKER",
"SANDAL"]),
("In the kitchen", ["BREAD", "SPOON", "PLATE", "KETTLE", "FLOUR", "APRON", "WHISK", "SUGAR", "BUTTER", "RECIPE",
"SIMMER", "PANTRY", "TEAPOT", "SAUCER", "LADLE", "KNIFE", "BOWL", "GRATER", "SKILLET",
"PEPPER", "GARLIC", "HONEY", "TOAST", "BATTER", "SPATULA", "COLANDER", "MIXER", "GRIDDLE",
"PITCHER", "NAPKIN"]),
("In the garden", ["BLOOM", "PETAL", "ROOTS", "LEAF", "GARDEN", "FLOWER", "SUNNY", "SEEDS", "MEADOW", "SPROUT",
"HEDGE", "TROWEL", "VINES", "SOIL", "SHRUB", "BUDS", "STALK", "DAISY", "TULIP", "FERNS",
"SHOVEL", "BRANCH", "BREEZE", "PEONY", "MOSS", "COMPOST", "BLOSSOM", "TENDRIL", "NECTAR",
"WATER"]),
("A walk outdoors", ["TRAIL", "MEADOW", "BROOK", "BIRDS", "BREEZE", "PEBBLE", "FOREST", "MAPLE", "ACORN",
"STREAM", "BRANCH", "VALLEY", "PATH", "HILLS", "RIVER", "FIELD", "CLOUDS", "LEAVES",
"MOSSY", "TWIGS", "FENCE", "BENCH", "RAMBLE"]),
"MOSSY", "TWIGS", "FENCE", "BENCH", "RAMBLE", "BOULDER", "THICKET", "CLEARING",
"PASTURE", "ORCHARD", "HOLLOW", "SUNSET"]),
("Making music", ["PIANO", "DRUMS", "CHOIR", "MELODY", "GUITAR", "VIOLIN", "SINGER", "BALLAD", "RHYTHM",
"ENCORE", "TEMPO", "NOTES", "SONG", "FLUTE", "CELLO", "BRASS", "CHORD", "STRUM",
"HARMONY", "LYRICS", "BANJO", "ANTHEM", "TUNES"]),
("Quiet calm", ["PEACE", "QUIET", "STILL", "SERENE", "REST", "SOOTHE", "GENTLE", "BREATHE", "CALM",
"HUSH", "DRIFT", "EASE", "DREAM", "RELAX", "MELLOW", "STEADY", "SETTLE", "LINGER",
"PAUSE", "SLOW", "SOFT", "WARM"]),
"ENCORE", "TEMPO", "NOTES", "SONG", "FLUTE", "CELLO", "BRASS", "CHORD", "STRUM", "HARMONY",
"LYRICS", "BANJO", "ANTHEM", "TUNES", "TRUMPET", "ORGAN", "OCTAVE", "CONCERT", "SONATA",
"TREBLE", "MELODIC"]),
("Quiet calm", ["PEACE", "QUIET", "STILL", "SERENE", "REST", "SOOTHE", "GENTLE", "BREATHE", "CALM", "HUSH",
"DRIFT", "EASE", "DREAM", "RELAX", "MELLOW", "STEADY", "SETTLE", "LINGER", "PAUSE", "SLOW",
"SOFT", "WARM", "SILENCE", "REPOSE", "PLACID", "TRANQUIL", "QUIETLY", "UNWIND", "COZY",
"DROWSY"]),
("Small joys", ["SMILE", "LAUGH", "CHEER", "HAPPY", "MERRY", "DANCE", "DELIGHT", "GLOW", "PLAY", "GRIN",
"BEAM", "GLEE", "WARM", "GIGGLE", "SHARE", "TREAT", "SUNNY", "SWEET", "LUCKY", "CHARM",
"SPARK", "BLISS"]),
"SPARK", "BLISS", "WONDER", "FROLIC", "CHUCKLE", "CHEERS", "JOYFUL", "SPARKLE", "TWINKLE",
"HUGS"]),
]
@@ -285,10 +295,12 @@ def wordsearch_response(conn: sqlite3.Connection, date: str, size: str = "med")
size = "med"
p = generate_wordsearch_puzzle(conn, date) # on-demand (curated fallback) if missing
tier = WS_TIERS[size]
usable = [w for w in p["words"] if len(w) <= tier["grid"]]
# Sample a fresh subset per size so each tier is its own puzzle (seeded → stable).
rng = random.Random(_seed(date, "wordsearch", size, "pick"))
chosen = rng.sample(usable, min(tier["count"], len(usable))) if usable else []
# Shuffle the day's words once (date-seeded → same order for every size) and hand
# each size a DISJOINT slice, so the three sizes are entirely distinct puzzles.
words = list(p["words"])
random.Random(_seed(date, "wordsearch", "shuffle")).shuffle(words)
start = sum(WS_TIERS[s]["count"] for s in _WS_ORDER[:_WS_ORDER.index(size)])
chosen = words[start:start + tier["count"]]
grid, placed = _build_grid(chosen, tier["grid"], _seed(date, "wordsearch", size))
return {"game": "wordsearch", "date": date, "size": size, "theme": p["theme"],
"words": placed, "grid": grid}
+8 -3
View File
@@ -325,15 +325,19 @@ def test_wordsearch_endpoint(tmp_path, monkeypatch):
return True
return False
themes, sizes = set(), {"small": 8, "med": 11, "large": 14}
themes, sizes, per_size = set(), {"small": 8, "med": 11, "large": 14}, {}
for tier, dim in sizes.items():
r = tc.get(f"/api/puzzle/wordsearch?variant={tier}").json()
assert r["game"] == "wordsearch" and r["theme"] and r["size"] == tier
assert len(r["grid"]) == dim and all(len(row) == dim for row in r["grid"]) # bigger tier → bigger grid
assert "placements" not in r # solution cells never sent
assert all(findable(r["grid"], dim, w) for w in r["words"]) # every word placed → solvable
themes.add(r["theme"])
themes.add(r["theme"]); per_size[tier] = set(r["words"])
assert len(themes) == 1 # all sizes share the day's one theme
# the three sizes are DISJOINT — each day is three distinct puzzles
assert per_size["small"] & per_size["med"] == set()
assert per_size["small"] & per_size["large"] == set()
assert per_size["med"] & per_size["large"] == set()
# an unknown size falls back to med
assert tc.get("/api/puzzle/wordsearch?variant=nope").json()["size"] == "med"
@@ -358,6 +362,7 @@ def test_wordsearch_thin_llm_falls_back(tmp_path, monkeypatch):
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)))
rich_words = [chr(65 + i) * 5 for i in range(26)] + ["ABCDE", "FGHIJ", "KLMNO", "PQRST"] # 30 distinct
rich = FakeClient("THEME: Rich Theme\nWORDS: " + ", ".join(rich_words))
p2 = games.generate_wordsearch_puzzle(c, "2026-07-02", client=rich)
assert p2["theme"] == "Rich Theme" and len(p2["words"]) >= games.WS_MIN_ACCEPT