Sync repo to deployed state: SEO recovery, Publishing Desk, Play games, emoji picker

The deploy pipeline runs from the working tree, so a wave of shipped features
had never been committed. This snapshots git to what's actually running.

SEO impression recovery (live + verified):
- Duplicate /a/{id} now 301-redirect to their canonical twin instead of 404
  (a hard 404 silently dropped already-indexed URLs and tanked impressions).
- Dedup representative selection reworked: accepted/serveable -> established
  rep (URL stability) -> quality score, so an accepted page never retires to a
  rejected rep and an indexed canonical doesn't churn when a newer twin arrives.
- HEAD /a/{id} returns the same status as GET (api_route GET+HEAD) instead of
  falling through to the static mount and 404ing.
- `dedup --force-recluster`: cycle-locked, model-free re-cluster to re-apply the
  policy to the existing corpus (shared cycle_lock context manager).
- CLI honors GOODNEWS_DB for its default --db (was silently ignored).

Publishing Desk (admin tool to post highlights to X via Web Intents):
- publishing.py queue/rank/handle-resolution; admin UI; full searchable emoji
  picker (bundled data, no CDN) for the blurb editor.

Play games + site:
- Bloom (word-wheel), Memory Match, daily ritual set, Zen Den (dev-gated).
- English-only language gate; source prospecting; paywall + dedup hardening.

