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,98 @@
|
||||
<script>
|
||||
// Full searchable emoji picker for the Publishing Desk. Data is bundled locally
|
||||
// (emoji-data.js) — no runtime/CDN fetch. Calls onpick(char) for each selection;
|
||||
// stays open so several can be inserted in a row. Recents persist in localStorage.
|
||||
import { EMOJI_GROUPS } from './emoji-data.js';
|
||||
|
||||
let { onpick } = $props();
|
||||
|
||||
let q = $state('');
|
||||
let recent = $state([]);
|
||||
const RECENT_KEY = 'ub.pub.emojiRecent';
|
||||
|
||||
$effect(() => {
|
||||
try { recent = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); } catch { recent = []; }
|
||||
});
|
||||
|
||||
function pick(c) {
|
||||
onpick(c);
|
||||
const next = [c, ...recent.filter((x) => x !== c)].slice(0, 24);
|
||||
recent = next;
|
||||
try { localStorage.setItem(RECENT_KEY, JSON.stringify(next)); } catch { /* private mode */ }
|
||||
}
|
||||
|
||||
// search across name + keyword slug; null when the box is empty (→ show grouped view)
|
||||
const matches = $derived.by(() => {
|
||||
const term = q.trim().toLowerCase();
|
||||
if (!term) return null;
|
||||
const res = [];
|
||||
for (const g of EMOJI_GROUPS)
|
||||
for (const e of g.emojis)
|
||||
if (e.n.includes(term) || e.k.includes(term)) res.push(e);
|
||||
return res;
|
||||
});
|
||||
|
||||
let sections = {}; // group name -> section element, for the jump tabs
|
||||
function jump(name) { sections[name]?.scrollIntoView({ block: 'start', behavior: 'smooth' }); }
|
||||
</script>
|
||||
|
||||
<div class="ep">
|
||||
<input class="ep-search" placeholder="Search emoji…" bind:value={q} autocomplete="off" />
|
||||
|
||||
{#if !q.trim()}
|
||||
<div class="ep-tabs">
|
||||
{#if recent.length}<button type="button" title="Recent" onclick={() => jump('__recent')}>🕘</button>{/if}
|
||||
{#each EMOJI_GROUPS as g (g.name)}
|
||||
<button type="button" title={g.name} onclick={() => jump(g.name)}>{g.emojis[0].c}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="ep-scroll">
|
||||
{#if matches}
|
||||
{#if matches.length}
|
||||
<div class="ep-grid">
|
||||
{#each matches as e (e.c)}<button type="button" class="ep-em" title={e.n} onclick={() => pick(e.c)}>{e.c}</button>{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="ep-none">No emoji match “{q}”.</p>
|
||||
{/if}
|
||||
{:else}
|
||||
{#if recent.length}
|
||||
<div class="ep-sec" bind:this={sections['__recent']}>
|
||||
<div class="ep-h">Recent</div>
|
||||
<div class="ep-grid">
|
||||
{#each recent as c (c)}<button type="button" class="ep-em" onclick={() => pick(c)}>{c}</button>{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#each EMOJI_GROUPS as g (g.name)}
|
||||
<div class="ep-sec" bind:this={sections[g.name]}>
|
||||
<div class="ep-h">{g.name}</div>
|
||||
<div class="ep-grid">
|
||||
{#each g.emojis as e (e.c)}<button type="button" class="ep-em" title={e.n} onclick={() => pick(e.c)}>{e.c}</button>{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.ep { width: 300px; }
|
||||
.ep-search { width: 100%; box-sizing: border-box; font: inherit; font-size: 0.85rem; padding: 6px 9px;
|
||||
border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); }
|
||||
.ep-tabs { display: flex; flex-wrap: wrap; gap: 1px; margin: 6px 0; }
|
||||
.ep-tabs button { font-size: 1.05rem; line-height: 1; padding: 3px; border: 0; border-radius: 6px;
|
||||
background: transparent; cursor: pointer; }
|
||||
.ep-tabs button:hover { background: var(--accent-soft); }
|
||||
.ep-scroll { max-height: 230px; overflow-y: auto; margin-top: 4px; }
|
||||
.ep-sec { scroll-margin-top: 2px; }
|
||||
.ep-h { position: sticky; top: 0; background: var(--card, var(--bg)); font-size: 0.72rem; font-weight: 700;
|
||||
color: var(--muted); text-transform: uppercase; letter-spacing: .04em; padding: 4px 2px; }
|
||||
.ep-grid { display: grid; grid-template-columns: repeat(8, 1fr); }
|
||||
.ep-em { font-size: 1.25rem; line-height: 1; padding: 4px 0; border: 0; border-radius: 7px;
|
||||
background: transparent; cursor: pointer; }
|
||||
.ep-em:hover { background: var(--accent-soft); }
|
||||
.ep-none { font-size: 0.82rem; color: var(--muted); padding: 8px 2px; }
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,36 @@
|
||||
// Reusable in-development gate. Lets us ship a half-built game LIVE (so we can
|
||||
// test the real, fully-wired experience) without any public visitor stumbling
|
||||
// onto it:
|
||||
// • its card shows on /play only for admins (or a preview link)
|
||||
// • its route bounces non-admins back to the hub
|
||||
// • search engines never index it (the route guard + noindex)
|
||||
// Launch = remove the game key from IN_DEV. Future dev games just join the set.
|
||||
//
|
||||
// This is casual concealment, not security — the API is harmless on its own.
|
||||
|
||||
// 'bloom' launched 2026-06-15 — public. 'match' (Memory/Color Match) launched
|
||||
// 2026-06-17 — public. 'zen' (UB/Zen Den) gated while UB is ironed out (model
|
||||
// tabled). Add a key here to gate any in-dev game/section.
|
||||
export const IN_DEV = new Set(['zen']);
|
||||
|
||||
// A non-obvious bypass so we can test logged-out / incognito: /play?...&preview=KEY
|
||||
const PREVIEW_KEY = 'sunflower';
|
||||
|
||||
export function isDevGated(gameKey) {
|
||||
return IN_DEV.has(gameKey);
|
||||
}
|
||||
|
||||
// Can THIS viewer see in-dev games? Admins always; anyone with the preview token.
|
||||
export function canSeeDev(user, url) {
|
||||
if (user?.is_admin) return true;
|
||||
try {
|
||||
return url?.searchParams?.get('preview') === PREVIEW_KEY;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// True when this game should be hidden/blocked for this viewer.
|
||||
export function blockedForViewer(gameKey, user, url) {
|
||||
return isDevGated(gameKey) && !canSeeDev(user, url);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,83 @@
|
||||
// Memory Match board engine — fully deterministic ("code disposes"): a given seed
|
||||
// always yields the same board, so the daily is shared across devices with no
|
||||
// server round-trip. Faces are stored as KEYS (icon name or color key), never raw
|
||||
// indices, so saved progress survives layout tweaks (Codex).
|
||||
import { ICON_KEYS } from './icons.js';
|
||||
import { COLORS, deltaE } from './palette.js';
|
||||
|
||||
// Tiers — Gentle/Standard match pairs; Expert matches THREE of a kind and uses a
|
||||
// bigger grid so it reads as a real step up (Codex's sizing).
|
||||
// gentle 4×3 = 12 cards = 6 pairs
|
||||
// standard 4×4 = 16 cards = 8 pairs
|
||||
// expert 6×4 = 24 cards = 8 triples
|
||||
export const TIERS = {
|
||||
gentle: { label: 'Gentle', cols: 4, rows: 3, faces: 6, matchN: 2 },
|
||||
standard: { label: 'Standard', cols: 4, rows: 4, faces: 8, matchN: 2 },
|
||||
expert: { label: 'Expert', cols: 6, rows: 4, faces: 8, matchN: 3 },
|
||||
};
|
||||
export const TIER_KEYS = Object.keys(TIERS);
|
||||
export const FORMAT_KEYS = ['icons', 'colors'];
|
||||
|
||||
// Minimum perceptual gap (ΔE) we try to keep between a board's colors so two
|
||||
// near-identical shades never appear together; relaxed only if a board can't fill.
|
||||
const MIN_DELTA_E = 26;
|
||||
|
||||
function hashStr(s) {
|
||||
let h = 2166136261 >>> 0;
|
||||
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
|
||||
return h >>> 0;
|
||||
}
|
||||
function mulberry32(a) {
|
||||
return function () {
|
||||
a |= 0; a = (a + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a);
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
||||
};
|
||||
}
|
||||
function shuffle(arr, rng) {
|
||||
const a = arr.slice();
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(rng() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
// Pick `n` colors, seed-varied but spaced: walk a seeded shuffle keeping only
|
||||
// colors at least `threshold` ΔE from those already chosen; relax if we can't fill.
|
||||
function pickColors(n, rng) {
|
||||
const order = shuffle(COLORS, rng);
|
||||
for (let threshold = MIN_DELTA_E; threshold >= 0; threshold -= 3) {
|
||||
const chosen = [];
|
||||
for (const c of order) {
|
||||
if (chosen.every((p) => deltaE(p.lab, c.lab) >= threshold)) chosen.push(c);
|
||||
if (chosen.length === n) break;
|
||||
}
|
||||
if (chosen.length === n) return chosen.map((c) => c.key);
|
||||
}
|
||||
return order.slice(0, n).map((c) => c.key); // pool too small (shouldn't happen)
|
||||
}
|
||||
|
||||
function pickFaces(format, n, rng) {
|
||||
if (format === 'colors') return pickColors(n, rng);
|
||||
return shuffle(ICON_KEYS, rng).slice(0, n);
|
||||
}
|
||||
|
||||
// Build a board. `seed` is the day's date (daily) or a random token (free play).
|
||||
export function buildBoard({ format = 'icons', tier = 'standard', seed = '' } = {}) {
|
||||
const t = TIERS[tier] || TIERS.standard;
|
||||
const fmt = FORMAT_KEYS.includes(format) ? format : 'icons';
|
||||
const rng = mulberry32(hashStr(`match:v1:${fmt}:${tier}:${seed}`));
|
||||
const faces = pickFaces(fmt, t.faces, rng);
|
||||
const multiset = [];
|
||||
for (const key of faces) for (let c = 0; c < t.matchN; c++) multiset.push(key);
|
||||
const cards = shuffle(multiset, rng).map((key, id) => ({ id, key }));
|
||||
return { cards, faces, matchN: t.matchN, cols: t.cols, rows: t.rows, format: fmt, tier, label: t.label };
|
||||
}
|
||||
|
||||
// A short token for a fresh free-play board (deterministic given the inputs so a
|
||||
// reload resumes the same board); callers persist it.
|
||||
export function freeSeed(n) {
|
||||
return 'f' + (n >>> 0).toString(36);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildBoard, TIERS, TIER_KEYS } from './board.js';
|
||||
import { COLOR_BY_KEY, deltaE } from './palette.js';
|
||||
import { ICON_KEYS } from './icons.js';
|
||||
|
||||
describe('match board', () => {
|
||||
it('is deterministic for a given seed', () => {
|
||||
const a = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-16' });
|
||||
const b = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-16' });
|
||||
expect(a.cards).toEqual(b.cards);
|
||||
});
|
||||
|
||||
it('different days give different boards', () => {
|
||||
const a = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-16' });
|
||||
const b = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-17' });
|
||||
expect(a.cards).not.toEqual(b.cards);
|
||||
});
|
||||
|
||||
for (const tier of TIER_KEYS) {
|
||||
it(`${tier}: correct card count and exactly matchN of each face`, () => {
|
||||
const t = TIERS[tier];
|
||||
const { cards, faces, matchN } = buildBoard({ tier, seed: 's' });
|
||||
expect(cards.length).toBe(t.cols * t.rows);
|
||||
expect(faces.length).toBe(t.faces);
|
||||
expect(matchN).toBe(t.matchN);
|
||||
for (const f of faces) {
|
||||
expect(cards.filter((c) => c.key === f).length).toBe(matchN);
|
||||
}
|
||||
// every card is one of the chosen faces
|
||||
expect(cards.every((c) => faces.includes(c.key))).toBe(true);
|
||||
});
|
||||
}
|
||||
|
||||
it('icon boards draw from the icon set', () => {
|
||||
const { faces } = buildBoard({ format: 'icons', tier: 'expert', seed: 'x' });
|
||||
expect(faces.every((k) => ICON_KEYS.includes(k))).toBe(true);
|
||||
});
|
||||
|
||||
it('color boards are perceptually spaced (no near-identical pair)', () => {
|
||||
// sweep many seeds; every board's colors must be comfortably distinct
|
||||
for (let i = 0; i < 80; i++) {
|
||||
const { faces } = buildBoard({ format: 'colors', tier: 'standard', seed: 'd' + i });
|
||||
const labs = faces.map((k) => COLOR_BY_KEY[k].lab);
|
||||
let min = Infinity;
|
||||
for (let a = 0; a < labs.length; a++)
|
||||
for (let b = a + 1; b < labs.length; b++)
|
||||
min = Math.min(min, deltaE(labs[a], labs[b]));
|
||||
expect(min).toBeGreaterThan(15); // never confusably close
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// Memory Match icon set — in-repo, owned, no dependency. Each value is the inner
|
||||
// SVG markup on a 0 0 24 24 viewBox; MatchIcon.svelte supplies the shared styling
|
||||
// (stroke=currentColor, fill=none, round caps) so every icon reads as one calm
|
||||
// line family. Keep additions simple and recognizable; quality over count.
|
||||
export const ICONS = {
|
||||
sun: '<circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.4M12 19.1v2.4M2.5 12h2.4M19.1 12h2.4M5.2 5.2l1.7 1.7M17.1 17.1l1.7 1.7M18.8 5.2l-1.7 1.7M6.9 17.1l-1.7 1.7"/>',
|
||||
moon: '<path d="M20 14.5A8 8 0 1 1 11 4a6.5 6.5 0 0 0 9 10.5z"/>',
|
||||
star: '<path d="M12 3.5l2.6 5.3 5.9.9-4.25 4.15 1 5.85L12 17l-5.25 2.75 1-5.85L3.5 9.7l5.9-.9z"/>',
|
||||
cloud: '<path d="M7 18h9.5a3.5 3.5 0 0 0 .3-7 5 5 0 0 0-9.6-1.3A3.8 3.8 0 0 0 7 18z"/>',
|
||||
raindrop: '<path d="M12 3.5c3.5 4.2 5.5 7 5.5 9.8a5.5 5.5 0 0 1-11 0C6.5 10.5 8.5 7.7 12 3.5z"/>',
|
||||
wave: '<path d="M3 9c2 0 2.5 2 4.5 2S10 9 12 9s2.5 2 4.5 2S19 9 21 9M3 15c2 0 2.5 2 4.5 2S10 15 12 15s2.5 2 4.5 2S19 15 21 15"/>',
|
||||
leaf: '<path d="M5 19C5 11 11 5 19 5c0 8-6 14-14 14z"/><path d="M5 19C9 15 12 12 16 9"/>',
|
||||
flower: '<circle cx="12" cy="12" r="2.3"/><circle cx="12" cy="6.6" r="2.3"/><circle cx="12" cy="17.4" r="2.3"/><circle cx="7.3" cy="9.3" r="2.3"/><circle cx="16.7" cy="9.3" r="2.3"/><circle cx="7.3" cy="14.7" r="2.3"/><circle cx="16.7" cy="14.7" r="2.3"/>',
|
||||
seedling: '<path d="M12 20v-7M12 13c0-3 2.5-5 6-5 0 3.5-2.5 5-6 5zM12 13c0-2.5-2-4.5-5-4.5 0 3 2 4.5 5 4.5z"/>',
|
||||
tree: '<path d="M12 3 6 11h3l-4 6h14l-4-6h3z"/><path d="M12 17v4"/>',
|
||||
mountain: '<path d="M3 19l6-10 4 6 2.5-4L21 19z"/>',
|
||||
shell: '<path d="M4 18C4 10 7.5 5 12 5s8 5 8 13z"/><path d="M12 18V6M8 18 9.5 7M16 18 14.5 7"/>',
|
||||
feather: '<path d="M5 19C7 9 12 5 19 5c0 7-4 12-12 13z"/><path d="M5 19 13 11"/>',
|
||||
acorn: '<path d="M6 8c0-2 2.7-3.5 6-3.5S18 6 18 8zM7 8a5 5 0 0 0 10 0M12 18v2"/>',
|
||||
butterfly: '<path d="M12 6v12M12 9C8 4 4 6 4 10s4 5 8 2M12 9c4-5 8-3 8 1s-4 5-8 2"/>',
|
||||
rainbow: '<path d="M3 18a9 9 0 0 1 18 0M6 18a6 6 0 0 1 12 0M9 18a3 3 0 0 1 6 0"/>',
|
||||
heart: '<path d="M12 20S4 14.5 4 9a4 4 0 0 1 8-1 4 4 0 0 1 8 1c0 5.5-8 11-8 11z"/>',
|
||||
sparkle: '<path d="M12 3c.6 4.5 1.5 5.4 6 6-4.5.6-5.4 1.5-6 6-.6-4.5-1.5-5.4-6-6 4.5-.6 5.4-1.5 6-6z"/>',
|
||||
home: '<path d="M4 11 12 4l8 7M6 10v9h12v-9M10 19v-5h4v5"/>',
|
||||
book: '<path d="M12 6C10 4.5 7 4.5 4 5v13c3-.5 6-.5 8 1 2-1.5 5-1.5 8-1V5c-3-.5-6-.5-8 1zM12 6v13"/>',
|
||||
teacup: '<path d="M5 9h12v3a6 6 0 0 1-12 0zM17 10h2a2 2 0 0 1 0 4h-2M5 19h12"/>',
|
||||
candle: '<path d="M9 11h6v9H9zM12 11V8M12 8c0-1.6-2-2-2-3.5C10 3.4 11 3 12 3s2 .4 2 1.5C14 6 12 6.4 12 8z"/>',
|
||||
lantern: '<path d="M10 3h4M8 6h8l-1 12H9zM9 6V4.5h6V6M8 18h8M12 9v6"/>',
|
||||
compass: '<circle cx="12" cy="12" r="9"/><path d="M15 9l-2.5 5.5L9 15l2.5-5.5z"/>',
|
||||
kite: '<path d="M12 3 19 10 12 17 5 10zM12 3v14M5 10h14M12 17l-2 4M12 17l2 4"/>',
|
||||
note: '<path d="M9 18V6l9-2v12"/><circle cx="7" cy="18" r="2"/><circle cx="16" cy="16" r="2"/>',
|
||||
boat: '<path d="M4 16h16l-2.5 4H6.5zM12 14V3l6 8zM12 14H6l6-7"/>',
|
||||
fish: '<path d="M3 12c2.5-3.5 6-5 9-5s5.5 1.5 7 5c-1.5 3.5-4 5-7 5s-6.5-1.5-9-5z"/><path d="M19 12l3-2.5v5z"/><circle cx="8" cy="11" r="0.9"/>',
|
||||
bird: '<path d="M3 9c3-2 6 0 9 0s6-2 9 0c-3 3-5 4-9 4S6 12 3 9z"/>',
|
||||
mushroom: '<path d="M4 11a8 8 0 0 1 16 0zM9 11v5a3 3 0 0 0 6 0v-5"/>',
|
||||
bell: '<path d="M6.5 17c1.3-1 1.8-3 1.8-6a3.7 3.7 0 0 1 7.4 0c0 3 .5 5 1.8 6zM10 17.5a2 2 0 0 0 4 0M12 4.2V2.6"/>',
|
||||
snowflake: '<path d="M12 3v18M4.2 7.5l15.6 9M19.8 7.5l-15.6 9M9 5l3 2 3-2M9 19l3-2 3 2"/>',
|
||||
clover: '<circle cx="9" cy="10" r="3"/><circle cx="15" cy="10" r="3"/><circle cx="12" cy="14.5" r="3"/><path d="M12 16.5V21"/>',
|
||||
};
|
||||
|
||||
export const ICON_KEYS = Object.keys(ICONS);
|
||||
@@ -0,0 +1,50 @@
|
||||
// Color Match palette — a hand-curated set of calm-but-distinct colors. The board
|
||||
// builder picks a perceptually-spaced subset (see board.js) so two near-identical
|
||||
// shades never land on the same board. Each color is NAMED (drives the aria-label
|
||||
// and the optional colorblind "assist" glyph), and varies in LIGHTNESS as well as
|
||||
// hue so confusable hue pairs (e.g. green/teal) still differ by brightness.
|
||||
//
|
||||
// `assist` is a short letter shown in the corner when the player turns assist on —
|
||||
// kept to 1–2 chars, unique enough to disambiguate the swatch without reading as
|
||||
// a full label. Default play is pure color; assist is opt-in.
|
||||
|
||||
const RAW = [
|
||||
{ key: 'color-rose', name: 'Rose', hex: '#d76a86', assist: 'Ro' },
|
||||
{ key: 'color-coral', name: 'Coral', hex: '#e07a52', assist: 'Co' },
|
||||
{ key: 'color-amber', name: 'Amber', hex: '#e3a32f', assist: 'Am' },
|
||||
{ key: 'color-gold', name: 'Gold', hex: '#cdb63c', assist: 'Go' },
|
||||
{ key: 'color-lime', name: 'Lime', hex: '#8fb24a', assist: 'Li' },
|
||||
{ key: 'color-green', name: 'Green', hex: '#4a9b5c', assist: 'Gr' },
|
||||
{ key: 'color-teal', name: 'Teal', hex: '#2f9c8e', assist: 'Te' },
|
||||
{ key: 'color-cyan', name: 'Cyan', hex: '#3aa6c4', assist: 'Cy' },
|
||||
{ key: 'color-sky', name: 'Sky', hex: '#5b8fd4', assist: 'Sk' },
|
||||
{ key: 'color-blue', name: 'Blue', hex: '#4f63c6', assist: 'Bl' },
|
||||
{ key: 'color-indigo', name: 'Indigo', hex: '#6a59b2', assist: 'In' },
|
||||
{ key: 'color-violet', name: 'Violet', hex: '#9460ba', assist: 'Vi' },
|
||||
{ key: 'color-plum', name: 'Plum', hex: '#b25a95', assist: 'Pl' },
|
||||
{ key: 'color-brown', name: 'Brown', hex: '#9c6b45', assist: 'Br' },
|
||||
{ key: 'color-sand', name: 'Sand', hex: '#cdb38c', assist: 'Sa' },
|
||||
{ key: 'color-slate', name: 'Slate', hex: '#5d6b78', assist: 'Sl' },
|
||||
{ key: 'color-charcoal', name: 'Charcoal', hex: '#3a4250', assist: 'Ch' },
|
||||
{ key: 'color-cream', name: 'Cream', hex: '#e6dfca', assist: 'Cm' },
|
||||
];
|
||||
|
||||
function srgbToLin(c) { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
|
||||
function hexToLab(hex) {
|
||||
const r = srgbToLin(parseInt(hex.slice(1, 3), 16));
|
||||
const g = srgbToLin(parseInt(hex.slice(3, 5), 16));
|
||||
const b = srgbToLin(parseInt(hex.slice(5, 7), 16));
|
||||
const X = r * 0.4124 + g * 0.3576 + b * 0.1805;
|
||||
const Y = r * 0.2126 + g * 0.7152 + b * 0.0722;
|
||||
const Z = r * 0.0193 + g * 0.1192 + b * 0.9505;
|
||||
const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
|
||||
const fx = f(X / 0.95047), fy = f(Y / 1), fz = f(Z / 1.08883);
|
||||
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
||||
}
|
||||
|
||||
// CIE76 ΔE — adequate for "are these two swatches obviously different".
|
||||
export function deltaE(a, b) { return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); }
|
||||
|
||||
export const COLORS = RAW.map((c) => ({ ...c, lab: hexToLab(c.hex) }));
|
||||
export const COLOR_BY_KEY = Object.fromEntries(COLORS.map((c) => [c.key, c]));
|
||||
export const COLOR_KEYS = COLORS.map((c) => c.key);
|
||||
@@ -17,6 +17,19 @@ export async function pushGameState(game, variant, date, local) {
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile MANY (game, variant) states for one date in a single request — used by
|
||||
// the hub so a /play load doesn't fan out a dozen calls. items: [{game, variant,
|
||||
// state}]. Returns the merged states array to adopt, or null when signed out / on error.
|
||||
export async function pushGameStatesBatch(date, items) {
|
||||
if (!auth.user) return null;
|
||||
try {
|
||||
const r = await putJSON('/api/games/state/batch', { date, items }, { timeout: 8000 });
|
||||
return r?.states ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Derived record (streak / distribution / best) computed server-side from the
|
||||
// player's synced states, so it's consistent across devices. null when signed out.
|
||||
export async function fetchGameStats(game, variant) {
|
||||
|
||||
@@ -11,6 +11,9 @@ export function blank() {
|
||||
// [] means "not customized" — the feed falls back to the default lane set.
|
||||
// The backend filter parser ignores unknown keys, so this rides along safely.
|
||||
lanes: [],
|
||||
// UI-only: which daily items fill the reader's "calm set" (ritual item keys).
|
||||
// null = not customized → all eligible items. Rides along like `lanes`.
|
||||
ritual: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,9 +80,16 @@ export function saveJSON(key, value) {
|
||||
|
||||
// "" or "prefs=<encoded json>" for a query string.
|
||||
export function param(prefs) {
|
||||
// Serialize ONLY real filter fields — UI-only keys (lanes, ritual) must not
|
||||
// ride in ?prefs= or they churn the feed URL / edge-cache key for no reason.
|
||||
const f = {
|
||||
include_topics: prefs.include_topics, include_flavors: prefs.include_flavors,
|
||||
mute_topics: prefs.mute_topics, mute_flavors: prefs.mute_flavors,
|
||||
avoid_terms: prefs.avoid_terms, pauses: prefs.pauses, max_cortisol: prefs.max_cortisol,
|
||||
};
|
||||
const empty =
|
||||
!prefs.include_topics.length && !prefs.include_flavors.length &&
|
||||
!prefs.mute_topics.length && !prefs.mute_flavors.length &&
|
||||
!prefs.avoid_terms.length && !prefs.pauses.length && prefs.max_cortisol == null;
|
||||
return empty ? '' : 'prefs=' + encodeURIComponent(JSON.stringify(prefs));
|
||||
!f.include_topics.length && !f.include_flavors.length &&
|
||||
!f.mute_topics.length && !f.mute_flavors.length &&
|
||||
!f.avoid_terms.length && !f.pauses.length && f.max_cortisol == null;
|
||||
return empty ? '' : 'prefs=' + encodeURIComponent(JSON.stringify(f));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
// Daily Ritual — the day's "calm set", a finite daily loop. Derived ENTIRELY
|
||||
// from signals we already store (game state in localStorage + a brief-seen flag)
|
||||
// — no backend, no new sync.
|
||||
//
|
||||
// "Today" is ALWAYS the server's puzzle/brief date passed in by the caller,
|
||||
// never the browser's local date, so the daily reset matches the site's actual
|
||||
// daily content (a reader in another timezone never sees "fresh set tomorrow"
|
||||
// while today's puzzle is still up).
|
||||
//
|
||||
// Spirit (Claude + Codex): gentle and non-instrumental. "Enjoyed," not
|
||||
// "completed"; "N of M · fresh set tomorrow," never "finish the rest." Brief
|
||||
// counts only when the end-cap is reached (the finite read), not on page open.
|
||||
//
|
||||
// The set is CURATABLE: only daily-cadence "one-a-day, finish-and-done" things
|
||||
// are eligible (never Free Play / ambient toys), and the reader chooses which of
|
||||
// those fill THEIR set (settings → Calm set). Default = all eligible items.
|
||||
|
||||
const BRIEF_SEEN_KEY = (date) => `goodnews:briefSeen:${date}`;
|
||||
|
||||
function read(key) {
|
||||
try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
|
||||
}
|
||||
|
||||
// Mark the brief as enjoyed for `date` — call only when the reader actually
|
||||
// reaches the end-cap, not merely on open.
|
||||
export function markBriefSeen(date) {
|
||||
if (!date) return;
|
||||
try { localStorage.setItem(BRIEF_SEEN_KEY(date), '1'); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function briefSeen(date) {
|
||||
if (!date) return false;
|
||||
try { return localStorage.getItem(BRIEF_SEEN_KEY(date)) === '1'; } catch { return false; }
|
||||
}
|
||||
|
||||
// A word is enjoyed once you've seen it through (won or lost) on either length.
|
||||
function wordEnjoyed(date) {
|
||||
for (const v of ['5', '6']) {
|
||||
const s = read(`goodnews:word:${v}:${date}`);
|
||||
if (s && (s.status === 'won' || s.status === 'lost')) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A word search is enjoyed once any size is cleared.
|
||||
function wordsearchEnjoyed(date) {
|
||||
for (const sz of ['small', 'med', 'large']) {
|
||||
const s = read(`goodnews:wordsearch:${sz}:${date}`);
|
||||
if (s && s.status === 'done') return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Bloom is enjoyed once the daily reaches the top tier (Flourishing) — the day's
|
||||
// goal, the "saw it through" point (BloomGame persists `top`). Free Play doesn't count.
|
||||
function bloomEnjoyed(date) {
|
||||
const s = read(`goodnews:bloom:${date}`);
|
||||
return !!(s && s.top);
|
||||
}
|
||||
|
||||
// Memory Match is enjoyed once ANY daily board (any tier/format) is cleared — the
|
||||
// reader's chosen mood-level counts; Free Play never does.
|
||||
const MATCH_VARIANTS = ['gentle', 'standard', 'expert'].flatMap(
|
||||
(t) => ['icons', 'colors'].map((f) => `${t}-${f}`));
|
||||
function matchEnjoyed(date) {
|
||||
for (const v of MATCH_VARIANTS) {
|
||||
const s = read(`goodnews:match:${v}:${date}`);
|
||||
if (s && s.done) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Eligible daily-cadence items, in display order. New daily games join here.
|
||||
export const RITUAL_ITEMS = [
|
||||
{ key: 'brief', label: 'Brief', href: '/', done: briefSeen },
|
||||
{ key: 'word', label: 'Daily Word', href: '/play?game=word', done: wordEnjoyed },
|
||||
{ key: 'wordsearch', label: 'Word Search', href: '/play?game=wordsearch', done: wordsearchEnjoyed },
|
||||
{ key: 'bloom', label: 'Bloom', href: '/play?game=bloom&v=daily', done: bloomEnjoyed },
|
||||
{ key: 'match', label: 'Memory Match', href: '/play?game=match&v=daily-icons-standard', done: matchEnjoyed },
|
||||
];
|
||||
|
||||
const ALL_KEYS = RITUAL_ITEMS.map((i) => i.key);
|
||||
// The default (out-of-box) set is a CURATED subset of the eligible items, kept
|
||||
// separate from RITUAL_ITEMS on purpose: adding a new eligible daily game must
|
||||
// NOT auto-bloat every uncustomized reader's set. A new game becomes selectable
|
||||
// in the chooser immediately; whether it joins the default is a deliberate
|
||||
// case-by-case decision (add its key here). `null` pref = "follow this default."
|
||||
export const DEFAULT_KEYS = ['brief', 'word', 'wordsearch', 'bloom'];
|
||||
|
||||
// Normalize the reader's selection: null/undefined → the curated default;
|
||||
// an array → those keys in canonical order; unknown keys ignored.
|
||||
export function ritualKeys(enabled) {
|
||||
if (!Array.isArray(enabled)) return DEFAULT_KEYS.filter((k) => ALL_KEYS.includes(k));
|
||||
return ALL_KEYS.filter((k) => enabled.includes(k));
|
||||
}
|
||||
|
||||
// The day's ritual snapshot for the reader's chosen set. Reads localStorage on
|
||||
// each call (cheap) — callers recompute on mount / navigation / focus.
|
||||
export function ritualState(date, enabled) {
|
||||
const keys = ritualKeys(enabled);
|
||||
const items = RITUAL_ITEMS
|
||||
.filter((i) => keys.includes(i.key))
|
||||
.map((i) => ({ key: i.key, label: i.label, href: i.href, done: i.done(date) }));
|
||||
return { items, count: items.filter((i) => i.done).length, total: items.length };
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { ritualState, ritualKeys, markBriefSeen, briefSeen, RITUAL_ITEMS, DEFAULT_KEYS } from './ritual.js';
|
||||
|
||||
// Minimal localStorage stand-in (jsdom may not provide one in this config).
|
||||
beforeEach(() => {
|
||||
const store = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => store.set(k, String(v)),
|
||||
removeItem: (k) => store.delete(k),
|
||||
clear: () => store.clear(),
|
||||
};
|
||||
});
|
||||
|
||||
const D = '2026-06-14';
|
||||
const done = (s, key) => s.items.find((i) => i.key === key)?.done;
|
||||
|
||||
describe('ritualState', () => {
|
||||
it('defaults to the curated DEFAULT_KEYS set, none done', () => {
|
||||
const s = ritualState(D);
|
||||
expect(s.items.map((i) => i.key)).toEqual(DEFAULT_KEYS);
|
||||
expect(s.count).toBe(0);
|
||||
expect(s.total).toBe(DEFAULT_KEYS.length);
|
||||
});
|
||||
|
||||
it('counts the brief only once the end-cap is marked', () => {
|
||||
expect(briefSeen(D)).toBe(false);
|
||||
markBriefSeen(D);
|
||||
expect(done(ritualState(D), 'brief')).toBe(true);
|
||||
expect(ritualState(D).count).toBe(1);
|
||||
});
|
||||
|
||||
it('counts a word at a terminal state, a word search when cleared', () => {
|
||||
localStorage.setItem(`goodnews:word:5:${D}`, JSON.stringify({ status: 'playing', guesses: ['aaaaa'] }));
|
||||
expect(done(ritualState(D), 'word')).toBe(false); // in-progress isn't "enjoyed"
|
||||
localStorage.setItem(`goodnews:word:6:${D}`, JSON.stringify({ status: 'lost' }));
|
||||
expect(done(ritualState(D), 'word')).toBe(true);
|
||||
localStorage.setItem(`goodnews:wordsearch:med:${D}`, JSON.stringify({ status: 'done', foundWords: ['x'] }));
|
||||
expect(done(ritualState(D), 'wordsearch')).toBe(true);
|
||||
});
|
||||
|
||||
it('counts Bloom only when the daily reached the top tier (top flag)', () => {
|
||||
localStorage.setItem(`goodnews:bloom:${D}`, JSON.stringify({ found: ['area'], score: 4 }));
|
||||
expect(done(ritualState(D), 'bloom')).toBe(false); // played but not top tier
|
||||
localStorage.setItem(`goodnews:bloom:${D}`, JSON.stringify({ found: ['area'], score: 99, top: true }));
|
||||
expect(done(ritualState(D), 'bloom')).toBe(true);
|
||||
});
|
||||
|
||||
it('honors a curated set — only chosen items appear and count', () => {
|
||||
markBriefSeen(D);
|
||||
localStorage.setItem(`goodnews:bloom:${D}`, JSON.stringify({ top: true }));
|
||||
const s = ritualState(D, ['brief', 'bloom']); // reader's chosen set
|
||||
expect(s.items.map((i) => i.key)).toEqual(['brief', 'bloom']);
|
||||
expect(s.total).toBe(2);
|
||||
expect(s.count).toBe(2);
|
||||
});
|
||||
|
||||
it('is scoped per server date — yesterday does not leak into today', () => {
|
||||
markBriefSeen('2026-06-13');
|
||||
expect(ritualState(D).count).toBe(0);
|
||||
});
|
||||
|
||||
it('empty/missing date → nothing done (offline-safe)', () => {
|
||||
expect(ritualState('').count).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ritualKeys', () => {
|
||||
it('null/undefined → the curated default (NOT all eligible items)', () => {
|
||||
expect(ritualKeys(null)).toEqual(DEFAULT_KEYS);
|
||||
expect(ritualKeys(undefined)).toEqual(DEFAULT_KEYS);
|
||||
// default is a subset of eligible — adding a future eligible game must not auto-join it
|
||||
expect(DEFAULT_KEYS.every((k) => RITUAL_ITEMS.some((i) => i.key === k))).toBe(true);
|
||||
});
|
||||
it('an array → those keys in canonical order, unknown ignored', () => {
|
||||
expect(ritualKeys(['bloom', 'brief', 'nope'])).toEqual(['brief', 'bloom']);
|
||||
expect(ritualKeys([])).toEqual([]); // explicitly empty set
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
// The Zen Den aquarium — raw Three.js, lazy-loaded only on /zen (keeps three's
|
||||
// ~150KB off every other page). Phase A: get UB alive on screen (Idle_swim),
|
||||
// soft light, simple backdrop, capped pixel ratio, reduced-motion aware, clean
|
||||
// teardown. The behavior engine (steering / spine-bend turns / darts) lands in
|
||||
// Phase B; the structure here (kept animation actions, a single update loop)
|
||||
// is set up to grow into it.
|
||||
//
|
||||
// UB ships (ub-split.glb) as THREE materials — UB_Body, UB_Fins, UB_Tail — so each
|
||||
// renders differently. The single-mesh approach hit a hard ceiling: a transparent,
|
||||
// double-sided whole fish can't depth-sort its own triangles (tail/eye drew through
|
||||
// the face), and any alpha cutoff that cleaned the fin fringe also chewed the fins.
|
||||
// Thin midline fans (tail especially) also self-overlap as they fold/sweep, so even
|
||||
// single-sided *transparent* tail triangles bleed through each other → "two tails".
|
||||
// The render is fully parameter-driven (see DEFAULTS) so it can be tuned live via
|
||||
// /zen?debug=1 — tuning a blind WebGL fish through redeploys was the slow path.
|
||||
import * as THREE from 'three';
|
||||
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
|
||||
|
||||
const MODEL_URL = '/models/ub-split.glb';
|
||||
|
||||
// Converged Phase-A starting point (Claude + Codex): a hair off strict profile so
|
||||
// the mouth/chin reads naturally; tail OPAQUE alpha-tested (one coherent tail, no
|
||||
// blend bleed); fins translucent + single-sided (kills the double-sided ghosting
|
||||
// that read as an off-center dorsal). All live-tunable at /zen?debug=1.
|
||||
export const DEFAULTS = {
|
||||
yaw: Math.PI / 2 + 0.10, // ub.rotation.y — slight 3/4 view
|
||||
pitch: 0, // ub.rotation.x — nose up/down
|
||||
tailTranslucent: false, // false = opaque alpha-tested (coherent); true = blended
|
||||
tailSide: 'front', // front | back | double
|
||||
tailAlphaTest: 0.035,
|
||||
tailOpacity: 1.0, // only when translucent
|
||||
finSide: 'front', // front | back | double
|
||||
finOpacity: 0.75,
|
||||
finAlphaTest: 0.02,
|
||||
paused: false,
|
||||
frame: 0, // 0..1 scrub position when paused
|
||||
};
|
||||
|
||||
const SIDE = { front: THREE.FrontSide, back: THREE.BackSide, double: THREE.DoubleSide };
|
||||
|
||||
export async function createAquarium(canvas, initial = {}) {
|
||||
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
const params = { ...DEFAULTS, ...initial };
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); // mobile thermal guard
|
||||
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
|
||||
camera.position.set(0, 0.25, 4.6);
|
||||
camera.lookAt(0, 0, 0);
|
||||
|
||||
// Soft, warm light — calm, gently dimensional (no harsh shadows in Phase A).
|
||||
scene.add(new THREE.HemisphereLight(0xffffff, 0x8aa0b0, 1.05));
|
||||
const key = new THREE.DirectionalLight(0xfff2e0, 1.25);
|
||||
key.position.set(2.5, 4, 3);
|
||||
scene.add(key);
|
||||
|
||||
function resize() {
|
||||
const w = canvas.clientWidth || 1;
|
||||
const h = canvas.clientHeight || 1;
|
||||
renderer.setSize(w, h, false);
|
||||
camera.aspect = w / h;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
// Load UB.
|
||||
const gltf = await new GLTFLoader().loadAsync(MODEL_URL);
|
||||
const ub = gltf.scene;
|
||||
|
||||
// Center at origin and scale to a consistent on-screen size, regardless of the
|
||||
// model's native units.
|
||||
const box = new THREE.Box3().setFromObject(ub);
|
||||
const size = box.getSize(new THREE.Vector3());
|
||||
const center = box.getCenter(new THREE.Vector3());
|
||||
ub.position.sub(center);
|
||||
const maxDim = Math.max(size.x, size.y, size.z) || 1;
|
||||
ub.scale.setScalar(2.6 / maxDim);
|
||||
|
||||
// Collect the three named materials/meshes so applyMaterials can retarget them
|
||||
// live without re-traversing semantics.
|
||||
const part = {};
|
||||
ub.traverse((o) => { if (o.isMesh && o.material?.name) part[o.material.name] = o; });
|
||||
|
||||
function applyMaterials() {
|
||||
const body = part.UB_Body, fins = part.UB_Fins, tail = part.UB_Tail;
|
||||
if (body) {
|
||||
const m = body.material;
|
||||
m.transparent = false; m.opacity = 1; m.alphaTest = 0;
|
||||
m.depthWrite = true; m.depthTest = true; m.side = THREE.FrontSide;
|
||||
body.renderOrder = 1; m.needsUpdate = true;
|
||||
}
|
||||
if (fins) {
|
||||
const m = fins.material;
|
||||
m.transparent = true; m.opacity = params.finOpacity; m.alphaTest = params.finAlphaTest;
|
||||
m.alphaToCoverage = true; m.depthWrite = false; m.depthTest = true;
|
||||
m.side = SIDE[params.finSide] ?? THREE.FrontSide;
|
||||
fins.renderOrder = 3; m.needsUpdate = true;
|
||||
}
|
||||
if (tail) {
|
||||
const m = tail.material;
|
||||
m.alphaTest = params.tailAlphaTest; m.alphaToCoverage = true; m.depthTest = true;
|
||||
m.side = SIDE[params.tailSide] ?? THREE.FrontSide;
|
||||
if (params.tailTranslucent) {
|
||||
m.transparent = true; m.opacity = params.tailOpacity; m.depthWrite = false;
|
||||
} else {
|
||||
m.transparent = false; m.opacity = 1; m.depthWrite = true; // opaque, coherent
|
||||
}
|
||||
tail.renderOrder = 2; m.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
function applyTransform() {
|
||||
ub.rotation.set(params.pitch, params.yaw, 0);
|
||||
}
|
||||
|
||||
applyMaterials();
|
||||
applyTransform();
|
||||
scene.add(ub);
|
||||
|
||||
// Animation — keep every action by name so Phase B can crossfade (Idle_swim →
|
||||
// Eat_Up / Roll). Idle_swim is the base loop.
|
||||
const mixer = new THREE.AnimationMixer(ub);
|
||||
const actions = {};
|
||||
for (const clip of gltf.animations) actions[clip.name] = mixer.clipAction(clip);
|
||||
const baseClip = (actions.Idle_swim ? actions.Idle_swim : mixer.clipAction(gltf.animations[0]));
|
||||
baseClip.play();
|
||||
const baseDuration = baseClip.getClip().duration || 1;
|
||||
mixer.timeScale = reduced ? 0.6 : 1; // calmer when reduced-motion
|
||||
|
||||
resize();
|
||||
// The canvas lives in a responsive container; a ResizeObserver catches layout
|
||||
// shifts (mobile browser-chrome show/hide, future side panels) a bare 'resize' misses.
|
||||
const onResize = () => resize();
|
||||
window.addEventListener('resize', onResize);
|
||||
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(onResize) : null;
|
||||
ro?.observe(canvas);
|
||||
|
||||
const clock = new THREE.Clock();
|
||||
renderer.setAnimationLoop(() => {
|
||||
const dt = clock.getDelta();
|
||||
if (params.paused) {
|
||||
mixer.setTime(params.frame * baseDuration); // scrub to a frozen frame
|
||||
} else {
|
||||
mixer.update(dt);
|
||||
}
|
||||
renderer.render(scene, camera);
|
||||
});
|
||||
|
||||
return {
|
||||
// exposed for Phase B tuning + the /zen?debug=1 panel
|
||||
ub, actions, mixer, scene, camera, params, baseDuration,
|
||||
// live setter: merge new values, re-apply materials + transform
|
||||
setParams(next = {}) {
|
||||
Object.assign(params, next);
|
||||
applyMaterials();
|
||||
applyTransform();
|
||||
return { ...params };
|
||||
},
|
||||
getParams() { return { ...params }; },
|
||||
dispose() {
|
||||
renderer.setAnimationLoop(null);
|
||||
window.removeEventListener('resize', onResize);
|
||||
ro?.disconnect();
|
||||
scene.traverse((o) => {
|
||||
o.geometry?.dispose?.();
|
||||
const mats = Array.isArray(o.material) ? o.material : o.material ? [o.material] : [];
|
||||
for (const m of mats) {
|
||||
for (const v of Object.values(m)) v?.isTexture && v.dispose();
|
||||
m.dispose?.();
|
||||
}
|
||||
});
|
||||
renderer.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
import { initHistory, deviceIds, record, loadServerHistory } from '$lib/history.svelte.js';
|
||||
import { trackVisit, track } from '$lib/analytics.js';
|
||||
import { pwa, installApp, dismissPwa } from '$lib/pwa.svelte.js';
|
||||
import { ritualState, markBriefSeen } from '$lib/ritual.js';
|
||||
|
||||
let moods = $state([]);
|
||||
let topics = $state([]);
|
||||
@@ -28,6 +29,7 @@
|
||||
// /?view=<mood|topic>, or bare / for Highlights.
|
||||
function parseView(url) {
|
||||
const p = url.searchParams;
|
||||
if ((p.get('q') || '').trim()) return 'search';
|
||||
if (p.get('source')) return 'source:' + p.get('source');
|
||||
if (p.get('tag')) return 'tag:' + p.get('tag');
|
||||
return p.get('view') || 'today';
|
||||
@@ -39,9 +41,25 @@
|
||||
return '/?view=' + encodeURIComponent(key);
|
||||
}
|
||||
let selected = $derived(parseView($page.url));
|
||||
let searchQuery = $derived(($page.url.searchParams.get('q') || '').trim());
|
||||
let searchOpen = $state(false);
|
||||
let searchText = $state('');
|
||||
function toggleSearch() { searchOpen = !searchOpen; if (searchOpen) searchText = searchQuery; }
|
||||
function runSearch() {
|
||||
const q = searchText.trim();
|
||||
goto(q ? '/?q=' + encodeURIComponent(q) : '/');
|
||||
}
|
||||
let sourceNames = $state({}); // source id -> name, for an instant header label
|
||||
let brief = $state(null);
|
||||
let heroIdx = $state(0);
|
||||
// Daily Ritual ("today's calm set") — derived from local game state + a
|
||||
// brief-seen flag, keyed on the brief's SERVER date (never the browser's).
|
||||
let ritual = $state({ items: [], count: 0, total: 0 });
|
||||
let endcapEl = $state(null);
|
||||
function refreshRitual() {
|
||||
const d = brief?.brief_date;
|
||||
if (d) ritual = ritualState(d, prefs.data.ritual);
|
||||
}
|
||||
let feed = $state([]);
|
||||
let feedDone = $state(false); // no more pages for the current feed view
|
||||
let loadingMore = $state(false);
|
||||
@@ -136,6 +154,7 @@
|
||||
);
|
||||
let viewLabel = $derived(
|
||||
selected === 'today' ? 'Highlights from Today'
|
||||
: selected === 'search' ? `Search: “${searchQuery}”`
|
||||
: selected.startsWith('source:') ? (sourceNames[selected.slice(7)] ?? feed[0]?.source ?? 'Source')
|
||||
: selected === 'latest' ? 'Latest'
|
||||
: selected === 'following' ? 'Following'
|
||||
@@ -144,6 +163,7 @@
|
||||
);
|
||||
let viewSubtitle = $derived(
|
||||
selected === 'today' ? localDateLabel(brief)
|
||||
: selected === 'search' ? 'Results across Upbeat Bytes'
|
||||
: selected.startsWith('source:') ? 'Latest from this source'
|
||||
: selected === 'latest' ? 'Freshest calm reads — newest first'
|
||||
: selected === 'following' ? 'From the sources & topics you follow'
|
||||
@@ -262,6 +282,10 @@
|
||||
function feedUrl(key, offset) {
|
||||
const ex = Array.from(dismissed).join(',');
|
||||
const exq = ex ? `&exclude=${ex}` : '';
|
||||
if (key === 'search') {
|
||||
const q = P.param(prefs.data);
|
||||
return `/api/search?q=${encodeURIComponent(searchQuery)}&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}`;
|
||||
}
|
||||
if (key === 'latest') {
|
||||
const q = P.param(prefs.data);
|
||||
return `/api/feed?sort=latest&limit=${PAGE}&offset=${offset}${q ? '&' + q : ''}${exq}`;
|
||||
@@ -340,9 +364,25 @@
|
||||
// an accurate count. Clamped at 0, so an out-of-app landing stays app-safe.
|
||||
if (nav.type === 'goto' || nav.type === 'link') appNavDepth += 1;
|
||||
else if (nav.type === 'popstate') appNavDepth = Math.max(0, appNavDepth + (nav.delta ?? -1));
|
||||
if (selected === 'search') { searchText = searchQuery; searchOpen = true; } // prefill on shared/back links
|
||||
loadView(selected);
|
||||
});
|
||||
|
||||
// The brief is "enjoyed" only when its end-cap actually scrolls into view (the
|
||||
// finite read), not on mere page-open. When it does, mark it for today and
|
||||
// refresh the calm-set — which also picks up any Word / Word Search played in
|
||||
// this session. The block lives inside the end-cap, so by the time the reader
|
||||
// sees it, the brief tick has already settled.
|
||||
$effect(() => {
|
||||
if (!endcapEl || !brief?.brief_date) return;
|
||||
const date = brief.brief_date;
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries.some((e) => e.isIntersecting)) { markBriefSeen(date); refreshRitual(); }
|
||||
}, { threshold: 0.4 });
|
||||
io.observe(endcapEl);
|
||||
return () => io.disconnect();
|
||||
});
|
||||
|
||||
// "Load more" for any feed view (Latest, topics, tags, moods): fetch the next
|
||||
// page at the current length and append, de-duping against what's shown.
|
||||
async function loadMore() {
|
||||
@@ -449,6 +489,7 @@
|
||||
dismissed = new Set(P.loadJSON(DISMISSED_KEY, []));
|
||||
refreshAuth();
|
||||
trackVisit();
|
||||
if (selected === 'search') { searchText = searchQuery; searchOpen = true; } // prefill on direct/shared link
|
||||
// Instant paint: render the last saved Today brief immediately and refresh
|
||||
// it behind the scenes, so the first view never blocks on a (personalized,
|
||||
// origin-bound) /api/brief request. "Gathering the good news…" then only
|
||||
@@ -517,6 +558,9 @@
|
||||
{#if viewSubtitle}<p class="sub">{viewSubtitle}</p>{/if}
|
||||
</div>
|
||||
<div class="vh-actions">
|
||||
<button class="searchtoggle" class:on={searchOpen || selected === 'search'} onclick={toggleSearch} aria-label="Search articles" title="Search articles">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7" fill="none" stroke="currentColor" stroke-width="2"/><path d="M21 21l-4.4-4.4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
|
||||
</button>
|
||||
{#if auth.user && followTarget}
|
||||
<button class="followbtn" class:on={isFollowing(followTarget.kind, followTarget.value)}
|
||||
onclick={() => toggleFollow(followTarget.kind, followTarget.value)}>
|
||||
@@ -532,6 +576,16 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if searchOpen || selected === 'search'}
|
||||
<div class="searchbar rise">
|
||||
<input type="search" bind:value={searchText} placeholder="Search articles, topics, or a source…"
|
||||
autocapitalize="off" autocomplete="off" spellcheck="false"
|
||||
onkeydown={(e) => (e.key === 'Enter' ? runSearch() : e.key === 'Escape' ? (searchOpen = false) : null)} />
|
||||
<button class="searchgo" onclick={runSearch}>Search</button>
|
||||
{#if selected === 'search'}<button class="searchclear" onclick={() => { searchOpen = false; goto('/'); }}>Clear</button>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selected === 'today'}
|
||||
{#if sinceCount > 0 && !sinceDismissed}
|
||||
<div class="welcomeback rise">
|
||||
@@ -563,9 +617,24 @@
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
<div class="endcap rise">
|
||||
<div class="endcap rise" bind:this={endcapEl}>
|
||||
<p class="endmark">✦ that's the good news for today ✦</p>
|
||||
<p class="endsub">You're caught up for now.</p>
|
||||
{#if 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.key === 'brief'}{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}
|
||||
{#if auth.user?.digest_enabled}
|
||||
<p class="digestnote">Tomorrow's brief is headed to your inbox ☕</p>
|
||||
{:else}
|
||||
@@ -592,6 +661,8 @@
|
||||
{:else}
|
||||
<p class="endcap rise">✦ you're all caught up ✦</p>
|
||||
{/if}
|
||||
{:else if selected === 'search'}
|
||||
<p class="muted center pad">No articles found for “{searchQuery}”. Try a different word, or a source name like “Nature”.</p>
|
||||
{:else if selected === 'following'}
|
||||
<p class="muted center pad">
|
||||
{#if auth.user}Nothing here yet — open a source or a grouping and tap <strong>Follow</strong> to fill this lane with what you care about.
|
||||
@@ -660,6 +731,21 @@
|
||||
.viewback:hover { border-color: var(--accent); }
|
||||
.viewback svg { width: 16px; height: 16px; display: block; }
|
||||
.vh-actions { flex-shrink: 0; display: flex; align-items: center; gap: 8px; margin-top: 8px; }
|
||||
.searchtoggle { display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px;
|
||||
background: none; border: 1px solid var(--line); color: var(--accent-deep); border-radius: 999px;
|
||||
cursor: pointer; transition: border-color 0.14s ease, background 0.14s ease; }
|
||||
.searchtoggle:hover { border-color: var(--accent); }
|
||||
.searchtoggle.on { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
.searchtoggle svg { width: 17px; height: 17px; display: block; }
|
||||
.searchbar { display: flex; gap: 8px; margin: 0 0 18px; }
|
||||
.searchbar input { flex: 1; min-width: 0; font: inherit; font-size: 1rem; padding: 10px 14px;
|
||||
border: 1px solid var(--line); border-radius: 10px; background: var(--surface); color: var(--ink); }
|
||||
.searchbar input:focus { outline: none; border-color: var(--accent); }
|
||||
.searchgo { background: var(--accent); color: #fff; border: none; border-radius: 10px; padding: 0 18px;
|
||||
font: inherit; font-weight: 600; cursor: pointer; }
|
||||
.searchgo:hover { background: var(--accent-deep); }
|
||||
.searchclear { background: none; border: 1px solid var(--line); color: var(--muted); border-radius: 10px;
|
||||
padding: 0 14px; font: inherit; cursor: pointer; }
|
||||
.followbtn {
|
||||
display: inline-flex; align-items: center; gap: 5px; white-space: nowrap;
|
||||
background: none; border: 1px solid var(--accent); color: var(--accent-deep);
|
||||
@@ -713,6 +799,36 @@
|
||||
.endcap .digestcta:hover { background: var(--accent-deep); }
|
||||
.endcap .digestcta:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
/* Daily Ritual — "today's calm set". Gentle, non-instrumental: a soft check
|
||||
for what's been enjoyed, no streak, no pressure to finish the rest. */
|
||||
.calmset {
|
||||
margin: 20px auto 4px; max-width: 320px; padding-top: 16px;
|
||||
border-top: 1px solid var(--line); font-style: normal; font-family: var(--label);
|
||||
}
|
||||
.cs-head {
|
||||
margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.13em;
|
||||
font-size: 0.66rem; font-weight: 600; color: var(--accent-deep);
|
||||
}
|
||||
.cs-items {
|
||||
list-style: none; margin: 0; padding: 0; display: flex; gap: 16px;
|
||||
justify-content: center; 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-size: 0.82rem; color: var(--muted); }
|
||||
.cs-edit { color: var(--accent-deep); text-decoration: underline; white-space: nowrap; }
|
||||
|
||||
/* "Since you last visited" — a calm welcome-back cue on Highlights */
|
||||
.welcomeback {
|
||||
display: flex; align-items: center; gap: 12px; justify-content: space-between;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { getJSON, postJSON } from '$lib/api.js';
|
||||
import { auth, savedIds, refresh, toggleFollow } from '$lib/auth.svelte.js';
|
||||
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
|
||||
import { RITUAL_ITEMS, ritualKeys, DEFAULT_KEYS } from '$lib/ritual.js';
|
||||
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
|
||||
import { track } from '$lib/analytics.js';
|
||||
import { openFeedback } from '$lib/feedback.svelte.js';
|
||||
@@ -25,9 +26,24 @@
|
||||
{ key: 'following', label: 'Following' },
|
||||
{ key: 'history', label: 'History' },
|
||||
{ key: 'lanes', label: 'Lanes' },
|
||||
{ key: 'calmset', label: 'Calm set' },
|
||||
{ key: 'boundaries', label: 'Boundaries' },
|
||||
];
|
||||
|
||||
// Calm set: which daily items make up the reader's gentle daily ritual.
|
||||
let calmEnabled = $derived(new Set(ritualKeys(prefs.data.ritual)));
|
||||
function toggleCalmItem(key) {
|
||||
const cur = new Set(ritualKeys(prefs.data.ritual));
|
||||
cur.has(key) ? cur.delete(key) : cur.add(key);
|
||||
const keys = RITUAL_ITEMS.map((i) => i.key).filter((k) => cur.has(k));
|
||||
// null = "follow the curated default" (only when the selection IS the default);
|
||||
// otherwise store the explicit chosen list (frozen — future games won't auto-join).
|
||||
const isDefault = keys.length === DEFAULT_KEYS.length && DEFAULT_KEYS.every((k) => cur.has(k));
|
||||
prefs.data.ritual = isDefault ? null : keys;
|
||||
prefs.data = { ...prefs.data };
|
||||
persistPrefs();
|
||||
}
|
||||
|
||||
let follows = $state([]);
|
||||
let followsReady = $state(false);
|
||||
async function loadFollowsList() {
|
||||
@@ -112,6 +128,25 @@
|
||||
<p class="muted">Loading…</p>
|
||||
{/if}
|
||||
|
||||
{:else if section === 'calmset'}
|
||||
<section class="panel">
|
||||
<h2>Your calm set</h2>
|
||||
<p class="dnote">The gentle daily loop shown on Highlights and Play — a finite "today's set"
|
||||
you can finish and feel caught up. Choose what fills yours, like building a workout. No
|
||||
pressure, no streaks; uncheck anything you'd rather not see there.</p>
|
||||
<ul class="calmpick">
|
||||
{#each RITUAL_ITEMS as it (it.key)}
|
||||
<li>
|
||||
<button class="cpitem" class:on={calmEnabled.has(it.key)} onclick={() => toggleCalmItem(it.key)}
|
||||
aria-pressed={calmEnabled.has(it.key)}>
|
||||
<span class="cpmark" aria-hidden="true"></span>{it.label}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if calmEnabled.size === 0}<p class="empty">Your calm set is empty — it won't show until you add something.</p>{/if}
|
||||
</section>
|
||||
|
||||
{:else if section === 'boundaries'}
|
||||
<BoundariesPanel prefs={prefs.data} onchange={persistPrefs} />
|
||||
|
||||
@@ -226,6 +261,22 @@
|
||||
.digest { margin-top: 16px; }
|
||||
.digest h2 { font-size: 1.1rem; margin: 0 0 6px; }
|
||||
.dnote { color: var(--muted); font-size: 0.9rem; margin: 0 0 14px; line-height: 1.5; }
|
||||
.calmpick { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; max-width: 360px; }
|
||||
.cpitem {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
|
||||
padding: 12px 16px; font: inherit; font-weight: 600; color: var(--muted); cursor: pointer;
|
||||
transition: border-color 0.14s ease, color 0.14s ease;
|
||||
}
|
||||
.cpitem.on { color: var(--ink); border-color: var(--accent); }
|
||||
.cpitem:hover { border-color: var(--accent); }
|
||||
.cpmark { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--line); flex-shrink: 0;
|
||||
transition: background 0.14s ease, border-color 0.14s ease; }
|
||||
.cpitem.on .cpmark {
|
||||
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: 14px; background-repeat: no-repeat; background-position: center;
|
||||
}
|
||||
.dtoggle {
|
||||
font: inherit; font-size: 0.9rem; border-radius: 999px; padding: 9px 18px; cursor: pointer;
|
||||
border: 1px solid var(--accent); background: var(--surface); color: var(--accent-deep);
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { page } from '$app/stores';
|
||||
import { getJSON, postJSON, delJSON } from '$lib/api.js';
|
||||
import { auth, refresh } from '$lib/auth.svelte.js';
|
||||
import EmojiPicker from '$lib/EmojiPicker.svelte';
|
||||
|
||||
let stats = $state(null);
|
||||
let feedback = $state([]);
|
||||
@@ -31,26 +32,192 @@
|
||||
// Load all panels in PARALLEL — these were sequential awaits, so the page
|
||||
// paid the (uncached, origin) round-trip six times back-to-back. One batch
|
||||
// instead of a chain.
|
||||
const [, fb, cand, wp, ce, ws] = await Promise.all([
|
||||
const [, fb, cand, wp, ce, ws, bq] = await Promise.all([
|
||||
loadStats(),
|
||||
getJSON('/api/admin/feedback'),
|
||||
getJSON('/api/admin/candidates'),
|
||||
getJSON('/api/admin/word/pool'),
|
||||
getJSON('/api/admin/client-errors'),
|
||||
getJSON('/api/admin/wordsearch/themes'),
|
||||
getJSON('/api/admin/bloom/reports').catch(() => null),
|
||||
]);
|
||||
feedback = fb;
|
||||
candidates = cand;
|
||||
wpPool = wp;
|
||||
clientErrors = ce;
|
||||
wsThemes = ws;
|
||||
if (bq) { bloomReports = bq.reports || []; bloomOverrides = bq.overrides || []; }
|
||||
} catch {
|
||||
error = "Couldn't load stats.";
|
||||
}
|
||||
});
|
||||
|
||||
// --- Publishing Desk: build the X share queue, write blurbs, open in X ---------
|
||||
let pubItems = $state([]); // active: queued/drafting/opened
|
||||
let pubArchived = $state([]); // recoverable tray: skipped/snoozed
|
||||
let pubBuilding = $state(false);
|
||||
let pubLast = $state(null); // last build summary {added, active, ranked_by}
|
||||
let pubError = $state(null); // last build error (e.g. non-model failure), surfaced to the user
|
||||
let pubLoaded = $state(false);
|
||||
let pubHandleInput = $state({}); // keyed "itemId:entity" → handle being saved
|
||||
let pubPollTimer = null;
|
||||
const pubDraftTimers = {};
|
||||
|
||||
async function loadPublish() {
|
||||
try {
|
||||
const r = await getJSON('/api/admin/publishing/queue?archived=true');
|
||||
pubBuilding = r.building; pubLast = r.last; pubError = r.error || null;
|
||||
const items = r.items || [];
|
||||
pubItems = items.filter((i) => ['queued', 'drafting', 'opened'].includes(i.status));
|
||||
pubArchived = items.filter((i) => ['skipped', 'snoozed'].includes(i.status));
|
||||
pubLoaded = true;
|
||||
} catch { /* leave as-is */ }
|
||||
}
|
||||
function pollPublish() {
|
||||
clearTimeout(pubPollTimer);
|
||||
pubPollTimer = setTimeout(async () => {
|
||||
await loadPublish();
|
||||
if (pubBuilding) pollPublish();
|
||||
}, 1500);
|
||||
}
|
||||
async function buildPublish() {
|
||||
pubBuilding = true;
|
||||
try { await postJSON('/api/admin/publishing/build', {}); pollPublish(); }
|
||||
catch { pubBuilding = false; }
|
||||
}
|
||||
function onDraftInput(item) {
|
||||
clearTimeout(pubDraftTimers[item.id]);
|
||||
pubDraftTimers[item.id] = setTimeout(() => {
|
||||
postJSON(`/api/admin/publishing/${item.id}/draft`, { draft_text: item.draft_text || '' }).catch(() => {});
|
||||
}, 700); // debounced autosave
|
||||
}
|
||||
async function pubSetStatus(item, status, extra = {}) {
|
||||
clearTimeout(pubDraftTimers[item.id]); // cancel a pending autosave so it can't land after the transition
|
||||
try { await postJSON(`/api/admin/publishing/${item.id}/status`, { status, ...extra }); await loadPublish(); }
|
||||
catch (e) { item._err = e?.message || 'Action failed.'; }
|
||||
}
|
||||
// "Posted" is a confirm step: the user may have edited the text inside X, so we let
|
||||
// them correct the saved text + paste the real post URL before recording it.
|
||||
async function pubConfirmPosted(item) {
|
||||
await pubSetStatus(item, 'posted', {
|
||||
final_text: item.draft_text || '',
|
||||
post_url: (item._postUrl || '').trim() || null,
|
||||
});
|
||||
}
|
||||
async function pubRestore(item) {
|
||||
try { await postJSON(`/api/admin/publishing/${item.id}/restore`, {}); await loadPublish(); } catch { /* ignore */ }
|
||||
}
|
||||
function markOpened(item) {
|
||||
// fire-and-forget so the X tab opens immediately from the click (no awaited call
|
||||
// before navigation, or the browser may block the popup)
|
||||
if (item.status !== 'opened') {
|
||||
postJSON(`/api/admin/publishing/${item.id}/status`, { status: 'opened' }).catch(() => {});
|
||||
item.status = 'opened';
|
||||
}
|
||||
}
|
||||
async function pubSaveHandle(item, entity) {
|
||||
const h = (pubHandleInput[`${item.id}:${entity}`] || '').trim();
|
||||
if (!h) return;
|
||||
try { await postJSON('/api/admin/publishing/handles', { entity_name: entity, handle: h }); await loadPublish(); }
|
||||
catch (e) { item._err = e?.message || 'Bad handle.'; }
|
||||
}
|
||||
function insertHandle(item, handle) {
|
||||
item.draft_text = ((item.draft_text || '').trimEnd() + ' ' + handle + ' ').trimStart();
|
||||
onDraftInput(item);
|
||||
}
|
||||
// Emoji picker: a full searchable set (EmojiPicker.svelte) inserted at the caret so it
|
||||
// drops in mid-sentence cleanly. The textarea also still accepts your OS picker.
|
||||
let pubEmojiOpen = $state(null); // id of the card whose palette is open
|
||||
const pubTextareas = {}; // id -> <textarea> element, for caret-aware insert
|
||||
function regTextarea(node, id) {
|
||||
pubTextareas[id] = node;
|
||||
return { destroy() { if (pubTextareas[id] === node) delete pubTextareas[id]; } };
|
||||
}
|
||||
function insertEmoji(item, emoji) {
|
||||
const el = pubTextareas[item.id];
|
||||
const cur = item.draft_text || '';
|
||||
if (el && typeof el.selectionStart === 'number') {
|
||||
const s = el.selectionStart, e = el.selectionEnd;
|
||||
item.draft_text = cur.slice(0, s) + emoji + cur.slice(e);
|
||||
const pos = s + emoji.length;
|
||||
requestAnimationFrame(() => { el.focus(); el.setSelectionRange(pos, pos); }); // caret after the emoji
|
||||
} else {
|
||||
item.draft_text = cur + emoji;
|
||||
}
|
||||
onDraftInput(item);
|
||||
}
|
||||
function snoozeDate(days) {
|
||||
return new Date(Date.now() + days * 86400000).toISOString().slice(0, 19).replace('T', ' ');
|
||||
}
|
||||
function intentURL(item) {
|
||||
return `https://x.com/intent/tweet?text=${encodeURIComponent(item.draft_text || '')}&url=${encodeURIComponent(item.share_url || '')}`;
|
||||
}
|
||||
function findOnX(entity) {
|
||||
return `https://x.com/search?q=${encodeURIComponent(entity)}&f=user`;
|
||||
}
|
||||
// Weighted length ≈ X's counting: any URL = 23. The share link X auto-appends adds
|
||||
// ~24 (a space + the 23-char t.co link), so the live budget reflects the real tweet.
|
||||
function weightedLen(t) {
|
||||
const urls = (t || '').match(/https?:\/\/\S+/g) || [];
|
||||
return (t || '').replace(/https?:\/\/\S+/g, '').length + urls.length * 23;
|
||||
}
|
||||
function pubRemaining(item) {
|
||||
return 280 - (weightedLen(item.draft_text) + 24);
|
||||
}
|
||||
// Named entities (for Find-on-X + save-a-handle); the verified ones already show as
|
||||
// chips above, so this is mostly the not-yet-known ones.
|
||||
function namedEntities(item) {
|
||||
return (item.entities || []).slice(0, 5);
|
||||
}
|
||||
|
||||
$effect(() => { if (section === 'publish' && !pubLoaded) loadPublish(); });
|
||||
|
||||
let clientErrors = $state([]);
|
||||
|
||||
// "X ago" from a UTC 'YYYY-MM-DD HH:MM:SS' created_at — so a morning glance shows
|
||||
// whether the newest error is fresh or just last night.
|
||||
function ago(ts) {
|
||||
if (!ts) return '—';
|
||||
const d = new Date(ts.replace(' ', 'T') + 'Z');
|
||||
const s = Math.max(0, (Date.now() - d.getTime()) / 1000);
|
||||
if (s < 90) return 'just now';
|
||||
if (s < 5400) return Math.round(s / 60) + 'm ago';
|
||||
if (s < 129600) return Math.round(s / 3600) + 'h ago';
|
||||
return Math.round(s / 86400) + 'd ago';
|
||||
}
|
||||
// Classify a beacon by layer so one scary count doesn't conflate a 30s HTML stall
|
||||
// (incident) with a 5s app boot or a post-deploy chunk miss (benign).
|
||||
function errType(e) {
|
||||
if (e.bot) return { k: 'bot', label: 'bot' };
|
||||
const r = e.reason || '';
|
||||
if (/preload|dynamically imported|failed to fetch/i.test(r)) return { k: 'preload', label: 'preload' };
|
||||
const m = r.match(/^boot-slow.*?\bhtml\s+(\d+)ms/i);
|
||||
if (m) return Number(m[1]) >= 3000 ? { k: 'html', label: 'html-slow' } : { k: 'app', label: 'app-slow' };
|
||||
if (/^boot-slow/i.test(r)) return { k: 'app', label: 'app-slow' };
|
||||
return { k: 'runtime', label: 'runtime' };
|
||||
}
|
||||
|
||||
// --- Games: Bloom word curation (reports queue + allow/block overrides) ---
|
||||
let bloomReports = $state([]);
|
||||
let bloomOverrides = $state([]);
|
||||
let bloomOvrWord = $state('');
|
||||
async function loadBloomQueue() {
|
||||
try { const r = await getJSON('/api/admin/bloom/reports'); bloomReports = r.reports || []; bloomOverrides = r.overrides || []; }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
async function resolveBloom(id, action) {
|
||||
try { await postJSON('/api/admin/bloom/reports/' + id, { action }); await loadBloomQueue(); } catch { /* ignore */ }
|
||||
}
|
||||
async function addBloomOverride(action) {
|
||||
const w = bloomOvrWord.trim().toLowerCase();
|
||||
if (!w) return;
|
||||
try { await postJSON('/api/admin/bloom/overrides', { word: w, action }); bloomOvrWord = ''; await loadBloomQueue(); }
|
||||
catch { /* ignore */ }
|
||||
}
|
||||
async function removeBloomOverride(w) {
|
||||
try { await delJSON('/api/admin/bloom/overrides/' + encodeURIComponent(w)); await loadBloomQueue(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// --- Games: Daily Word pool ---
|
||||
let wpWord = $state('');
|
||||
let wpResult = $state(null); // lookup result for the current input
|
||||
@@ -168,6 +335,7 @@
|
||||
{ key: 'audience', label: 'Audience' },
|
||||
{ key: 'feedback', label: 'Feedback' },
|
||||
{ key: 'games', label: 'Games' },
|
||||
{ key: 'publish', label: 'Publishing' },
|
||||
];
|
||||
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
|
||||
// Unknown ?section= values fall back to Overview so the page never renders blank.
|
||||
@@ -282,7 +450,7 @@
|
||||
} catch (e) { s._artErr = e?.message || 'Could not load articles.'; }
|
||||
finally { s._artBusy = false; }
|
||||
}
|
||||
const ART_FILTERS = [['all', 'All'], ['accepted', 'Accepted'], ['rejected', 'Rejected'], ['no_image', 'No image'], ['duplicates', 'Duplicates']];
|
||||
const ART_FILTERS = [['all', 'All'], ['accepted', 'Accepted'], ['rejected', 'Rejected'], ['held', 'Held'], ['no_image', 'No image'], ['duplicates', 'Duplicates']];
|
||||
function pwBasis(sum) {
|
||||
if (!sum) return '';
|
||||
if (sum.paywall_override === 'free') return 'OFF (override)';
|
||||
@@ -309,6 +477,7 @@
|
||||
let addBusy = $state(false);
|
||||
let addErr = $state('');
|
||||
let pendingCandidates = $derived(candidates.filter((c) => c.status !== 'promoted' && c.status !== 'rejected'));
|
||||
let rejectedCandidates = $derived(candidates.filter((c) => c.status === 'rejected'));
|
||||
|
||||
async function addCandidate() {
|
||||
const url = newFeedUrl.trim();
|
||||
@@ -338,7 +507,7 @@
|
||||
try {
|
||||
// ~5-7s/item on the LAN model; bound the wait so a model stall can't pin
|
||||
// the button on "Deep-checking…" forever.
|
||||
const res = await postJSON(`/api/admin/candidates/${c.id}/preview?deep=true`, undefined, { timeout: 120000 });
|
||||
const res = await postJSON(`/api/admin/candidates/${c.id}/preview?deep=true`, undefined, { timeout: 180000 });
|
||||
Object.assign(c, res, { _deep: false });
|
||||
} catch (e) { c._err = e?.message || 'Deep preview failed.'; c._deep = false; }
|
||||
}
|
||||
@@ -365,6 +534,19 @@
|
||||
try { Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/reject`)); }
|
||||
catch { /* leave as-is */ }
|
||||
}
|
||||
// Codex's operating rule for the Deep-Preview access verdict, shown on hover.
|
||||
const ACC_HELP = {
|
||||
fine: 'Sample reads fine — judge mainly on content quality.',
|
||||
review: 'Click 2–3 example links before promoting — catches false-positive paywalls and bot-blocks (readable in a browser, blocked to us).',
|
||||
'reject-ready': 'Domain rule AND the sample agree it’s walled — usually reject, unless it’s a source you want and the examples open fine in your browser.',
|
||||
};
|
||||
// Send a rejected candidate back to staging for another look (status → suggested).
|
||||
async function restoreCandidate(c) {
|
||||
try {
|
||||
Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/restore`));
|
||||
candidates = [...candidates]; // nudge the derived queue/tray to recompute
|
||||
} catch { /* leave as-is */ }
|
||||
}
|
||||
|
||||
// Feedback inbox: filter + read/unread + delete.
|
||||
let fbCat = $state('all'); // 'all' | 'unread' | a category
|
||||
@@ -527,12 +709,13 @@
|
||||
</div>
|
||||
|
||||
{#if clientErrors.length}
|
||||
<h2>Recent load errors <span class="count">(last {clientErrors.length})</span></h2>
|
||||
<h2>Recent load errors <span class="count">(last {clientErrors.length} · newest {ago(clientErrors[0]?.created_at)})</span></h2>
|
||||
<ul class="cerrs">
|
||||
{#each clientErrors as e (e.created_at + e.reason)}
|
||||
{@const t = errType(e)}
|
||||
<li class:bot={e.bot}>
|
||||
<span class="ce-when">{fdate(e.created_at)}</span>
|
||||
<span class="ce-reason">{e.reason || '—'}{#if e.bot}<span class="ce-bot">bot</span>{/if}</span>
|
||||
<span class="ce-reason"><span class="ce-tag {t.k}">{t.label}</span>{e.reason || '—'}</span>
|
||||
<span class="ce-path">{e.path || '/'}</span>
|
||||
<span class="ce-ua">{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}</span>
|
||||
</li>
|
||||
@@ -639,9 +822,19 @@
|
||||
<div class="curl">{c.feed_url}</div>
|
||||
{#if c.preview}
|
||||
<div class="cprev">
|
||||
{c.preview.accepted ?? 0}/{c.preview.sampled ?? 0} sampled would pass{#if c.preview.acceptance_rate != null} · {Math.round(c.preview.acceptance_rate * 100)}% accept{/if}{#if c.preview.recent_7d != null} · {c.preview.recent_7d} in last 7d{/if}
|
||||
{c.preview.accepted ?? 0}/{c.preview.sampled ?? 0} sampled would pass{#if c.preview.acceptance_rate != null} · {Math.round(c.preview.acceptance_rate * 100)}% accept{/if}{#if c.preview.non_english} · <span class="held" title="Non-English items are held (English-only feed for now), not counted as rejections">{c.preview.non_english} held · non-English</span>{/if}{#if c.preview.recent_7d != null} · {c.preview.recent_7d} in last 7d{/if}
|
||||
{#if c.preview.classified}<span class="vbadge model" title="Scored by the real classifier — the true acceptance view">model-checked</span>{:else}<span class="vbadge fast" title="Fast keyword heuristic — an estimate. Run Deep preview for the model's real verdict.">quick estimate</span>{/if}
|
||||
{#if c.preview.paywall_rule}<span class="vbadge wall" title="This domain is on the paywall list (a hint, not a verdict)">paywall domain</span>{/if}
|
||||
{#if c.preview.examples_accepted?.length}<div class="cex">e.g. {c.preview.examples_accepted.slice(0, 3).join(' · ')}</div>{/if}
|
||||
{#if c.preview.access}
|
||||
<div class="caccess">
|
||||
<span class="acc-verdict {c.preview.access_verdict}" title={ACC_HELP[c.preview.access_verdict] || ''}>{c.preview.access_verdict}</span>
|
||||
<span class="acc-counts">Access: {c.preview.access.readable} readable · {c.preview.access.paywalled} paywalled{#if c.preview.access.blocked} · <span title="Couldn't fetch — may be a bot-block (readable in a browser), not a reader paywall">{c.preview.access.blocked} blocked</span>{/if}{#if c.preview.access.unknown} · {c.preview.access.unknown} unknown{/if} <span class="acc-of">({c.preview.access.checked} sampled)</span></span>
|
||||
{#if c.preview.access.examples?.length}
|
||||
<div class="acc-ex-row">{#each c.preview.access.examples as ex (ex.url)}<a class="acc-ex {ex.access}" href={ex.url} target="_blank" rel="noopener">{ex.access}↗</a>{/each}</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if c._err}<p class="cerr">{c._err}</p>{/if}
|
||||
@@ -659,6 +852,29 @@
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if rejectedCandidates.length}
|
||||
<section>
|
||||
<details class="rejtray">
|
||||
<summary>Rejected candidates <span class="count">({rejectedCandidates.length})</span></summary>
|
||||
<ul class="candlist rejlist">
|
||||
{#each rejectedCandidates as c (c.id)}
|
||||
<li>
|
||||
<div class="chead">
|
||||
<span class="cname">{c.name || c.feed_url}</span>
|
||||
<span class="cstatus">rejected</span>
|
||||
</div>
|
||||
<div class="curl">{c.feed_url}</div>
|
||||
{#if c._err}<p class="cerr">{c._err}</p>{/if}
|
||||
<div class="cactions">
|
||||
<button class="csend" onclick={() => restoreCandidate(c)}>Send back to staging</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<h2>Sources <a class="exportlink" href="/api/admin/export/sources.csv" download>export CSV ↓</a></h2>
|
||||
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {retired} retired · {sources.length} total</p>
|
||||
<div class="srctools">
|
||||
@@ -744,7 +960,7 @@
|
||||
{#if s._artSummary}
|
||||
<div class="artsum">
|
||||
<strong>{s._artSummary.total}</strong> ingested · {s._artSummary.accepted} accepted ·
|
||||
{s._artSummary.rejected} rejected · {s._artSummary.no_image} no image · {s._artSummary.duplicates} dup
|
||||
{s._artSummary.rejected} rejected{#if s._artSummary.non_english} · <span class="held">{s._artSummary.non_english} held · non-English</span>{/if} · {s._artSummary.no_image} no image · {s._artSummary.duplicates} dup
|
||||
</div>
|
||||
<div class="pwctl">
|
||||
<span>Paywall: <span class="pwrule" class:on={s._artSummary.paywalled}>{pwBasis(s._artSummary)}</span></span>
|
||||
@@ -770,7 +986,7 @@
|
||||
{#if typeof a.url === 'string' && /^https?:\/\//.test(a.url)}
|
||||
<a class="art-title" href={a.url} target="_blank" rel="noopener">{a.title}</a>
|
||||
{:else}<span class="art-title plain">{a.title}</span>{/if}
|
||||
{#if a.accepted === 1}<span class="badge ok">accepted</span>{:else if a.accepted === 0}<span class="badge no">rejected</span>{/if}
|
||||
{#if a.accepted === 1}<span class="badge ok">accepted</span>{:else if a.held}<span class="badge held">held · non-English</span>{:else if a.accepted === 0}<span class="badge no">rejected</span>{/if}
|
||||
{#if a.paywalled}<span class="pw" title="domain paywall rule">🔒</span>{/if}
|
||||
{#if !a.has_image}<span class="art-flag" title="no image extracted">no img</span>{/if}
|
||||
{#if a.duplicate}<span class="art-flag" title="marked duplicate">dup</span>{/if}
|
||||
@@ -958,7 +1174,46 @@
|
||||
{:else}<p class="muted">No feedback yet.</p>{/if}
|
||||
|
||||
{:else if section === 'games'}
|
||||
<h2>Daily Word pool</h2>
|
||||
<h2>Bloom words <span class="count">({bloomReports.length} to review)</span></h2>
|
||||
<p class="muted">Acceptance is broad (every valid dictionary word) — these are player
|
||||
“should this count?” reports. <strong>Approve</strong> allows the word everywhere
|
||||
(takes effect immediately, no deploy); <strong>Block</strong> hides it; <strong>Dismiss</strong>
|
||||
clears the report without a rule.</p>
|
||||
{#if bloomReports.length}
|
||||
<ul class="bloom-reports">
|
||||
{#each bloomReports as r (r.id)}
|
||||
<li>
|
||||
<span class="br-word">{r.word}</span>
|
||||
<span class="br-ctx">{r.format || ''}{r.letters ? ' · ' + r.letters.toUpperCase() : ''}{r.puzzle_date ? ' · ' + r.puzzle_date : ''}</span>
|
||||
<span class="br-acts">
|
||||
<button class="wp-add" onclick={() => resolveBloom(r.id, 'approve')}>Approve</button>
|
||||
<button class="act del" onclick={() => resolveBloom(r.id, 'block')}>Block</button>
|
||||
<button class="link" onclick={() => resolveBloom(r.id, 'dismiss')}>Dismiss</button>
|
||||
</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="empty">No pending word reports.</p>
|
||||
{/if}
|
||||
|
||||
<div class="wp-lookup">
|
||||
<input type="text" bind:value={bloomOvrWord} maxlength="24" autocapitalize="off"
|
||||
autocomplete="off" spellcheck="false" placeholder="Manually allow/block a word…" />
|
||||
<button class="wp-add" onclick={() => addBloomOverride('allow')}>Allow</button>
|
||||
<button class="act del" onclick={() => addBloomOverride('block')}>Block</button>
|
||||
</div>
|
||||
{#if bloomOverrides.length}
|
||||
<div class="bloom-ovr">
|
||||
{#each bloomOverrides as o (o.word)}
|
||||
<span class="ovr-chip {o.action}">{o.word} · {o.action}
|
||||
<button class="ovr-x" aria-label="Remove override" onclick={() => removeBloomOverride(o.word)}>×</button>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h2 style="margin-top:32px">Daily Word pool</h2>
|
||||
<p class="muted">Look up a word to add or remove it from the answer pool. Only real, 5- or 6-letter
|
||||
words in the guess dictionary qualify, so the daily answer is always solvable. Removals take
|
||||
effect for future puzzles and can be restored any time.</p>
|
||||
@@ -1082,6 +1337,109 @@
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}<p class="muted small">No custom themes yet — the daily rotation uses the built-in ones.</p>{/if}
|
||||
|
||||
{:else if section === 'publish'}
|
||||
<h2>Publishing Desk <span class="count">({pubItems.length})</span></h2>
|
||||
<p class="sub2">Build a queue of share-worthy stories, write the blurb in your own voice, and open it in X. Posts your <code>/a/</code> share link (your card, drives to UB, still credits the source). Opening X can't confirm a post — you mark Posted.</p>
|
||||
<div class="pubtools">
|
||||
<button class="csend" onclick={buildPublish} disabled={pubBuilding}>{pubBuilding ? 'Building…' : 'Build queue'}</button>
|
||||
{#if pubLast}<span class="muted small">Last build: +{pubLast.added} · {pubLast.active} active · ranked {pubLast.ranked_by}</span>{/if}
|
||||
</div>
|
||||
{#if pubError}<p class="cerr">Build error: {pubError}</p>{/if}
|
||||
|
||||
{#if !pubItems.length && !pubBuilding}
|
||||
<p class="muted small">Queue is empty — hit “Build queue” to gather candidates.</p>
|
||||
{/if}
|
||||
|
||||
<ul class="publist">
|
||||
{#each pubItems as item (item.id)}
|
||||
{@const remaining = pubRemaining(item)}
|
||||
<li class="pubcard" class:opened={item.status === 'opened'}>
|
||||
<div class="pub-head">
|
||||
{#if item.image_url}<img class="pub-img" src={item.image_url} alt="" loading="lazy" />{/if}
|
||||
<div class="pub-meta">
|
||||
<a class="pub-title" href={item.share_url} target="_blank" rel="noopener">{item.title}</a>
|
||||
<span class="pub-src">{item.source_name}{#if item.social_score != null} · interest {item.social_score}/10{/if}{#if item.status === 'opened'} · <span class="pub-openedtag">opened</span>{/if}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if item.rationale}<p class="pub-why">{item.rationale}</p>{/if}
|
||||
{#if item.talking_points?.length}
|
||||
<ul class="pub-points">{#each item.talking_points as p}<li>{p}</li>{/each}</ul>
|
||||
{/if}
|
||||
{#if item.angle}<p class="pub-angle"><span class="hlbl">Angle:</span> {item.angle}</p>{/if}
|
||||
|
||||
{#if item.suggested_handles?.length}
|
||||
<div class="pub-handles">
|
||||
<span class="hlbl">Tag:</span>
|
||||
{#each item.suggested_handles as h}<button class="hchip" onclick={() => insertHandle(item, h.handle)} title="Insert into your post">{h.handle}</button>{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="pub-toolbar">
|
||||
<button type="button" class="emoji-toggle" class:on={pubEmojiOpen === item.id}
|
||||
onclick={() => (pubEmojiOpen = pubEmojiOpen === item.id ? null : item.id)}
|
||||
title="Insert emoji">😊 Emoji</button>
|
||||
{#if pubEmojiOpen === item.id}
|
||||
<div class="emoji-pop">
|
||||
<EmojiPicker onpick={(em) => insertEmoji(item, em)} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<textarea class="pub-draft" rows="3" placeholder="Write your post — in your voice…"
|
||||
use:regTextarea={item.id}
|
||||
bind:value={item.draft_text} oninput={() => onDraftInput(item)}></textarea>
|
||||
<div class="pub-count" class:over={remaining < 0}>{remaining} left <span class="muted">(+ your /a/ link)</span></div>
|
||||
|
||||
{#if namedEntities(item).length}
|
||||
<details class="pub-find">
|
||||
<summary>Find / save handles</summary>
|
||||
{#each namedEntities(item) as ent (ent)}
|
||||
<div class="pub-ent">
|
||||
<span class="pub-entname">{ent}</span>
|
||||
<a class="act mini" href={findOnX(ent)} target="_blank" rel="noopener">Find on X ↗</a>
|
||||
<input class="hin" placeholder="@handle" bind:value={pubHandleInput[`${item.id}:${ent}`]} />
|
||||
<button class="act mini" onclick={() => pubSaveHandle(item, ent)}>Save</button>
|
||||
</div>
|
||||
{/each}
|
||||
</details>
|
||||
{/if}
|
||||
|
||||
{#if item._err}<p class="cerr">{item._err}</p>{/if}
|
||||
{#if item._confirm}
|
||||
<div class="pub-confirm">
|
||||
<p class="muted small">If you tweaked the post inside X, edit the text above to match before confirming — it's saved as your final wording.</p>
|
||||
<input class="hin wide" placeholder="Post URL (optional)" bind:value={item._postUrl} />
|
||||
<div class="pub-actions">
|
||||
<button class="csend" onclick={() => pubConfirmPosted(item)}>Confirm posted</button>
|
||||
<button class="act" onclick={() => (item._confirm = false)}>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="pub-actions">
|
||||
<a class="csend" href={intentURL(item)} target="_blank" rel="noopener" onclick={() => markOpened(item)}>Open in X ↗</a>
|
||||
<button class="act" onclick={() => (item._confirm = true)}>Posted ✓</button>
|
||||
<button class="act" onclick={() => pubSetStatus(item, 'snoozed', { snooze_until: snoozeDate(1) })}>Snooze 1d</button>
|
||||
<button class="act del" onclick={() => pubSetStatus(item, 'skipped')}>Skip</button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
{#if pubArchived.length}
|
||||
<details class="rejtray">
|
||||
<summary>Archived · skipped & snoozed <span class="count">({pubArchived.length})</span></summary>
|
||||
<ul class="candlist">
|
||||
{#each pubArchived as item (item.id)}
|
||||
<li>
|
||||
<div class="chead"><span class="cname">{item.title}</span><span class="cstatus">{item.status}{#if item.snooze_until} · until {item.snooze_until.slice(0, 16)}{/if}</span></div>
|
||||
<div class="curl">{item.source_name}</div>
|
||||
<div class="cactions"><button class="csend" onclick={() => pubRestore(item)}>Restore to queue</button></div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</details>
|
||||
{/if}
|
||||
{/if}
|
||||
{/if}
|
||||
</main>
|
||||
@@ -1216,6 +1574,8 @@
|
||||
.addrow input { flex: 1 1 200px; box-sizing: border-box; font: inherit; font-size: 0.9rem; padding: 8px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg); color: var(--ink); }
|
||||
.addrow input:focus { outline: none; border-color: var(--accent); }
|
||||
ul.candlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.rejtray > summary { cursor: pointer; font-family: var(--label); font-weight: 600; color: var(--muted); margin-bottom: 10px; }
|
||||
.rejtray .rejlist { opacity: 0.85; } /* tucked-away, lower-emphasis tray */
|
||||
ul.candlist li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
|
||||
.chead { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
|
||||
.chead .cname { font-weight: 600; color: var(--ink); }
|
||||
@@ -1229,11 +1589,26 @@
|
||||
.curl { font-size: 0.76rem; color: var(--muted); word-break: break-all; margin-top: 2px; }
|
||||
.cprev { font-size: 0.84rem; color: var(--ink); margin-top: 7px; }
|
||||
.cprev .cex { color: var(--muted); font-size: 0.8rem; margin-top: 2px; font-style: italic; }
|
||||
.held { color: #8a5a18; font-weight: 600; } /* language-held (not a rejection) */
|
||||
/* Heuristic-vs-model preview badge */
|
||||
.vbadge { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
|
||||
font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; cursor: help; }
|
||||
.vbadge.model { background: #e3efe4; color: #3f7048; }
|
||||
.vbadge.fast { background: var(--line); color: var(--muted); }
|
||||
.vbadge.wall { background: #fdf0d8; color: #8a5a18; }
|
||||
/* Deep-preview accessibility sample */
|
||||
.caccess { margin-top: 6px; font-size: 0.8rem; color: var(--ink); display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.acc-verdict { padding: 1px 8px; border-radius: 999px; font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||
.acc-verdict.fine { background: #e3efe4; color: #3f7048; }
|
||||
.acc-verdict.review { background: #fdf0d8; color: #8a5a18; }
|
||||
.acc-verdict.reject-ready { background: #fbe3e3; color: #9a3b3b; }
|
||||
.acc-of { color: var(--muted); }
|
||||
.acc-ex-row { display: flex; gap: 6px; flex-wrap: wrap; flex-basis: 100%; }
|
||||
.acc-ex { font-size: 0.7rem; padding: 1px 7px; border-radius: 7px; text-decoration: none; }
|
||||
.acc-ex.readable { background: #e3efe4; color: #3f7048; }
|
||||
.acc-ex.paywalled { background: #fbe3e3; color: #9a3b3b; }
|
||||
.acc-ex.blocked { background: #eceff2; color: #67727d; }
|
||||
.acc-ex.unknown { background: var(--line); color: var(--muted); }
|
||||
.cactions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
|
||||
.cactions .ccat { font: inherit; font-size: 0.8rem; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); width: 150px; }
|
||||
.cactions .cchk { font-size: 0.8rem; color: var(--muted); display: inline-flex; align-items: center; gap: 5px; }
|
||||
@@ -1297,6 +1672,7 @@
|
||||
.art-row .badge { font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 7px; border-radius: 999px; }
|
||||
.badge.ok { background: #e3efe4; color: #3f7048; }
|
||||
.badge.no { background: #f3e0e0; color: #9a3b3b; }
|
||||
.badge.held { background: #fdf0d8; color: #8a5a18; } /* language-held, not rejected */
|
||||
.art-row .pw { font-size: 0.78rem; }
|
||||
.art-flag { font-size: 0.7rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; }
|
||||
.art-cat { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
|
||||
@@ -1400,6 +1776,15 @@
|
||||
font-size: 0.82rem; padding: 8px 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; }
|
||||
.ce-when { color: var(--muted); white-space: nowrap; }
|
||||
.ce-reason { font-family: var(--label); color: #9a3b3b; }
|
||||
/* Layer tag — html-slow is the only incident-grade one, so it reads loudest. */
|
||||
.ce-tag { display: inline-block; margin-right: 8px; padding: 1px 7px; border-radius: 999px;
|
||||
font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
|
||||
vertical-align: middle; }
|
||||
.ce-tag.html { background: #fbe3e3; color: #9a3b3b; }
|
||||
.ce-tag.app { background: #fdf0d8; color: #8a5a18; }
|
||||
.ce-tag.preload { background: var(--accent-soft); color: var(--accent-deep); }
|
||||
.ce-tag.runtime { background: #fbe3e3; color: #9a3b3b; }
|
||||
.ce-tag.bot { background: #eceff2; color: #67727d; }
|
||||
.cerrs li.bot { opacity: 0.6; }
|
||||
.cerrs li.bot .ce-reason { color: var(--muted); }
|
||||
.ce-bot { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
|
||||
@@ -1422,6 +1807,20 @@
|
||||
font: inherit; font-weight: 600; cursor: pointer; }
|
||||
.wp-add:hover { background: var(--accent-deep); }
|
||||
.wp-msg { color: var(--accent-deep); font-size: 0.9rem; margin: 6px 0 0; }
|
||||
.bloom-reports { list-style: none; padding: 0; margin: 10px 0 18px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.bloom-reports li { display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; }
|
||||
.br-word { font-weight: 700; text-transform: capitalize; }
|
||||
.br-ctx { color: var(--muted); font-size: 0.82rem; }
|
||||
.br-acts { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
||||
.br-acts .wp-add { padding: 5px 13px; font-size: 0.85rem; }
|
||||
.bloom-ovr { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 8px; }
|
||||
.ovr-chip { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 4px 6px 4px 12px;
|
||||
font-size: 0.85rem; color: #fff; }
|
||||
.ovr-chip.allow { background: #2e8b57; }
|
||||
.ovr-chip.block { background: #b8553f; }
|
||||
.ovr-x { background: rgba(255,255,255,0.25); border: none; color: #fff; border-radius: 50%; width: 18px; height: 18px;
|
||||
line-height: 1; cursor: pointer; font-size: 0.9rem; }
|
||||
.wp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 22px; margin-top: 24px; }
|
||||
.wp-col h3 { margin: 0 0 2px; font-size: 1.05rem; }
|
||||
.wp-col .count { font-size: 0.85rem; }
|
||||
@@ -1474,4 +1873,45 @@
|
||||
border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
|
||||
.wt-name { font-weight: 600; }
|
||||
.wt-count { color: var(--muted); font-size: 0.84rem; margin-right: auto; }
|
||||
|
||||
/* Publishing Desk */
|
||||
.pubtools { display: flex; align-items: center; gap: 12px; margin: 6px 0 16px; }
|
||||
.publist { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 14px; }
|
||||
.pubcard { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; }
|
||||
.pubcard.opened { border-color: var(--accent); }
|
||||
.pub-head { display: flex; gap: 12px; align-items: flex-start; }
|
||||
.pub-img { width: 88px; height: 64px; object-fit: cover; border-radius: 9px; flex-shrink: 0; }
|
||||
.pub-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.pub-title { font-weight: 600; color: var(--ink); text-decoration: none; }
|
||||
.pub-title:hover { text-decoration: underline; }
|
||||
.pub-src { font-size: 0.8rem; color: var(--muted); }
|
||||
.pub-openedtag { color: var(--accent-deep); font-weight: 600; }
|
||||
.pub-why { margin: 10px 0 4px; font-style: italic; color: var(--ink); }
|
||||
.pub-points { margin: 4px 0; padding-left: 18px; color: var(--ink); font-size: 0.9rem; }
|
||||
.pub-points li { margin: 1px 0; }
|
||||
.pub-angle { margin: 4px 0; font-size: 0.88rem; color: var(--muted); }
|
||||
.hlbl { font-family: var(--label); font-weight: 600; font-size: 0.78rem; color: var(--muted); }
|
||||
.pub-handles { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 8px 0; }
|
||||
.hchip { font-size: 0.8rem; padding: 2px 9px; border: 1px solid var(--accent); border-radius: 999px;
|
||||
background: var(--accent-soft); color: var(--accent-deep); cursor: pointer; }
|
||||
.pub-draft { width: 100%; box-sizing: border-box; margin-top: 8px; padding: 9px 11px; font: inherit;
|
||||
border: 1px solid var(--line); border-radius: 10px; background: var(--bg); color: var(--ink); resize: vertical; }
|
||||
.pub-toolbar { position: relative; margin-top: 8px; }
|
||||
.emoji-toggle { font: inherit; font-size: 0.8rem; padding: 3px 10px; border: 1px solid var(--line);
|
||||
border-radius: 999px; background: var(--bg); color: var(--ink); cursor: pointer; }
|
||||
.emoji-toggle.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-deep); }
|
||||
.emoji-pop { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; padding: 8px;
|
||||
border: 1px solid var(--line); border-radius: 10px; background: var(--card, var(--bg));
|
||||
box-shadow: 0 6px 20px rgba(0,0,0,0.18); }
|
||||
.pub-count { font-size: 0.78rem; color: var(--muted); text-align: right; margin-top: 2px; font-variant-numeric: tabular-nums; }
|
||||
.pub-count.over { color: #9a3b3b; font-weight: 700; }
|
||||
.pub-find { margin: 8px 0; font-size: 0.85rem; }
|
||||
.pub-find > summary { cursor: pointer; color: var(--accent-deep); }
|
||||
.pub-ent { display: flex; align-items: center; gap: 8px; margin: 6px 0; flex-wrap: wrap; }
|
||||
.pub-entname { min-width: 120px; }
|
||||
.hin { font: inherit; font-size: 0.82rem; padding: 4px 8px; border: 1px solid var(--line); border-radius: 8px;
|
||||
background: var(--bg); color: var(--ink); width: 130px; }
|
||||
.pub-actions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
|
||||
.pub-confirm { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 10px; background: var(--accent-soft); }
|
||||
.hin.wide { width: 100%; box-sizing: border-box; margin: 4px 0; }
|
||||
</style>
|
||||
|
||||
@@ -3,23 +3,45 @@
|
||||
import { goto, afterNavigate } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { getJSON } from '$lib/api.js';
|
||||
import { pushGameState } from '$lib/gamesync.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(sp.get('game') === 'wordsearch' ? 'wordsearch' : 'word');
|
||||
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 {
|
||||
@@ -40,6 +62,36 @@
|
||||
} 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;
|
||||
@@ -49,6 +101,9 @@
|
||||
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);
|
||||
@@ -58,23 +113,42 @@
|
||||
// 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.
|
||||
async function syncOne(g, v, key) {
|
||||
let local = null;
|
||||
try { local = JSON.parse(localStorage.getItem(key) || 'null'); } catch { /* ignore */ }
|
||||
const merged = await pushGameState(g, v, date, local || {});
|
||||
if (!merged) return;
|
||||
if (g === 'wordsearch') merged.status = merged.ms ? 'done' : 'playing'; // card reads .status
|
||||
try { localStorage.setItem(key, JSON.stringify(merged)); } catch { /* ignore */ }
|
||||
// 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;
|
||||
await Promise.allSettled([
|
||||
syncOne('word', '5', `goodnews:word:5:${date}`),
|
||||
syncOne('word', '6', `goodnews:word:6:${date}`),
|
||||
syncOne('wordsearch', 'small', `goodnews:wordsearch:small:${date}`),
|
||||
syncOne('wordsearch', 'med', `goodnews:wordsearch:med:${date}`),
|
||||
syncOne('wordsearch', 'large', `goodnews:wordsearch:large:${date}`),
|
||||
]);
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -91,6 +165,24 @@
|
||||
if (wsStatus.found > 0) return `Today: ${wsStatus.found} found`;
|
||||
return 'Find the day’s themed words';
|
||||
}
|
||||
function bloomHubLabel() {
|
||||
if (!bloomStatus || !bloomStatus.count) return 'Make words from today’s letters';
|
||||
if (bloomStatus.full) return 'Today: Full Bloom 🌸';
|
||||
return `Today: ${bloomStatus.count} ${bloomStatus.count === 1 ? 'word' : 'words'}`;
|
||||
}
|
||||
|
||||
function matchHubLabel() {
|
||||
if (!matchStatus) return 'Match the day’s 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) {
|
||||
@@ -121,13 +213,19 @@
|
||||
}
|
||||
|
||||
// Canonicalize shareable/bookmarked URLs: unknown game → hub; invalid v for the
|
||||
// game → its default (replaceState, so it doesn't add a history entry).
|
||||
// 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 && g !== 'word' && g !== 'wordsearch') { goto('/play', { replaceState: true }); return; }
|
||||
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'] : ['small', 'med', 'large'];
|
||||
if (!valid.includes(v)) goto(`/play?game=${g}&v=${g === 'word' ? '5' : 'med'}`, { replaceState: true });
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -150,6 +248,7 @@
|
||||
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();
|
||||
@@ -159,7 +258,10 @@
|
||||
afterNavigate(() => refreshStatus());
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Play · Upbeat Bytes</title></svelte:head>
|
||||
<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">
|
||||
@@ -178,6 +280,19 @@
|
||||
{#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>
|
||||
@@ -195,19 +310,81 @@
|
||||
<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 today’s 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' : 'Word Search'}</h1>
|
||||
<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">Today’s theme</span>
|
||||
<span class="tc-name">{wsTheme}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<p class="sub">{game === 'word' ? 'Pick your length.' : 'Pick your size.'}</p>
|
||||
<p class="sub">{game === 'word' ? 'Pick your length.' : game === 'wordsearch' ? 'Pick your size.' : game === 'match' ? 'Pick a format, then today’s board or free play.' : 'Play today’s shared puzzle, or graze freely.'}</p>
|
||||
<div class="opts">
|
||||
{#if game === 'word'}
|
||||
{#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">Today’s 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>Today’s 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>
|
||||
@@ -229,8 +406,16 @@
|
||||
{:else if view === 'play'}
|
||||
{#if game === 'word'}
|
||||
<WordGame {variant} onstatus={refreshStatus} />
|
||||
{:else}
|
||||
{: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>
|
||||
@@ -250,8 +435,42 @@
|
||||
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;
|
||||
@@ -260,8 +479,15 @@
|
||||
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; }
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { auth } from '$lib/auth.svelte.js';
|
||||
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
|
||||
|
||||
let canvas = $state();
|
||||
let failed = $state(false);
|
||||
let loading = $state(true);
|
||||
|
||||
// Live tuning panel (admins, /zen?debug=1) — dial in the render without redeploys.
|
||||
let handle = $state(null);
|
||||
let dbg = $state(null);
|
||||
let debug = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
function apply() { handle?.setParams($state.snapshot(dbg)); }
|
||||
async function copyValues() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(JSON.stringify($state.snapshot(dbg), null, 2));
|
||||
copied = true; setTimeout(() => (copied = false), 1500);
|
||||
} catch { /* clipboard blocked — values are still visible in the panel */ }
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
// Dev-gated while UB is being ironed out: non-admins (no preview token) bounce.
|
||||
if (blockedForViewer('zen', auth.user, $page.url)) { goto('/play'); return; }
|
||||
debug = $page.url.searchParams.get('debug') === '1';
|
||||
let h;
|
||||
let cancelled = false; // guard the async load against an early unmount
|
||||
(async () => {
|
||||
try {
|
||||
// WebGL guard — fall back to a warm card rather than a blank canvas.
|
||||
const t = document.createElement('canvas');
|
||||
if (!t.getContext('webgl2') && !t.getContext('webgl')) throw new Error('no-webgl');
|
||||
const { createAquarium } = await import('$lib/zen/aquarium.js'); // lazy: three loads only here
|
||||
h = await createAquarium(canvas);
|
||||
if (cancelled) { h.dispose(); return; } // left /zen mid-load — don't start a loop
|
||||
handle = h;
|
||||
if (debug) dbg = h.getParams();
|
||||
loading = false;
|
||||
} catch (e) {
|
||||
console.warn('Zen Den could not start:', e);
|
||||
if (!cancelled) { failed = true; loading = false; }
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; h?.dispose(); };
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>The Zen Den · Upbeat Bytes</title>
|
||||
{#if isDevGated('zen')}<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>
|
||||
<a class="back" href="/play">
|
||||
<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>Play
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="zen container">
|
||||
<h1>The Zen Den</h1>
|
||||
<p class="sub">Drop in with UB for a quiet minute.</p>
|
||||
|
||||
<div class="tankwrap">
|
||||
{#if failed}
|
||||
<div class="fallback">
|
||||
<div class="fish">🐟</div>
|
||||
<p>UB's tank needs a browser with WebGL. He's resting for now — try again on a different device or browser.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<canvas bind:this={canvas} class="tank" aria-label="UB the koi, swimming"></canvas>
|
||||
{#if loading}<p class="loadnote">UB is settling in…</p>{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if debug && dbg}
|
||||
<div class="panel">
|
||||
<div class="prow"><strong>UB render tuner</strong><button class="copy" onclick={copyValues}>{copied ? 'copied ✓' : 'copy values'}</button></div>
|
||||
|
||||
<label>yaw <span>{dbg.yaw.toFixed(2)}</span>
|
||||
<input type="range" min="-3.15" max="3.15" step="0.01" bind:value={dbg.yaw} oninput={apply} /></label>
|
||||
<label>pitch <span>{dbg.pitch.toFixed(2)}</span>
|
||||
<input type="range" min="-0.6" max="0.6" step="0.01" bind:value={dbg.pitch} oninput={apply} /></label>
|
||||
|
||||
<hr />
|
||||
<div class="ph">Tail</div>
|
||||
<label class="chk"><input type="checkbox" bind:checked={dbg.tailTranslucent} onchange={apply} /> translucent (off = opaque, coherent)</label>
|
||||
<label>side
|
||||
<select bind:value={dbg.tailSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
|
||||
<label>alphaTest <span>{dbg.tailAlphaTest.toFixed(3)}</span>
|
||||
<input type="range" min="0" max="0.2" step="0.005" bind:value={dbg.tailAlphaTest} oninput={apply} /></label>
|
||||
{#if dbg.tailTranslucent}
|
||||
<label>opacity <span>{dbg.tailOpacity.toFixed(2)}</span>
|
||||
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.tailOpacity} oninput={apply} /></label>
|
||||
{/if}
|
||||
|
||||
<hr />
|
||||
<div class="ph">Fins</div>
|
||||
<label>side
|
||||
<select bind:value={dbg.finSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
|
||||
<label>opacity <span>{dbg.finOpacity.toFixed(2)}</span>
|
||||
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.finOpacity} oninput={apply} /></label>
|
||||
<label>alphaTest <span>{dbg.finAlphaTest.toFixed(3)}</span>
|
||||
<input type="range" min="0" max="0.2" step="0.005" bind:value={dbg.finAlphaTest} oninput={apply} /></label>
|
||||
|
||||
<hr />
|
||||
<label class="chk"><input type="checkbox" bind:checked={dbg.paused} onchange={apply} /> freeze frame</label>
|
||||
{#if dbg.paused}
|
||||
<label>frame <span>{dbg.frame.toFixed(2)}</span>
|
||||
<input type="range" min="0" max="1" step="0.01" bind:value={dbg.frame} oninput={apply} /></label>
|
||||
{/if}
|
||||
</div>
|
||||
{/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; }
|
||||
.back svg { width: 17px; height: 17px; display: block; }
|
||||
|
||||
.zen { padding: 22px 20px 60px; }
|
||||
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 4px; }
|
||||
.sub { color: var(--muted); margin: 0 0 18px; }
|
||||
|
||||
/* A calm tank: soft aqua gradient backdrop; the transparent WebGL canvas sits
|
||||
on top so the water reads even before we build the real bowl (Phase C). */
|
||||
.tankwrap {
|
||||
position: relative; width: 100%; max-width: 640px; aspect-ratio: 4 / 3;
|
||||
border-radius: 20px; overflow: hidden; box-shadow: var(--shadow);
|
||||
background: radial-gradient(120% 100% at 50% 0%, #d6f0f2 0%, #aadbe0 45%, #7cc3cc 100%);
|
||||
}
|
||||
.tank { display: block; width: 100%; height: 100%; }
|
||||
.loadnote { position: absolute; inset: auto 0 16px 0; text-align: center; color: var(--accent-deep);
|
||||
font-family: var(--label); font-size: 0.9rem; }
|
||||
.fallback { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
|
||||
justify-content: center; gap: 12px; text-align: center; padding: 24px; color: #2b5560; }
|
||||
.fallback .fish { font-size: 3rem; }
|
||||
.fallback p { max-width: 340px; margin: 0; }
|
||||
|
||||
/* Dev tuning panel (admins only, ?debug=1). */
|
||||
.panel { margin-top: 18px; max-width: 360px; padding: 14px 16px; border: 1px solid var(--line);
|
||||
border-radius: 14px; background: var(--surface); font-size: 0.82rem; }
|
||||
.prow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
|
||||
.panel hr { border: none; border-top: 1px solid var(--line); margin: 10px 0 6px; }
|
||||
.ph { font-family: var(--label); text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.72rem;
|
||||
color: var(--muted); margin-bottom: 4px; }
|
||||
.panel label { display: block; margin: 6px 0; color: var(--ink); }
|
||||
.panel label span { float: right; color: var(--accent-deep); font-variant-numeric: tabular-nums; }
|
||||
.panel label.chk { display: flex; align-items: center; gap: 7px; }
|
||||
.panel input[type="range"] { width: 100%; margin-top: 3px; }
|
||||
.panel select { width: 100%; margin-top: 3px; }
|
||||
.copy { font-size: 0.75rem; padding: 4px 9px; border: 1px solid var(--line); border-radius: 8px;
|
||||
background: var(--bg); color: var(--accent-deep); cursor: pointer; }
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.tankwrap { aspect-ratio: 3 / 4; } /* taller on phones */
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user