59ff48ae90
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>
485 lines
24 KiB
Svelte
485 lines
24 KiB
Svelte
<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>
|