Files
upbeatBytes/frontend/src/lib/components/BloomGame.svelte
T
thejayman77 59ff48ae90 Game share-loop: instrument funnel, deep-link shares, /play metadata
Sharpen the existing daily-game share loop into something measurable (per Codex's
"instrument what you have, then feed people into it" plan), ahead of a Show HN launch.

Analytics:
- Per-game funnel events <game>_{arrival,started,completed,shared} (article_id=0).
  arrival = landed via a shared link (utm_source=game_share); started = first move
  (guess/find/flip); completed = solved/cleared/Full Bloom; shared = on share success.
- trackVisit() moved into the global layout so direct /play landings count; the
  server-rendered /a/ share page now creates a visitor token + sends a daily visit
  beacon (first-time /a/-only visitors were previously dropped).
- Admin "Games funnel" panel: arrivals / engaged / completed / shared, per game.

Sharing:
- Memory Match gains a Share button (it was the only game without one).
- All shares deep-link to the exact game+variant with a full https:// URL +
  utm_source=game_share (gameShareUrl helper), instead of a bare /play.
- "shared" is counted only after navigator.share()/clipboard.writeText() succeeds.

/play social metadata:
- /play served homepage canonical/OG (static SPA, ssr=false). postbuild script
  patches build/play.html's head to /play canonical/title/description/OG; fails the
  build if the homepage tags drift. Caddy try_files now serves {path}.html so /play
  is served from the patched file (snapshot in deploy/caddy/).

Tests: backend 352, frontend 27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:22:06 -04:00

485 lines
24 KiB
Svelte
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script>
import { getJSON, postJSON } from '$lib/api.js';
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
import { trackGame, gameShareUrl } from '$lib/analytics.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);
if (found.length === 0) trackGame('bloom', 'started'); // first word = started
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(); trackGame('bloom', 'completed'); // Full Bloom
}
}
// 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 bloomV = mode === 'daily' ? 'daily' : (format === 'wild' ? 'free-wild' : 'free-center');
const text = `Upbeat Bytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\n${gameShareUrl('bloom', bloomV)}`;
if (navigator.share) navigator.share({ text }).then(() => trackGame('bloom', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { trackGame('bloom', 'shared'); 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>