Tests: full suite green (349). Ignores tightened (node_modules, data/*.db).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-18 11:32:27 -04:00
parent 2dbe73430c
commit 89c0fbe1f6
66 changed files with 6138 additions and 109 deletions
@@ -0,0 +1,481 @@
<script>
import { getJSON, postJSON } from '$lib/api.js';
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
// mode: 'daily' (shared, synced, ritual) | 'free' (local-only, infinite wheels)
// format: 'center' (center letter required) | 'wild' (any word from the 7)
let { mode = 'daily', format = 'center', onstatus } = $props();
// $derived (not const) so a prop change can't leave the mode "stuck".
let isFree = $derived(mode === 'free');
let isWild = $derived(format === 'wild');
let center = $state('');
let outer = $state([]); // Center Circle: 6 petals (order shuffles)
let wildRing = $state([]); // Wild Bloom: all 7 letters, equal (order shuffles)
let accepted = $state(new Set()); // sha256(salt:word) hex — no plaintext answers
let maxScore = $state(0);
let tiers = $state([]);
let date = $state(''); // daily mode
let seed = $state(''); // free mode (lets us resume the same wheel)
let found = $state([]); // plaintext words this device has found
let current = $state(''); // the word being built
let loading = $state(true);
let ready = $state(false);
let message = $state('');
let shake = $state(false);
let pulse = $state(false); // bloom flourish on a pangram
let fullShown = $state(false); // Full Bloom celebration latch
let reportWord = $state(''); // a rejected real-looking word offered to flag
let reported = $state(false); // "thanks" after flagging
let salt = $derived(isFree ? seed : date); // the hash salt + storage discriminator
const stateKey = $derived(isFree ? `goodnews:bloom:free:${format}` : `goodnews:bloom:${date}`);
// The active letter set matches what's actually on screen (Wild ring vs center+petals).
let activeLetters = $derived(isWild ? wildRing : [center, ...outer]);
let wheel = $derived(new Set(activeLetters));
// The flower's center: azure (Center Circle = "required") vs slate (Wild =
// "just a letter"); both bloom to jewel hues as you find words.
let centerColor = $derived(found.length ? bloomColor(found.length) : (isWild ? '#5b6b78' : 'var(--accent)'));
function scoreWord(w) {
let s = w.length === 4 ? 1 : w.length;
if (isPangram(w)) s += 7;
return s;
}
function isPangram(w) {
return w.length >= 7 && [...wheel].every((l) => w.includes(l));
}
// Curated calm jewel hues (all readable under the white letter). The flower's
// center blooms a fresh hue with each found word; each found chip keeps its own.
const PALETTE = ['#0083ad', '#117a8b', '#2e8b57', '#b8732e', '#c0563f', '#bb4a63',
'#c2569b', '#8e5bb0', '#6a5fc4', '#4f6fc6', '#b14a8a', '#2f8f8f'];
function bloomColor(n) { return n ? PALETTE[(n * 5) % PALETTE.length] : 'var(--accent)'; }
function hueFor(w) {
let h = 0;
for (const c of w) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return PALETTE[h % PALETTE.length];
}
let score = $derived(found.reduce((s, w) => s + scoreWord(w), 0));
let tierIdx = $derived.by(() => { let idx = 0; tiers.forEach((t, i) => { if (score >= t.score) idx = i; }); return idx; });
let tier = $derived(tiers[tierIdx] || { name: '', score: 0 });
let nextTier = $derived(tiers[tierIdx + 1] || null);
// The ring fills toward the NEXT goal — the next tier, or (at the top tier)
// Full Bloom — so a full ring never falsely implies "every word found."
let progress = $derived.by(() => {
const start = tier.score;
const target = nextTier ? nextTier.score : maxScore;
return target > start ? Math.min(1, (score - start) / (target - start)) : 1;
});
let fullBloom = $derived(maxScore > 0 && score >= maxScore);
// Reached the top tier (Flourishing) — the daily's "saw it through" point;
// persisted so the calm-set ritual can read it without the puzzle payload.
let reachedTop = $derived(score >= (tiers.find((t) => t.name === 'Flourishing')?.score ?? Infinity));
async function sha256hex(str) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
function readStored() {
try { return JSON.parse(localStorage.getItem(stateKey) || 'null'); } catch { return null; }
}
async function load(forceNew = false) {
loading = true; ready = false;
found = []; current = ''; message = ''; fullShown = false;
try {
let p, savedFound = [];
if (isFree) {
const stored = forceNew ? null : readStored(); // resume the same wheel by its seed
const q = stored?.seed ? `&seed=${encodeURIComponent(stored.seed)}` : '';
p = await getJSON(`/api/puzzle/bloom/free?format=${format}${q}`);
seed = p.seed;
if (stored && stored.seed === p.seed && Array.isArray(stored.found)) savedFound = stored.found;
} else {
p = await getJSON('/api/puzzle/bloom'); // holds NO plaintext words
date = p.date;
const stored = readStored();
if (stored && Array.isArray(stored.found)) savedFound = stored.found;
}
center = p.center; outer = p.outer;
if (isWild) wildRing = [center, ...outer]; // 7 equal petals — no required center
accepted = new Set(p.accepted); maxScore = p.max_score; tiers = p.tiers;
await restore(savedFound);
} catch {
message = isFree ? 'Could not load a wheel.' : 'Could not load todays Bloom.';
}
loading = false;
requestAnimationFrame(() => (ready = true));
if (!isFree) syncNow(); // free play is local-only — never syncs
}
function newWheel() { if (!loading) load(true); } // free: deal a fresh wheel
async function restore(savedFound) {
// Keep only finds valid for THIS wheel (drops stale/junk / wrong-seed words).
const valid = [];
for (const w of (savedFound || [])) {
if (typeof w === 'string' && accepted.has(await sha256hex(`${salt}:${w}`))) valid.push(w);
}
found = valid;
persist();
if (!isFree) onstatus?.(summary());
}
function persist() {
// `full` lets the hub card show "Full Bloom" after reload; `seed` lets free
// play resume the same wheel.
const data = isFree ? { seed, found, score, full: fullBloom, top: reachedTop }
: { found, score, full: fullBloom, top: reachedTop };
try { localStorage.setItem(stateKey, JSON.stringify(data)); } catch { /* ignore */ }
if (!isFree) onstatus?.(summary()); // only the daily feeds the hub status
}
function summary() {
return { date, count: found.length, tier: tier.name, full: fullBloom };
}
// --- cross-device sync (signed-in; union of found words merged server-side) ---
let serverStats = $state(null);
let syncTimer;
async function adopt(merged) {
if (!merged || !Array.isArray(merged.found)) return;
// The server state is authoritative: it has already merged this device's push
// with any other device AND sanitized against today's accept set, so we adopt
// it wholesale — which also removes any local words the server dropped.
const valid = [];
for (const w of merged.found) {
if (typeof w === 'string' && accepted.has(await sha256hex(`${date}:${w}`))) valid.push(w);
}
found = valid;
persist();
}
async function syncNow() {
if (isFree) return; // free play is local-only
const d = date;
const merged = await pushGameState('bloom', '', d, { found, score });
if (d === date) await adopt(merged);
serverStats = await fetchGameStats('bloom', '');
}
function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1000); }
function flash(m) { message = m; setTimeout(() => (message = ''), 1300); }
function shakeIt() { shake = true; setTimeout(() => (shake = false), 400); }
function bloomPulse() { pulse = true; setTimeout(() => (pulse = false), 700); }
function tap(l) { if (!loading) current += l; }
// Touch/mouse act on pointerdown (instant, and preventDefault stops focus-scroll).
// There is deliberately NO onclick — a click handler alongside pointerdown
// double-enters. Keyboard users type directly (global handler) or can Tab to a
// petal and press Enter/Space (onkeydown) — neither collides with pointer events.
function petalDown(e, l) { e.preventDefault(); tap(l); }
function petalKey(e, l) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tap(l); } }
function del() { current = current.slice(0, -1); }
// Fisher-Yates, retried until it actually feels shuffled (≥minMoved positions
// change) — sort(()=>Math.random()-0.5) is biased and often leaves letters put.
function shuffled(arr, minMoved) {
let out = arr;
for (let t = 0; t < 12; t++) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
out = a;
if (a.filter((l, i) => l !== arr[i]).length >= minMoved) break;
}
return out;
}
function shuffle() {
if (isWild) wildRing = shuffled(wildRing, 4);
else outer = shuffled(outer, 4);
}
async function submit() {
const w = current.trim().toLowerCase();
current = '';
reportWord = ''; reported = false;
if (!w) return;
if (w.length < 4) { flash('Too short'); shakeIt(); return; }
if (!isWild && !w.includes(center)) { flash('Needs the center letter'); shakeIt(); return; }
if ([...w].some((l) => !wheel.has(l))) { flash('Letters not in the bloom'); shakeIt(); return; }
if (found.includes(w)) { flash('Already found'); shakeIt(); return; }
const h = await sha256hex(`${salt}:${w}`);
if (!accepted.has(h)) {
flash('Not in the word list'); shakeIt();
reportWord = w; // it's a well-formed wheel word — offer to flag it
setTimeout(() => { if (reportWord === w) reportWord = ''; }, 7000);
return;
}
const pan = isPangram(w);
found = [w, ...found];
flash(pan ? 'Pangram! 🌸 +' + scoreWord(w) : '+' + scoreWord(w));
if (pan) bloomPulse();
persist();
if (!isFree) syncSoon();
if (found.reduce((s, x) => s + scoreWord(x), 0) >= maxScore && !fullShown) {
fullShown = true; bloomPulse();
}
}
// Spoiler-safe share: achievement + a word-length breakdown, never the words.
let copied = $state(false);
function share() {
const byLen = {};
found.forEach((w) => (byLen[w.length] = (byLen[w.length] || 0) + 1));
const breakdown = Object.keys(byLen).sort((a, b) => b - a).map((l) => `${l}×${byLen[l]}`).join(' ');
const pang = found.some(isPangram) ? ' · pangram ✓' : '';
const text = `Upbeat Bytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\nupbeatbytes.com/play`;
if (navigator.share) navigator.share({ text }).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); });
}
// Quiet "this should count?" — flags a rejected word for the admin queue.
async function reportMissing() {
const w = reportWord;
if (!w) return;
reported = true;
try {
await postJSON('/api/bloom/report', {
word: w, date: isFree ? null : date, mode, format,
letters: [center, ...outer].join(''), reason: 'not in the word list',
});
} catch { /* best-effort */ }
setTimeout(() => { if (reportWord === w) { reportWord = ''; reported = false; } }, 2600);
}
function onKeydown(e) {
if (e.metaKey || e.ctrlKey || e.altKey || loading) return;
const k = e.key.toLowerCase();
if (k === 'enter') { e.preventDefault(); submit(); }
else if (k === 'backspace') { e.preventDefault(); del(); }
else if (/^[a-z]$/.test(k) && wheel.has(k)) { e.preventDefault(); tap(k); }
}
$effect(() => { load(); });
</script>
<svelte:window onkeydown={onKeydown} />
<div class="bloomgame" class:ready>
{#if loading}
<p class="muted">Loading todays Bloom…</p>
{:else}
{#if isFree}<p class="freecap">Free Play · {isWild ? 'Wild Bloom' : 'Center Circle'}</p>{/if}
<!-- progress: tier name + ring toward the next goal (total stays hidden) -->
<div class="meter">
<div class="ring" style="--p:{progress}">
<span class="rscore">{found.length}</span>
</div>
<div class="tierline">
<span class="tname" class:full={fullBloom}>{fullBloom ? 'Full Bloom 🌸' : tier.name}</span>
<span class="tsub">{found.length} {found.length === 1 ? 'word' : 'words'}</span>
</div>
{#if isFree}
<button class="share top" onclick={newWheel}>New wheel</button>
{:else}
<button class="share top" onclick={share}>{copied ? 'Copied!' : 'Share'}</button>
{/if}
</div>
<!-- Fixed-height entry + feedback rows so the bloom never reflows. -->
<div class="entry" class:shake>
{#if current}
{#each current.split('') as ch, i (i)}
<span class="ec" class:cen={!isWild && ch === center}>{ch.toUpperCase()}</span>
{/each}
{:else}
<span class="ph">Type or tap letters</span>
{/if}
</div>
<!-- One fixed-height feedback slot (flash OR the "should count?" flag) so the
bloom never shifts whether or not a message/report is showing. -->
<div class="feedback">
{#if reportWord}
{#if reported}
<span class="thanks">Thanks — flagged for review 🌱</span>
{:else}
<button class="reportbtn" onclick={reportMissing}>{reportWord} should count?</button>
{/if}
{:else}
<span class="flash" class:show={!!message} class:pan={message.includes('Pangram')}>{message || ' '}</span>
{/if}
</div>
<!-- Petals: pointerdown for instant touch + no focus-scroll. Center Circle =
1 azure center letter + 6 petals. Wild = 7 equal petals ringing a small
decorative bloom dot (letterless, non-tappable — never a required letter). -->
<div class="bloom" class:pulse class:wild={isWild}>
{#if isWild}
<span class="bloomcenter" style="background: {bloomColor(found.length)}" aria-hidden="true"></span>
{#each wildRing as letter, i (letter)}
<button class="petal" style="--a:{i * (360 / 7)}deg"
onpointerdown={(e) => petalDown(e, letter)} onkeydown={(e) => petalKey(e, letter)}>{letter.toUpperCase()}</button>
{/each}
{:else}
<button class="petal center" style="background: {centerColor}"
onpointerdown={(e) => petalDown(e, center)} onkeydown={(e) => petalKey(e, center)}>{center.toUpperCase()}</button>
{#each outer as letter, i (letter)}
<button class="petal" style="--a:{i * 60}deg"
onpointerdown={(e) => petalDown(e, letter)} onkeydown={(e) => petalKey(e, letter)}>{letter.toUpperCase()}</button>
{/each}
{/if}
</div>
<!-- Controls use click so they stay keyboard-activatable (Shuffle especially);
touch-action keeps them snappy on phones. -->
<div class="controls">
<button class="ctl" onclick={del} aria-label="Delete">Delete</button>
<button class="ctl round" onclick={shuffle} aria-label="Shuffle letters">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"/><path d="M4 20 21 3"/><path d="M21 16v5h-5"/><path d="M15 15l6 6"/><path d="M4 4l5 5"/></svg>
</button>
<button class="ctl enter" onclick={submit}>Enter</button>
</div>
{#if fullShown}
<p class="fullmsg rise">🌸 Full Bloom — you found today's whole bloom. Lovely.</p>
{/if}
{#if found.length}
<div class="found">
{#each found.slice().sort() as w (w)}
<span class="chip" class:pan={isPangram(w)} style="background: {hueFor(w)}">{w}</span>
{/each}
</div>
{/if}
{/if}
</div>
<style>
.bloomgame { max-width: 480px; margin: 0 auto; opacity: 0; transform: translateY(6px);
display: flex; flex-direction: column; align-items: center; }
.bloomgame.ready { opacity: 1; transform: none; transition: opacity 0.3s ease, transform 0.3s ease; }
.muted { color: var(--muted); text-align: center; }
/* Everything above the found-list holds its height (flex-shrink:0) so the
height-constrained mobile column can't compress/redistribute it — only the
found-list flexes. This is what was nudging the bloom on mobile. */
.meter, .entry, .feedback, .controls, .fullmsg, .freecap { flex-shrink: 0; }
.freecap { margin: 0 0 6px; font-family: var(--label); font-size: 0.72rem; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--muted); font-weight: 600; }
.meter { display: flex; align-items: center; gap: 12px; width: 100%; max-width: 360px; margin: 2px 0 14px; }
.ring {
width: 46px; height: 46px; border-radius: 50%; flex-shrink: 0;
background: conic-gradient(var(--accent) calc(var(--p) * 360deg), var(--line) 0);
display: grid; place-items: center; transition: background 0.4s ease;
}
.ring .rscore { width: 36px; height: 36px; border-radius: 50%; background: var(--surface);
display: grid; place-items: center; font-family: var(--label); font-weight: 700;
font-size: 0.95rem; color: var(--accent-deep); }
.tierline { display: flex; flex-direction: column; line-height: 1.15; margin-right: auto; }
.tname { font-family: var(--serif); font-size: 1.2rem; color: var(--accent-deep); }
.tname.full { color: #c2569b; }
.tsub { color: var(--muted); font-size: 0.8rem; }
.share.top { background: none; border: 1px solid var(--line); color: var(--accent-deep);
border-radius: 999px; padding: 6px 16px; font: inherit; font-size: 0.85rem; cursor: pointer; }
.share.top:hover { border-color: var(--accent); }
/* Fixed heights → the bloom below never shifts as you type or messages appear. */
.entry { height: 46px; display: flex; align-items: center; justify-content: center; gap: 3px; }
.entry.shake { animation: shake 0.4s ease; }
.ec { font-family: var(--label); font-weight: 700; font-size: 1.8rem; letter-spacing: 0.04em; color: var(--ink); }
.ec.cen { color: var(--accent); }
.ph { color: var(--muted); font-style: italic; font-size: 1rem; }
.feedback { height: 26px; margin: 2px 0 4px; display: flex; align-items: center; justify-content: center; }
.flash { font-family: var(--label); font-size: 0.86rem; color: var(--accent-deep);
white-space: nowrap; opacity: 0; transition: opacity 0.15s ease; }
.flash.show { opacity: 1; }
.flash.pan { color: #c2569b; font-weight: 700; }
.reportbtn { background: none; border: none; cursor: pointer; font-family: var(--label);
font-size: 0.84rem; color: var(--accent-deep); text-decoration: underline;
text-underline-offset: 3px; padding: 0; }
.reportbtn:hover { color: var(--accent); }
.thanks { font-family: var(--label); font-size: 0.84rem; color: #2e8b57; }
/* The bloom: a flower — a larger azure center surrounded by 6 evenly-spaced
circular petals. Generous gaps, no overlap (our identity, not a honeycomb). */
.bloom { position: relative; width: 300px; height: 300px; margin: 2px 0 10px; --r: 100px; flex-shrink: 0; }
.bloom.pulse { animation: bloompulse 0.7s ease; }
.petal {
position: absolute; left: 50%; top: 50%; --a: 0deg;
transform: translate(-50%, -50%) rotate(var(--a)) translateY(calc(-1 * var(--r))) rotate(calc(-1 * var(--a)));
width: 84px; height: 84px; border-radius: 50%; border: none; cursor: pointer; z-index: 1;
background: var(--surface); color: var(--accent-deep);
font-family: var(--label); font-weight: 700; font-size: 1.7rem; text-transform: uppercase;
box-shadow: inset 0 0 0 1px var(--line), 0 1px 5px rgba(60, 50, 30, 0.07);
transition: transform 0.45s cubic-bezier(.2, .8, .2, 1), background 0.12s ease, filter 0.1s ease;
touch-action: manipulation; -webkit-tap-highlight-color: transparent; user-select: none;
}
.petal.center {
z-index: 2; width: 96px; height: 96px; transform: translate(-50%, -50%);
background: var(--accent); color: #fff; box-shadow: 0 4px 14px rgba(60, 50, 30, 0.24);
/* gentle hue morph as the flower blooms (background set inline per word) */
transition: background 0.6s ease, transform 0.18s ease;
}
.petal:hover { filter: brightness(0.97); }
.petal:not(.center):active {
transform: translate(-50%, -50%) rotate(var(--a)) translateY(calc(-1 * var(--r))) rotate(calc(-1 * var(--a))) scale(0.92);
}
.petal.center:active { transform: translate(-50%, -50%) scale(0.93); }
/* Wild Bloom: 7 equal petals in an open ring around a small decorative bloom
dot (letterless, non-tappable — never reads as a required letter). */
.bloom.wild { --r: 106px; }
.bloom.wild .petal { width: 76px; height: 76px; font-size: 1.6rem; }
/* The whole center circle IS the bloom — a petal-sized disc that shifts hue
with each found word (letterless + non-tappable, so never a required letter). */
.bloomcenter {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: 76px; height: 76px; border-radius: 50%; pointer-events: none; z-index: 1;
/* Ring sits OUTSIDE the fill (box-shadow, not a border) so the color reaches
the very edge — no white gap. No inset highlight (that was the white sliver). */
box-shadow: 0 0 0 2px rgba(20, 20, 20, 0.65), 0 1px 6px rgba(60, 50, 30, 0.12);
transition: background 0.6s ease;
}
.controls { display: flex; align-items: center; gap: 12px; margin: 0 0 22px; }
.ctl {
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
border-radius: 999px; padding: 11px 22px; font: inherit; font-weight: 600; cursor: pointer;
box-shadow: 0 2px 0 rgba(120, 108, 84, 0.18);
touch-action: manipulation; -webkit-tap-highlight-color: transparent; user-select: none;
}
.ctl:active { transform: translateY(2px); box-shadow: none; }
.ctl.round { padding: 0; width: 46px; height: 46px; display: grid; place-items: center; }
.ctl.round svg { width: 22px; height: 22px; }
.ctl.enter { background: var(--accent); border-color: var(--accent); color: #fff;
box-shadow: 0 2px 0 var(--accent-deep); }
.ctl.enter:hover { background: var(--accent-deep); }
.fullmsg { text-align: center; color: #c2569b; font-family: var(--serif); font-style: italic;
font-size: 1.05rem; margin: 0 0 12px; }
.found { display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; max-width: 440px; }
/* Each found word keeps its own hue — a little garden of finds (bg set inline). */
.chip { border: none; border-radius: 999px; padding: 5px 13px; font-size: 0.9rem; color: #fff;
text-transform: capitalize; box-shadow: 0 1px 3px rgba(40, 38, 28, 0.12);
transition: background 0.5s ease; }
.chip.pan { font-weight: 700; box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.75), 0 1px 4px rgba(40, 38, 28, 0.18); }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-6px); } 75% { transform: translateX(6px); } }
@keyframes bloompulse { 0% { transform: scale(1); } 40% { transform: scale(1.04); } 100% { transform: scale(1); } }
@media (prefers-reduced-motion: reduce) { .entry, .bloom, .petal { animation: none !important; transition: none !important; } }
@media (max-width: 720px) {
/* /play locks the viewport (overflow:hidden), so the found list gets its own
scroll region — the bloom + controls stay put, words scroll under them. */
.bloomgame { height: 100%; max-width: 100%; }
.bloom { width: 280px; height: 280px; --r: 92px; }
.petal { width: 76px; height: 76px; font-size: 1.6rem; }
.petal.center { width: 88px; height: 88px; }
.bloom.wild { --r: 90px; }
.bloom.wild .petal { width: 64px; height: 64px; font-size: 1.45rem; }
.bloom.wild .bloomcenter { width: 64px; height: 64px; }
.found { flex: 1 1 auto; min-height: 0; width: 100%; overflow-y: auto;
align-content: flex-start; padding-bottom: calc(env(safe-area-inset-bottom) + 8px);
scrollbar-width: none; }
.found::-webkit-scrollbar { display: none; }
}
</style>