Files
upbeatBytes/frontend/src/routes/play/+page.svelte
T
thejayman77 89c0fbe1f6 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>
2026-06-18 11:32:27 -04:00

518 lines
26 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
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 { onMount } from 'svelte';
import { goto, afterNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { getJSON } from '$lib/api.js';
import { pushGameStatesBatch } from '$lib/gamesync.js';
import { ritualState } from '$lib/ritual.js';
import { prefs, initPrefs } from '$lib/prefs.svelte.js';
import { auth } from '$lib/auth.svelte.js';
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
import WordGame from '$lib/components/WordGame.svelte';
import WordSearchGame from '$lib/components/WordSearchGame.svelte';
import BloomGame from '$lib/components/BloomGame.svelte';
import MatchGame from '$lib/components/MatchGame.svelte';
// Screen is derived from the URL so the device/browser Back button steps through
// Hub → Game Selection → Game (each screen is its own history entry), instead of
// jumping straight out of /play.
let sp = $derived($page.url.searchParams);
let game = $derived(['wordsearch', 'bloom', 'match'].includes(sp.get('game')) ? sp.get('game') : 'word');
let view = $derived(!sp.get('game') ? 'hub' : (sp.get('v') ? 'play' : 'select'));
// Bloom v: 'daily' (shared Center Circle) | 'free-center' | 'free-wild'.
let bloomMode = $derived(sp.get('v') === 'daily' ? 'daily' : 'free');
let bloomFormat = $derived(sp.get('v') === 'free-wild' ? 'wild' : 'center');
// Hide an in-dev game's card from non-admins, and bounce them off its route.
let bloomBlocked = $derived(blockedForViewer('bloom', auth.user, $page.url));
let bloomStatus = $state(null);
let variant = $derived(['5', '6'].includes(sp.get('v')) ? sp.get('v') : '5');
let wsSize = $derived(['small', 'med', 'large'].includes(sp.get('v')) ? sp.get('v') : 'med');
// Memory Match. v = "<mode>-<format>-<tier>" e.g. daily-icons-standard / free-colors-expert.
let matchBlocked = $derived(blockedForViewer('match', auth.user, $page.url));
let matchParts = $derived((sp.get('v') || '').split('-'));
let matchMode = $derived(matchParts[0] === 'free' ? 'free' : 'daily');
let matchFormat = $derived(matchParts[1] === 'colors' ? 'colors' : 'icons');
let matchTier = $derived(['gentle', 'standard', 'expert'].includes(matchParts[2]) ? matchParts[2] : 'standard');
let matchStatus = $state(null);
let matchFmt = $state('icons'); // format toggle on the Match selection screen
let date = $state('');
let wordStatus = $state({ 5: null, 6: null });
let wsStatus = $state(null);
// Daily Ritual ("today's calm set") — Brief · Daily Word · Word Search, keyed
// on the server puzzle date; the Brief tick is set on the home end-cap, read here.
let ritual = $state({ items: [], count: 0, total: 0 });
function readWord(v) {
try {
const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null');
const tries = (s?.guesses || []).length;
// Surface in-progress too (so "continue on another device" shows on the card),
// not just finished games.
if (s && (s.status === 'won' || s.status === 'lost' || (s.status === 'playing' && tries > 0))) {
return { status: s.status, tries, max: v === '6' ? 7 : 6 };
}
} catch { /* ignore */ }
return null;
}
function readWsSize(sz) {
try {
const s = JSON.parse(localStorage.getItem(`goodnews:wordsearch:${sz}:${date}`) || 'null');
if (s && Array.isArray(s.foundWords)) return { status: s.status, found: s.foundWords.length, ms: s.ms };
} catch { /* ignore */ }
return null;
}
function readBloom() {
try {
const s = JSON.parse(localStorage.getItem(`goodnews:bloom:${date}`) || 'null');
if (s && Array.isArray(s.found)) return { count: s.found.length, full: !!s.full };
} catch { /* ignore */ }
return null;
}
// All daily Match boards (any tier × format) for the day.
const MATCH_TIERS = [
['gentle', 'Gentle', '4×3 · 6 pairs'],
['standard', 'Standard', '4×4 · 8 pairs'],
['expert', 'Expert', '6×4 · match 3 of a kind'],
];
const MATCH_VARIANTS = MATCH_TIERS.flatMap(([t]) => ['icons', 'colors'].map((f) => `${t}-${f}`));
const MATCH_VS = ['daily', 'free'].flatMap((m) =>
['icons', 'colors'].flatMap((f) => MATCH_TIERS.map(([t]) => `${m}-${f}-${t}`)));
function readMatchVariant(v) {
try { return JSON.parse(localStorage.getItem(`goodnews:match:${v}:${date}`) || 'null'); } catch { return null; }
}
function readMatch() {
let best = null;
for (const v of MATCH_VARIANTS) {
const s = readMatchVariant(v);
if (!s) continue;
if (s.done) return { done: true };
const count = (s.matched || []).length;
if (count > 0 && (!best || count > best.count)) best = { done: false, count };
}
return best;
}
function refreshStatus() {
wordStatus = { 5: readWord('5'), 6: readWord('6') };
let ws = null;
for (const sz of ['small', 'med', 'large']) {
const s = readWsSize(sz);
if (s && s.status === 'done') { ws = s; break; }
if (s && s.found > 0 && !ws) ws = s;
}
wsStatus = ws;
bloomStatus = readBloom();
matchStatus = readMatch();
if (date) ritual = ritualState(date, prefs.data.ritual);
}
function fmtMs(ms) {
const s = Math.round(ms / 1000);
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}
// The hub itself reconciles every game with the server (signed-in), so cards
// show cross-device status WITHOUT having to open each game first, and this
// device's local progress gets uploaded even for games it hasn't reopened.
// Every daily board the hub surfaces, as [game, variant, localStorage key]. Match
// includes all tier×format variants so cross-device progress shows without opening
// the game.
function gameSpecs() {
return [
['word', '5', `goodnews:word:5:${date}`],
['word', '6', `goodnews:word:6:${date}`],
['wordsearch', 'small', `goodnews:wordsearch:small:${date}`],
['wordsearch', 'med', `goodnews:wordsearch:med:${date}`],
['wordsearch', 'large', `goodnews:wordsearch:large:${date}`],
['bloom', '', `goodnews:bloom:${date}`],
...MATCH_VARIANTS.map((v) => ['match', v, `goodnews:match:${v}:${date}`]),
];
}
// One batch request reconciles ALL boards (instead of a dozen calls on every /play
// load — that fan-out was tripping the boot-slow beacon). Still server-merged, so
// cross-device pull is preserved.
async function syncAllGames() {
if (!auth.user || !date) return;
const specs = gameSpecs();
const items = specs.map(([game, variant, key]) => {
let local = null;
try { local = JSON.parse(localStorage.getItem(key) || 'null'); } catch { /* ignore */ }
return { game, variant, state: local || {} };
});
const states = await pushGameStatesBatch(date, items);
if (states) {
const keyOf = (g, v) => specs.find((s) => s[0] === g && s[1] === v)?.[2];
for (const { game, variant, state } of states) {
if (!state) continue;
const merged = { ...state };
if (game === 'wordsearch') merged.status = merged.ms ? 'done' : 'playing'; // card reads .status
const key = keyOf(game, variant);
if (key) { try { localStorage.setItem(key, JSON.stringify(merged)); } catch { /* ignore */ } }
}
}
refreshStatus();
}
// Hub card one-liners
function wordLabel() {
const a = wordStatus['5'], b = wordStatus['6'];
if (!a && !b) return 'Guess the days word';
const part = (s, mx) => !s ? '' : s.status === 'won' ? `${s.tries}/${mx}` : s.status === 'lost' ? 'X' : `${s.tries}…`;
return `Today · 5:${part(a, 6)} 6:${part(b, 7)}`;
}
function wsHubLabel() {
if (!wsStatus) return 'Find the days themed words';
if (wsStatus.status === 'done') return `Today: cleared${wsStatus.ms ? ' · ' + fmtMs(wsStatus.ms) : ''}`;
if (wsStatus.found > 0) return `Today: ${wsStatus.found} found`;
return 'Find the days themed words';
}
function bloomHubLabel() {
if (!bloomStatus || !bloomStatus.count) return 'Make words from todays letters';
if (bloomStatus.full) return 'Today: Full Bloom 🌸';
return `Today: ${bloomStatus.count} ${bloomStatus.count === 1 ? 'word' : 'words'}`;
}
function matchHubLabel() {
if (!matchStatus) return 'Match the days pairs';
if (matchStatus.done) return 'Today: cleared';
return `Today: ${matchStatus.count} matched`;
}
function matchOpt(t) {
const s = readMatchVariant(`${t}-${matchFmt}`);
if (!s) return 'Play';
if (s.done) return 'Cleared';
const c = (s.matched || []).length;
return c > 0 ? `${c} matched` : 'Play';
}
// Game-selection option statuses
function wordOpt(v) {
const s = wordStatus[v];
if (!s) return 'Play';
if (s.status === 'won') return `Solved ${s.tries}/${s.max}`;
if (s.status === 'lost') return 'Out of guesses';
return `Continue · ${s.tries}/${s.max}`;
}
function wsOpt(sz) {
const s = readWsSize(sz);
if (!s) return 'Play';
if (s.status === 'done') return `Cleared${s.ms ? ' · ' + fmtMs(s.ms) : ''}`;
if (s.found > 0) return `${s.found} found`;
return 'Play';
}
// Forward navigations push a history entry (via goto). The in-app Back button
// pops it (history.back) when we have in-app history, but on a direct deep link
// (no in-app history) it navigates to the parent screen instead of leaving the
// site. Device Back stays browser-native either way.
let appNavDepth = 0;
function openGame(g) { appNavDepth++; goto('/play?game=' + g); }
function pick(v) { appNavDepth++; goto('/play?game=' + game + '&v=' + v); }
function back() {
if (appNavDepth > 0) { appNavDepth--; history.back(); }
else goto(view === 'play' ? '/play?game=' + game : '/play');
}
// Canonicalize shareable/bookmarked URLs: unknown game → hub; invalid v for the
// game → its default (replaceState, so it doesn't add a history entry). An in-dev
// game's route bounces non-admins back to the hub.
$effect(() => {
const g = sp.get('game'), v = sp.get('v');
if (g && !['word', 'wordsearch', 'bloom', 'match'].includes(g)) { goto('/play', { replaceState: true }); return; }
if (g && blockedForViewer(g, auth.user, $page.url)) { goto('/play', { replaceState: true }); return; }
if (g && v) {
const valid = g === 'word' ? ['5', '6']
: g === 'wordsearch' ? ['small', 'med', 'large']
: g === 'match' ? MATCH_VS
: ['daily', 'free-center', 'free-wild'];
const def = g === 'word' ? '5' : g === 'wordsearch' ? 'med' : g === 'match' ? 'daily-icons-standard' : 'daily';
if (!valid.includes(v)) goto(`/play?game=${g}&v=${def}`, { replaceState: true });
}
});
// Any puzzle on mobile = a focused viewport: lock scroll + hide footer. Cleanup
// ALWAYS removes the class (re-run or unmount), so leaving /play can't strand it.
$effect(() => {
if (typeof document === 'undefined') return;
if (view === 'play') {
document.documentElement.classList.add('playing-game');
return () => document.documentElement.classList.remove('playing-game');
}
});
const WS_OPTS = [
['small', 'Small', 'cozy · 8×8'],
['med', 'Medium', 'balanced · 11×11'],
['large', 'Large', 'a longer sit · 14×14'],
];
let wsTheme = $state('');
onMount(async () => {
initPrefs(); // so the reader's chosen calm set is available on a direct /play landing
try { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ }
try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ }
refreshStatus();
syncAllGames(); // signed-in: pull cross-device status into the cards + upload local progress
});
// Refresh hub/selection statuses whenever we land on a screen (incl. Back).
afterNavigate(() => refreshStatus());
</script>
<svelte:head>
<title>Play · Upbeat Bytes</title>
{#if isDevGated(game)}<meta name="robots" content="noindex" />{/if}
</svelte:head>
<header class="bar">
<div class="container inner">
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
{#if view === 'hub'}
<a class="back" href="/"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>News</a>
{:else}
<button class="back" onclick={back}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>{view === 'play' ? 'Game Selection' : 'Play Hub'}
</button>
{/if}
</div>
</header>
<main class="container page" class:gameview={view === 'play'}>
{#if view === 'hub'}
<h1>Play</h1>
<p class="sub">A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.</p>
{#if date && ritual.total}
<div class="calmset">
<p class="cs-head">Today's calm set</p>
<ul class="cs-items">
{#each ritual.items as it (it.key)}
<li class="cs-item" class:done={it.done}>
<span class="cs-mark" aria-hidden="true"></span>{#if it.done}{it.label}{:else}<a href={it.href}>{it.label}</a>{/if}
</li>
{/each}
</ul>
<p class="cs-foot">{ritual.count === ritual.total ? `All ${ritual.total} enjoyed today` : `${ritual.count} of ${ritual.total} enjoyed today`} · fresh set tomorrow · <a class="cs-edit" href="/account?section=calmset">make it yours</a></p>
</div>
{/if}
<div class="cards">
<button class="gamecard" onclick={() => openGame('word')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Daily Word</h2>
<p class="gc-sub">Guess the hopeful word · 5 or 6 letters</p>
<p class="gc-status" class:played={wordStatus['5'] || wordStatus['6']}>{wordLabel()}</p>
</div>
</button>
<button class="gamecard" onclick={() => openGame('wordsearch')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Word Search</h2>
<p class="gc-sub">Find the days themed words</p>
<p class="gc-status" class:played={wsStatus && (wsStatus.status === 'done' || wsStatus.found > 0)}>{wsHubLabel()}</p>
</div>
</button>
{#if !bloomBlocked}
<button class="gamecard" onclick={() => openGame('bloom')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Bloom{#if isDevGated('bloom')}<span class="devtag">dev</span>{/if}</h2>
<p class="gc-sub">Make words from todays letters</p>
<p class="gc-status" class:played={bloomStatus && bloomStatus.count > 0}>{bloomHubLabel()}</p>
</div>
</button>
{/if}
{#if !matchBlocked}
<button class="gamecard" onclick={() => openGame('match')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Memory Match{#if isDevGated('match')}<span class="devtag">dev</span>{/if}</h2>
<p class="gc-sub">Find the pairs — icons or colors</p>
<p class="gc-status" class:played={matchStatus}>{matchHubLabel()}</p>
</div>
</button>
{/if}
{#if !blockedForViewer('zen', auth.user, $page.url)}
<a class="gamecard zencard" href="/zen">
<div class="gc-icon">🐟</div>
<div class="gc-body">
<h2>Zen Den{#if isDevGated('zen')}<span class="devtag">dev</span>{/if}</h2>
<p class="gc-sub">A calm corner — drop in with UB</p>
<p class="gc-status zen">Visit · no scores, just quiet</p>
</div>
</a>
{/if}
</div>
{:else if view === 'select'}
<h1 class="seltitle">{game === 'word' ? 'Daily Word' : game === 'wordsearch' ? 'Word Search' : game === 'match' ? 'Memory Match' : 'Bloom'}</h1>
{#if game === 'wordsearch' && wsTheme}
<div class="themecard">
<span class="tc-label">Todays theme</span>
<span class="tc-name">{wsTheme}</span>
</div>
{/if}
<p class="sub">{game === 'word' ? 'Pick your length.' : game === 'wordsearch' ? 'Pick your size.' : game === 'match' ? 'Pick a format, then todays board or free play.' : 'Play todays shared puzzle, or graze freely.'}</p>
<div class="opts">
{#if game === 'match'}
<div class="seg">
<button class="segbtn" class:on={matchFmt === 'icons'} onclick={() => (matchFmt = 'icons')}>Memory · icons</button>
<button class="segbtn" class:on={matchFmt === 'colors'} onclick={() => (matchFmt = 'colors')}>Color Match</button>
</div>
<p class="grp">Todays board — shared daily</p>
{#each MATCH_TIERS as [t, name, desc] (t)}
<button class="opt" onclick={() => pick(`daily-${matchFmt}-${t}`)}>
<span class="opt-main"><strong>{name}</strong><span>{desc}</span></span>
<span class="opt-go" class:done={readMatchVariant(`${t}-${matchFmt}`)?.done}>{matchOpt(t)}</span>
</button>
{/each}
<p class="grp">Free play — fresh boards anytime</p>
{#each MATCH_TIERS as [t, name, desc] (t)}
<button class="opt" onclick={() => pick(`free-${matchFmt}-${t}`)}>
<span class="opt-main"><strong>{name}</strong><span>{desc}</span></span>
<span class="opt-go">Play</span>
</button>
{/each}
{:else if game === 'bloom'}
<button class="opt" onclick={() => pick('daily')}>
<span class="opt-main"><strong>Todays Bloom</strong><span>the shared daily · center letter</span></span>
<span class="opt-go" class:done={bloomStatus && bloomStatus.count > 0}>{bloomStatus && bloomStatus.count ? (bloomStatus.full ? 'Full Bloom 🌸' : `${bloomStatus.count} found`) : 'Play'}</span>
</button>
<button class="opt" onclick={() => pick('free-center')}>
<span class="opt-main"><strong>Free Play · Center Circle</strong><span>fresh wheels anytime · center letter</span></span>
<span class="opt-go">Play</span>
</button>
<button class="opt" onclick={() => pick('free-wild')}>
<span class="opt-main"><strong>Free Play · Wild Bloom</strong><span>fresh wheels · use any letters</span></span>
<span class="opt-go">Play</span>
</button>
{:else if game === 'word'}
<button class="opt" onclick={() => pick('5')}>
<span class="opt-main"><strong>Daily Word</strong><span>5 letters · 6 guesses</span></span>
<span class="opt-go" class:done={wordStatus['5']}>{wordOpt('5')}</span>
</button>
<button class="opt" onclick={() => pick('6')}>
<span class="opt-main"><strong>Long Word</strong><span>6 letters · 7 guesses</span></span>
<span class="opt-go" class:done={wordStatus['6']}>{wordOpt('6')}</span>
</button>
{:else}
{#each WS_OPTS as [sz, name, desc] (sz)}
<button class="opt" onclick={() => pick(sz)}>
<span class="opt-main"><strong>{name}</strong><span>{desc}</span></span>
<span class="opt-go" class:done={readWsSize(sz)}>{wsOpt(sz)}</span>
</button>
{/each}
{/if}
</div>
{:else if view === 'play'}
{#if game === 'word'}
<WordGame {variant} onstatus={refreshStatus} />
{:else if game === 'wordsearch'}
<WordSearchGame size={wsSize} onstatus={refreshStatus} />
{:else if game === 'bloom'}
{#key sp.get('v')}
<BloomGame mode={bloomMode} format={bloomFormat} onstatus={refreshStatus} />
{/key}
{:else if game === 'match'}
{#key sp.get('v')}
<MatchGame mode={matchMode} format={matchFormat} tier={matchTier} {date} onstatus={refreshStatus} />
{/key}
{/if}
{/if}
</main>
<style>
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
.logo { height: 40px; display: block; }
.back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px;
background: none; border: none; font-family: inherit; cursor: pointer; }
.back svg { width: 17px; height: 17px; display: block; }
.page { padding: 22px 20px 70px; }
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 6px; }
.seltitle { font-size: clamp(1.7rem, 4.5vw, 2.2rem); }
.sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; }
.themecard { background: var(--accent-soft); border-radius: 16px; padding: 16px 22px;
margin: 8px 0 24px; max-width: 460px; text-align: center; box-shadow: var(--shadow); }
.tc-label { display: block; text-transform: uppercase; letter-spacing: 0.13em; font-size: 0.66rem;
font-family: var(--label); font-weight: 600; color: var(--accent-deep); margin-bottom: 4px; }
/* Memory Match selection: format toggle + grouped tier options */
.seg { display: flex; gap: 6px; background: var(--bg); border: 1px solid var(--line);
border-radius: 12px; padding: 4px; margin-bottom: 6px; }
.segbtn { flex: 1; padding: 9px 10px; border: none; border-radius: 9px; background: none;
font-family: inherit; font-size: 0.9rem; color: var(--muted); cursor: pointer; }
.segbtn.on { background: var(--surface); color: var(--accent-deep); font-weight: 600; box-shadow: var(--shadow); }
.grp { margin: 14px 0 2px; font-family: var(--label); text-transform: uppercase; letter-spacing: 0.04em;
font-size: 0.7rem; color: var(--muted); }
.tc-name { font-family: var(--serif); font-size: 1.7rem; color: var(--accent-deep); line-height: 1.15; }
/* Daily Ritual — "today's calm set". Gentle, non-instrumental. */
.calmset {
max-width: 460px; margin: 0 0 24px; padding: 14px 18px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow);
}
.cs-head {
margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.13em;
font-family: var(--label); font-size: 0.64rem; font-weight: 600; color: var(--accent-deep);
}
.cs-items { list-style: none; margin: 0; padding: 0; display: flex; gap: 18px; flex-wrap: wrap; }
.cs-item { display: inline-flex; align-items: center; gap: 7px; font-size: 0.9rem; color: var(--muted); }
.cs-item a { color: inherit; text-decoration: none; }
.cs-item a:hover { color: var(--accent-deep); }
.cs-item.done { color: var(--ink); }
.cs-mark {
width: 16px; height: 16px; border-radius: 50%; border: 1.5px solid var(--line);
flex-shrink: 0; transition: background 0.16s ease, border-color 0.16s ease;
}
.cs-item.done .cs-mark {
background: var(--accent); border-color: var(--accent);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12l5 5 9-10' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-size: 13px; background-repeat: no-repeat; background-position: center;
}
.cs-foot { margin: 12px 0 0; font-family: var(--label); font-size: 0.82rem; color: var(--muted); }
.cs-edit { color: var(--accent-deep); text-decoration: underline; white-space: nowrap; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
.gamecard {
display: flex; gap: 14px; align-items: center; text-align: left;
background: var(--surface); border: 1px solid var(--line); border-radius: 16px;
padding: 18px 20px; cursor: pointer; font: inherit; color: var(--ink);
box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease;
}
.gamecard:hover { border-color: var(--accent); transform: translateY(-1px); }
/* The Zen Den isn't a game — give it a soft aqua identity so it reads as a calm corner. */
.zencard { text-decoration: none; background: linear-gradient(180deg, #f2fbfc, var(--surface)); }
.zencard:hover { border-color: #7cc3cc; }
.zencard .gc-status.zen { color: #2b8c98; font-weight: 600; }
.gc-icon { font-size: 2rem; color: var(--accent); line-height: 1; flex-shrink: 0; }
.gc-body h2 { font-size: 1.2rem; margin: 0 0 3px; }
.devtag { margin-left: 8px; font-size: 0.6rem; font-family: var(--label); font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em; color: #fff; background: #c2569b;
border-radius: 5px; padding: 2px 6px; vertical-align: middle; }
.gc-sub { color: var(--muted); font-size: 0.86rem; margin: 0 0 8px; }
.gc-status { font-size: 0.84rem; color: var(--accent-deep); font-weight: 600; margin: 0; }
/* Game-selection options */
.opts { display: flex; flex-direction: column; gap: 12px; max-width: 460px; }
.opt {
display: flex; align-items: center; justify-content: space-between; gap: 14px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
padding: 16px 18px; cursor: pointer; font: inherit; color: var(--ink);
box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease;
}
.opt:hover { border-color: var(--accent); transform: translateY(-1px); }
.opt-main { display: flex; flex-direction: column; text-align: left; }
.opt-main strong { font-size: 1.1rem; }
.opt-main span { color: var(--muted); font-size: 0.82rem; }
.opt-go { flex-shrink: 0; font-size: 0.82rem; font-weight: 600; color: #fff; background: var(--accent);
border-radius: 999px; padding: 6px 14px; }
.opt-go.done { background: var(--accent-soft); color: var(--accent-deep); }
/* Full-height game layout on mobile so the keyboard is always reachable. */
@media (max-width: 720px) {
.page.gameview {
height: calc(100dvh - 64px); padding: 10px 16px 0;
display: flex; flex-direction: column; overflow: hidden;
}
}
</style>