<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 today’s 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 today’s 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>
