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:
jay
2026-06-18 11:32:27 -04:00
parent 2dbe73430c
commit 89c0fbe1f6
66 changed files with 6138 additions and 109 deletions
+117 -1
View File
@@ -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;
+51
View File
@@ -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);
+449 -9
View File
@@ -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 23 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 its walled — usually reject, unless its 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 &amp; 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>
+251 -25
View File
@@ -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 days themed words';
}
function bloomHubLabel() {
if (!bloomStatus || !bloomStatus.count) return 'Make words from todays letters';
if (bloomStatus.full) return 'Today: Full Bloom 🌸';
return `Today: ${bloomStatus.count} ${bloomStatus.count === 1 ? 'word' : 'words'}`;
}
function matchHubLabel() {
if (!matchStatus) return 'Match the days pairs';
if (matchStatus.done) return 'Today: cleared';
return `Today: ${matchStatus.count} matched`;
}
function matchOpt(t) {
const s = readMatchVariant(`${t}-${matchFmt}`);
if (!s) return 'Play';
if (s.done) return 'Cleared';
const c = (s.matched || []).length;
return c > 0 ? `${c} matched` : 'Play';
}
// Game-selection option statuses
function wordOpt(v) {
@@ -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 todays letters</p>
<p class="gc-status" class:played={bloomStatus && bloomStatus.count > 0}>{bloomHubLabel()}</p>
</div>
</button>
{/if}
{#if !matchBlocked}
<button class="gamecard" onclick={() => openGame('match')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Memory Match{#if isDevGated('match')}<span class="devtag">dev</span>{/if}</h2>
<p class="gc-sub">Find the pairs — icons or colors</p>
<p class="gc-status" class:played={matchStatus}>{matchHubLabel()}</p>
</div>
</button>
{/if}
{#if !blockedForViewer('zen', auth.user, $page.url)}
<a class="gamecard zencard" href="/zen">
<div class="gc-icon">🐟</div>
<div class="gc-body">
<h2>Zen Den{#if isDevGated('zen')}<span class="devtag">dev</span>{/if}</h2>
<p class="gc-sub">A calm corner — drop in with UB</p>
<p class="gc-status zen">Visit · no scores, just quiet</p>
</div>
</a>
{/if}
</div>
{:else if view === 'select'}
<h1 class="seltitle">{game === 'word' ? 'Daily Word' : '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">Todays 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 todays board or free play.' : 'Play todays 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">Todays board — shared daily</p>
{#each MATCH_TIERS as [t, name, desc] (t)}
<button class="opt" onclick={() => pick(`daily-${matchFmt}-${t}`)}>
<span class="opt-main"><strong>{name}</strong><span>{desc}</span></span>
<span class="opt-go" class:done={readMatchVariant(`${t}-${matchFmt}`)?.done}>{matchOpt(t)}</span>
</button>
{/each}
<p class="grp">Free play — fresh boards anytime</p>
{#each MATCH_TIERS as [t, name, desc] (t)}
<button class="opt" onclick={() => pick(`free-${matchFmt}-${t}`)}>
<span class="opt-main"><strong>{name}</strong><span>{desc}</span></span>
<span class="opt-go">Play</span>
</button>
{/each}
{:else if game === 'bloom'}
<button class="opt" onclick={() => pick('daily')}>
<span class="opt-main"><strong>Todays Bloom</strong><span>the shared daily · center letter</span></span>
<span class="opt-go" class:done={bloomStatus && bloomStatus.count > 0}>{bloomStatus && bloomStatus.count ? (bloomStatus.full ? 'Full Bloom 🌸' : `${bloomStatus.count} found`) : 'Play'}</span>
</button>
<button class="opt" onclick={() => pick('free-center')}>
<span class="opt-main"><strong>Free Play · Center Circle</strong><span>fresh wheels anytime · center letter</span></span>
<span class="opt-go">Play</span>
</button>
<button class="opt" onclick={() => pick('free-wild')}>
<span class="opt-main"><strong>Free Play · Wild Bloom</strong><span>fresh wheels · use any letters</span></span>
<span class="opt-go">Play</span>
</button>
{:else if game === 'word'}
<button class="opt" onclick={() => pick('5')}>
<span class="opt-main"><strong>Daily Word</strong><span>5 letters · 6 guesses</span></span>
<span class="opt-go" class:done={wordStatus['5']}>{wordOpt('5')}</span>
@@ -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; }
+166
View File
@@ -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>