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:
@@ -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 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);
|
||||
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 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>
|
||||
@@ -0,0 +1,256 @@
|
||||
<script>
|
||||
import { untrack } from 'svelte';
|
||||
import { pushGameState } from '$lib/gamesync.js';
|
||||
import { COLOR_BY_KEY } from '$lib/games/match/palette.js';
|
||||
import { buildBoard, freeSeed } from '$lib/games/match/board.js';
|
||||
import MatchIcon from './MatchIcon.svelte';
|
||||
|
||||
// mode: 'daily' (shared, synced, feeds the ritual) | 'free' (local-only, replayable)
|
||||
// format: 'icons' | 'colors' · tier: 'gentle' | 'standard' | 'expert'
|
||||
let { mode = 'daily', format = 'icons', tier = 'standard', date = '', onstatus } = $props();
|
||||
|
||||
let isFree = $derived(mode === 'free');
|
||||
let variant = $derived(`${tier}-${format}`);
|
||||
// Storage: daily keyed by the server date (cross-device synced); free keeps one
|
||||
// resumable scratch board per variant.
|
||||
let stateKey = $derived(isFree ? `goodnews:match:free:${variant}` : `goodnews:match:${variant}:${date}`);
|
||||
const ASSIST_KEY = 'goodnews:match:assist';
|
||||
|
||||
let board = $state(null);
|
||||
let seed = $state('');
|
||||
let flipped = $state([]); // card ids face-up, not yet resolved
|
||||
let matchedKeys = $state(new Set());
|
||||
let moves = $state(0);
|
||||
let locked = $state(false); // brief hold while a mismatch is shown
|
||||
let ready = $state(false);
|
||||
let assist = $state(false); // colorblind assist: show a tiny letter on swatches
|
||||
let celebrated = $state(false);
|
||||
|
||||
let matchedIds = $derived(board ? new Set(board.cards.filter((c) => matchedKeys.has(c.key)).map((c) => c.id)) : new Set());
|
||||
let done = $derived(!!board && matchedKeys.size >= board.faces.length);
|
||||
|
||||
function read(key) { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } }
|
||||
function write(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch { /* ignore */ } }
|
||||
|
||||
function emitStatus() {
|
||||
onstatus?.({ count: matchedKeys.size, total: board?.faces.length ?? 0, moves, done });
|
||||
}
|
||||
|
||||
function persist() {
|
||||
const snap = { matched: [...matchedKeys], moves, done, seed: isFree ? seed : undefined };
|
||||
write(stateKey, snap);
|
||||
emitStatus();
|
||||
// Daily resumes across devices; free play stays local.
|
||||
if (!isFree && date) pushGameState('match', variant, date, { matched: snap.matched, moves, done });
|
||||
}
|
||||
|
||||
let loadSeq = 0;
|
||||
|
||||
async function load(forceNew = false) {
|
||||
const myLoad = ++loadSeq;
|
||||
ready = false;
|
||||
// A pending mismatch flip-back must not fire onto a freshly switched board.
|
||||
if (flipTimer) { clearTimeout(flipTimer); flipTimer = null; }
|
||||
try { assist = localStorage.getItem(ASSIST_KEY) === '1'; } catch { /* ignore */ }
|
||||
|
||||
const useFree = isFree;
|
||||
let saved, theSeed;
|
||||
if (useFree) {
|
||||
saved = forceNew ? null : read(stateKey);
|
||||
// a free board derives a stable seed so a reload resumes the same layout
|
||||
theSeed = saved?.seed || freeSeed((Date.now() ^ Math.floor(Math.random() * 1e9)) >>> 0);
|
||||
} else {
|
||||
theSeed = date;
|
||||
saved = read(stateKey);
|
||||
// pull/merge the server's copy first (cross-device resume)
|
||||
if (date) {
|
||||
const merged = await pushGameState('match', variant, date, {
|
||||
matched: saved?.matched || [], moves: saved?.moves || 0, done: !!saved?.done,
|
||||
});
|
||||
if (myLoad !== loadSeq) return; // a newer board load started — drop this stale one
|
||||
if (merged) saved = merged;
|
||||
}
|
||||
}
|
||||
if (myLoad !== loadSeq) return;
|
||||
|
||||
// Commit the new board + restored progress atomically AFTER any await, so a rapid
|
||||
// daily/free, icon/color, or tier switch can't hydrate an old response onto the
|
||||
// newer board.
|
||||
const b = buildBoard({ format, tier, seed: theSeed });
|
||||
const valid = new Set(b.faces);
|
||||
board = b;
|
||||
seed = theSeed;
|
||||
flipped = []; locked = false;
|
||||
matchedKeys = new Set((saved?.matched || []).filter((k) => valid.has(k)));
|
||||
moves = Number.isFinite(saved?.moves) ? saved.moves : 0;
|
||||
celebrated = matchedKeys.size >= b.faces.length; // don't replay the celebration on resume
|
||||
// Persist immediately so a freshly-generated free board's seed survives a reload
|
||||
// even before the first move.
|
||||
write(stateKey, { matched: [...matchedKeys], moves, done: matchedKeys.size >= b.faces.length,
|
||||
seed: useFree ? theSeed : undefined });
|
||||
ready = true;
|
||||
emitStatus();
|
||||
}
|
||||
|
||||
function reset() { if (isFree) load(true); }
|
||||
|
||||
function toggleAssist() {
|
||||
assist = !assist;
|
||||
try { localStorage.setItem(ASSIST_KEY, assist ? '1' : '0'); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
let flipTimer = null;
|
||||
|
||||
function flip(card) {
|
||||
if (!ready || locked || done) return;
|
||||
if (matchedKeys.has(card.key) || flipped.includes(card.id)) return;
|
||||
|
||||
// Match off the LOCAL `next`, not the just-assigned reactive `flipped`, so the
|
||||
// evaluation never runs a click behind.
|
||||
const next = [...flipped, card.id];
|
||||
flipped = next;
|
||||
if (next.length < board.matchN) return; // Expert needs all 3 before judging
|
||||
|
||||
moves += 1;
|
||||
const keys = next.map((id) => board.cards[id].key);
|
||||
const allSame = keys.every((k) => k === keys[0]);
|
||||
if (allSame) {
|
||||
matchedKeys = new Set([...matchedKeys, keys[0]]);
|
||||
flipped = [];
|
||||
persist();
|
||||
if (matchedKeys.size >= board.faces.length && !celebrated) celebrated = true;
|
||||
} else {
|
||||
locked = true; // show the mismatch briefly, then flip back
|
||||
persist();
|
||||
flipTimer = setTimeout(() => { flipped = []; locked = false; flipTimer = null; }, 850);
|
||||
}
|
||||
}
|
||||
|
||||
function faceLabel(card) {
|
||||
if (format === 'colors') return COLOR_BY_KEY[card.key]?.name ?? 'color';
|
||||
return card.key;
|
||||
}
|
||||
|
||||
// Reload whenever the board identity (mode/format/tier/date, all encoded in
|
||||
// stateKey) changes. untrack() keeps the effect from tracking load()'s internal
|
||||
// state reads (board/matchedKeys/moves/done via emitStatus) — which load() also
|
||||
// writes — so a synchronous free-play load can't self-retrigger into a loop.
|
||||
$effect(() => {
|
||||
stateKey;
|
||||
untrack(() => load());
|
||||
return () => { if (flipTimer) clearTimeout(flipTimer); };
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="match">
|
||||
<div class="topbar">
|
||||
<div class="meta">
|
||||
{#if board}
|
||||
<span class="tier">{board.label}{format === 'colors' ? ' · Color' : ''}</span>
|
||||
<span class="prog">{matchedKeys.size}/{board.faces.length}{moves ? ` · ${moves} moves` : ''}</span>
|
||||
<span class="rule" class:tri={board.matchN === 3}>{#if board.matchN === 3 && flipped.length > 0 && flipped.length < 3}Pick 3 · {flipped.length}/3{:else}{board.matchN === 3 ? 'Match 3 of a kind' : 'Match pairs'}{/if}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="controls">
|
||||
{#if format === 'colors'}
|
||||
<button class="ctl" aria-pressed={assist} onclick={toggleAssist}>Assist {assist ? 'on' : 'off'}</button>
|
||||
{/if}
|
||||
{#if isFree}
|
||||
<button class="ctl" onclick={reset}>New board</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if board}
|
||||
<div class="grid" class:locked
|
||||
style="grid-template-columns: repeat({board.cols}, 1fr); max-width: {board.cols * 76}px;">
|
||||
{#each board.cards as card (card.id)}
|
||||
{@const up = matchedIds.has(card.id) || flipped.includes(card.id)}
|
||||
{@const matched = matchedIds.has(card.id)}
|
||||
<button class="card" class:up class:matched
|
||||
aria-label={up ? faceLabel(card) : 'card, face down'}
|
||||
disabled={matched}
|
||||
onclick={() => flip(card)}>
|
||||
<span class="inner">
|
||||
<span class="back" aria-hidden="true"></span>
|
||||
<span class="front" class:colored={format === 'colors'} aria-hidden="true">
|
||||
{#if format === 'colors'}
|
||||
<span class="colorfill" style="--c: {COLOR_BY_KEY[card.key]?.hex}">
|
||||
{#if assist}<span class="assist">{COLOR_BY_KEY[card.key]?.assist}</span>{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<MatchIcon name={card.key} />
|
||||
{/if}
|
||||
{#if matched}<span class="check" aria-hidden="true">✓</span>{/if}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if done && celebrated}
|
||||
<p class="done">Lovely — you cleared {isFree ? 'the board' : "today's set"}.{isFree ? '' : ' Fresh one tomorrow.'}</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.match { display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
||||
.topbar { width: 100%; max-width: 460px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
|
||||
.meta { display: flex; flex-direction: column; gap: 1px; }
|
||||
.tier { font-family: var(--label); font-weight: 600; }
|
||||
.prog { color: var(--muted); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
|
||||
.rule { font-size: 0.8rem; color: var(--muted); margin-top: 2px; }
|
||||
/* Expert's 3-of-a-kind rule is the easy-to-forget one — give it a clear chip. */
|
||||
.rule.tri { align-self: flex-start; color: var(--accent-deep); background: var(--accent-soft);
|
||||
padding: 1px 9px; border-radius: 999px; font-weight: 600; }
|
||||
.controls { display: flex; gap: 8px; }
|
||||
.ctl { font-size: 0.8rem; padding: 5px 11px; border: 1px solid var(--line); border-radius: 9px;
|
||||
background: var(--surface); color: var(--accent-deep); cursor: pointer; }
|
||||
|
||||
.grid { display: grid; gap: 8px; width: 100%; }
|
||||
.card { aspect-ratio: 1; min-width: 0; padding: 0; border: none; background: none; cursor: pointer;
|
||||
perspective: 600px; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
|
||||
.card[disabled] { cursor: default; }
|
||||
.inner { position: relative; display: block; width: 100%; height: 100%;
|
||||
transition: transform 0.32s ease; transform-style: preserve-3d; }
|
||||
.card.up .inner { transform: rotateY(180deg); }
|
||||
.back, .front { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
|
||||
border-radius: 13px; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
|
||||
/* Face-down: a branded card back — azure gradient under a faint dot motif. */
|
||||
.back { background:
|
||||
radial-gradient(circle at 50% 50%, rgba(255,255,255,0.18) 1.2px, transparent 1.6px) 0 0 / 11px 11px,
|
||||
linear-gradient(150deg, var(--accent) 0%, var(--accent-deep) 100%);
|
||||
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18); }
|
||||
.front { background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
|
||||
transform: rotateY(180deg); }
|
||||
.front.colored { background: none; border: none; } /* the colorfill is the whole face */
|
||||
/* Icon front-and-center: ~70% of the card (≈50% area) so the symbol is memorable. */
|
||||
.front :global(svg) { width: 70%; height: 70%; }
|
||||
.colorfill { position: absolute; inset: 0; border-radius: 13px; background: var(--c);
|
||||
border: 2px solid #3a3a3a; }
|
||||
.assist { position: absolute; top: 5px; right: 6px; font-family: var(--label); font-weight: 700;
|
||||
font-size: 0.72rem; color: #fff; background: rgba(0, 0, 0, 0.45); padding: 1px 5px; border-radius: 6px; }
|
||||
|
||||
/* State hierarchy ----------------------------------------------------------
|
||||
Inspecting (flipped, unmatched): vivid + lifted, so the unknown cards pop. */
|
||||
.card.up:not(.matched) .front:not(.colored) { box-shadow: 0 2px 8px rgba(0,0,0,0.12); }
|
||||
.card.up:not(.matched) .colorfill { box-shadow: 0 2px 8px rgba(0,0,0,0.20); }
|
||||
/* Matched: a settled "paint-chip" tile — receded, desaturated, with a quiet check,
|
||||
so the last few unknown cards stay obvious instead of blending in. */
|
||||
.card.matched .inner { transform: rotateY(180deg) scale(0.9); }
|
||||
.card.matched .front { box-shadow: none; }
|
||||
.card.matched .front.colored { background: var(--surface); border: 1px solid var(--line); }
|
||||
.card.matched .colorfill { inset: 5px; border-radius: 8px; border: 1px solid rgba(0,0,0,0.22);
|
||||
filter: saturate(0.6) brightness(1.06); box-shadow: none; }
|
||||
.card.matched :global(svg) { opacity: 0.42; }
|
||||
.card.matched .assist { display: none; }
|
||||
.check { position: absolute; bottom: 4px; right: 6px; font-size: 0.82rem; line-height: 1;
|
||||
color: var(--accent-deep); font-weight: 700; }
|
||||
|
||||
.done { margin: 4px 0 0; color: var(--accent-deep); font-family: var(--label); text-align: center; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.inner { transition: none; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script>
|
||||
import { ICONS } from '$lib/games/match/icons.js';
|
||||
let { name, size = 34 } = $props();
|
||||
</script>
|
||||
|
||||
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
{@html ICONS[name] ?? ''}
|
||||
</svg>
|
||||
Reference in New Issue
Block a user