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