From 52a8bc5326755317829631899f2c2eff6881b59a Mon Sep 17 00:00:00 2001 From: jay Date: Thu, 11 Jun 2026 09:15:06 -0400 Subject: [PATCH] Word Search mobile: focused viewport, theme placement, unique-per-size words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 · " 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) --- frontend/src/app.css | 4 +- .../src/lib/components/WordSearchGame.svelte | 44 +++++++---- frontend/src/routes/play/+page.svelte | 21 +++-- goodnews/games.py | 78 +++++++++++-------- tests/test_admin.py | 11 ++- 5 files changed, 101 insertions(+), 57 deletions(-) diff --git a/frontend/src/app.css b/frontend/src/app.css index 889f800..f0a7896 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; } } diff --git a/frontend/src/lib/components/WordSearchGame.svelte b/frontend/src/lib/components/WordSearchGame.svelte index 2599773..d62feae 100644 --- a/frontend/src/lib/components/WordSearchGame.svelte +++ b/frontend/src/lib/components/WordSearchGame.svelte @@ -147,18 +147,20 @@ {:else if !words.length}

Could not load today’s word search.

{:else} -

Today’s theme{theme}

+

{theme}

-
- {#each grid as rowStr, r (r)} - {#each rowStr.split('') as ch, c (c)} - {@const key = r + ',' + c} -
{ch}
+
+
+ {#each grid as rowStr, r (r)} + {#each rowStr.split('') as ch, c (c)} + {@const key = r + ',' + c} +
{ch}
+ {/each} {/each} - {/each} +
@@ -189,9 +191,8 @@ diff --git a/frontend/src/routes/play/+page.svelte b/frontend/src/routes/play/+page.svelte index b9d5888..1ef0a16 100644 --- a/frontend/src/routes/play/+page.svelte +++ b/frontend/src/routes/play/+page.svelte @@ -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 @@
-
+
{#if view === 'hub'}

Play

A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.

@@ -165,6 +168,9 @@ {:else if view === 'select'}

{game === 'word' ? 'Daily Word' : 'Word Search'}

+ {#if game === 'wordsearch' && wsTheme} +

Today’s theme{wsTheme}

+ {/if}

{game === 'word' ? 'Pick your length.' : 'Pick your size.'}

{#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 { diff --git a/goodnews/games.py b/goodnews/games.py index 16937a4..5b467d9 100644 --- a/goodnews/games.py +++ b/goodnews/games.py @@ -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 (4–8 letters, uppercase) so every size has a fresh subset to draw from. +# Curated fallbacks — calm, neutral everyday scenes (4–8 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} diff --git a/tests/test_admin.py b/tests/test_admin.py index 91bc036..2127698 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -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