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
+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);