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
+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}