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;