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:
@@ -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 today’s word search.</p>
|
||||
{:else}
|
||||
<p class="theme"><span class="lbl">Today’s 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>
|
||||
|
||||
@@ -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>Today’s 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
@@ -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}
|
||||
|
||||
+8
-3
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user