Files
thejayman77 89c0fbe1f6 Sync repo to deployed state: SEO recovery, Publishing Desk, Play games, emoji picker
The deploy pipeline runs from the working tree, so a wave of shipped features
had never been committed. This snapshots git to what's actually running.

SEO impression recovery (live + verified):
- Duplicate /a/{id} now 301-redirect to their canonical twin instead of 404
  (a hard 404 silently dropped already-indexed URLs and tanked impressions).
- Dedup representative selection reworked: accepted/serveable -> established
  rep (URL stability) -> quality score, so an accepted page never retires to a
  rejected rep and an indexed canonical doesn't churn when a newer twin arrives.
- HEAD /a/{id} returns the same status as GET (api_route GET+HEAD) instead of
  falling through to the static mount and 404ing.
- `dedup --force-recluster`: cycle-locked, model-free re-cluster to re-apply the
  policy to the existing corpus (shared cycle_lock context manager).
- CLI honors GOODNEWS_DB for its default --db (was silently ignored).

Publishing Desk (admin tool to post highlights to X via Web Intents):
- publishing.py queue/rank/handle-resolution; admin UI; full searchable emoji
  picker (bundled data, no CDN) for the blurb editor.

Play games + site:
- Bloom (word-wheel), Memory Match, daily ritual set, Zen Den (dev-gated).
- English-only language gate; source prospecting; paywall + dedup hardening.

Tests: full suite green (349). Ignores tightened (node_modules, data/*.db).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:32:27 -04:00

466 lines
20 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>goodNews — calm, constructive news</title>
<style>
:root {
--bg: #f7f5f0; --card: #fff; --ink: #1f2a24; --muted: #6b7770;
--accent: #2f7d5b; --accent-soft: #e4efe8; --line: #e3e0d8;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--ink);
font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
header {
padding: 28px 20px 18px; text-align: center; border-bottom: 1px solid var(--line);
background: var(--card); position: relative;
}
header h1 { margin: 0; font-size: 1.7rem; letter-spacing: -0.02em; }
header h1 span { color: var(--accent); }
header p { margin: 6px 0 0; color: var(--muted); font-size: 0.95rem; }
.calm-btn {
position: absolute; top: 20px; right: 20px; border: 1px solid var(--line);
background: var(--card); border-radius: 999px; padding: 6px 14px; cursor: pointer;
font-size: 0.85rem; color: var(--ink);
}
.calm-btn.on { background: var(--accent); color: #fff; border-color: var(--accent); }
main { max-width: 760px; margin: 0 auto; padding: 20px; }
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 6px 0 18px; }
.chip {
border: 1px solid var(--line); background: var(--card); color: var(--ink);
border-radius: 999px; padding: 6px 13px; font-size: 0.85rem; cursor: pointer;
transition: all .12s ease;
}
.chip:hover { border-color: var(--accent); }
.chip.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.chip-group-label { font-size: 0.72rem; text-transform: uppercase; letter-spacing: .08em;
color: var(--muted); margin: 4px 0; width: 100%; }
.section-title { font-size: 0.95rem; color: var(--muted); margin: 22px 0 10px;
text-transform: uppercase; letter-spacing: .06em; }
article {
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px; margin-bottom: 12px;
}
article .meta { display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
font-size: 0.78rem; color: var(--muted); margin-bottom: 6px; }
.tag { background: var(--accent-soft); color: var(--accent); border-radius: 6px;
padding: 1px 8px; font-weight: 600; }
article h3 { margin: 0 0 6px; font-size: 1.08rem; line-height: 1.35; }
article h3 a { color: var(--ink); text-decoration: none; }
article h3 a:hover { color: var(--accent); }
article p.desc { margin: 0 0 8px; color: #44514a; font-size: 0.92rem; }
article .why { font-size: 0.8rem; color: var(--muted); font-style: italic; }
.rank-badge { background: var(--accent); color: #fff; border-radius: 50%;
width: 24px; height: 24px; display: inline-flex; align-items: center;
justify-content: center; font-size: 0.8rem; font-weight: 700; }
.actions { margin-top: 10px; display: flex; gap: 14px; }
.actions button {
background: none; border: none; padding: 0; cursor: pointer; font-size: 0.78rem;
color: var(--muted); border-bottom: 1px dotted var(--line);
}
.actions button:hover { color: var(--accent); border-bottom-color: var(--accent); }
.empty { color: var(--muted); text-align: center; padding: 30px; }
/* Calm settings panel */
.panel {
background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px; margin-bottom: 18px; display: none;
}
.panel.open { display: block; }
.panel h2 { font-size: 1rem; margin: 0 0 4px; }
.panel .hint { color: var(--muted); font-size: 0.82rem; margin: 0 0 12px; }
.panel .group { margin-bottom: 12px; }
.panel .group-label { font-size: 0.74rem; text-transform: uppercase; letter-spacing: .07em;
color: var(--muted); margin-bottom: 6px; }
.pill {
display: inline-flex; align-items: center; gap: 6px; background: var(--accent-soft);
color: var(--accent); border-radius: 999px; padding: 3px 6px 3px 11px; font-size: 0.82rem;
margin: 0 6px 6px 0;
}
.pill button { background: none; border: none; cursor: pointer; color: var(--accent);
font-size: 0.95rem; line-height: 1; padding: 0 2px; }
.panel input[type=text] { border: 1px solid var(--line); border-radius: 8px; padding: 6px 10px;
font-size: 0.88rem; width: 60%; }
.panel .addbtn { margin-left: 6px; border: 1px solid var(--accent); background: var(--accent);
color: #fff; border-radius: 8px; padding: 6px 12px; cursor: pointer; font-size: 0.85rem; }
.panel .reset { margin-top: 6px; background: none; border: none; color: var(--muted);
cursor: pointer; font-size: 0.8rem; text-decoration: underline; }
.calm-note { color: var(--muted); font-size: 0.8rem; margin: -8px 0 14px; }
.addsource { background: var(--card); border: 1px solid var(--line); border-radius: 12px;
padding: 16px 18px; margin-bottom: 18px; }
.addsource .hint { color: var(--muted); font-size: 0.82rem; margin: 0 0 10px; }
.addsource-row { display: flex; gap: 8px; flex-wrap: wrap; }
.addsource input { flex: 1 1 240px; border: 1px solid var(--line); border-radius: 8px;
padding: 7px 11px; font-size: 0.9rem; }
.addbtn { border: 1px solid var(--accent); background: var(--accent); color: #fff;
border-radius: 8px; padding: 7px 14px; cursor: pointer; font-size: 0.85rem; }
.addbtn.ghost { background: none; color: var(--accent); }
.preview-metrics { margin-top: 14px; }
.preview-metrics .stat { font-size: 0.9rem; margin: 2px 0; }
.preview-metrics .label { color: var(--muted); }
.preview-metrics ul { margin: 4px 0 10px; padding-left: 18px; font-size: 0.86rem; }
footer { text-align: center; color: var(--muted); font-size: 0.78rem; padding: 20px; }
footer a { color: var(--accent); }
</style>
</head>
<body>
<header>
<h1>good<span>News</span></h1>
<p>Calm, constructive news worth your attention — and nothing that isn't.</p>
<button id="calm-toggle" class="calm-btn">Calm filters</button>
</header>
<main>
<div id="panel" class="panel">
<h2>Calm filters</h2>
<p class="hint">Your boundaries, kept on this device. Nothing is sent anywhere or tied to an account.</p>
<div class="group">
<div class="group-label">Paused for now</div>
<div id="panel-pauses"></div>
</div>
<div class="group">
<div class="group-label">Always hidden</div>
<div id="panel-mutes"></div>
</div>
<div class="group">
<div class="group-label">Avoid words &amp; phrases</div>
<div id="panel-terms"></div>
<input id="term-input" type="text" placeholder="e.g. election, stock market" />
<button id="term-add" class="addbtn">Add</button>
</div>
<button id="reset" class="reset">Reset all calm filters</button>
</div>
<div id="calm-note" class="calm-note"></div>
<div id="brief-block">
<div class="section-title">Five Good Things</div>
<div id="brief"><div class="empty">Loading…</div></div>
</div>
<div class="section-title">Browse by category</div>
<div id="topic-chips" class="chips"></div>
<div id="flavor-chips" class="chips"></div>
<div id="feed"></div>
<div class="section-title">Suggest a source</div>
<div class="addsource">
<p class="hint">Paste a feed URL to see how calm it is before anyone adds it. Nothing is saved — this just samples and scores recent items.</p>
<div class="addsource-row">
<input id="src-url" type="text" placeholder="https://example.com/feed/" />
<button id="src-quick" class="addbtn">Quick check</button>
<button id="src-deep" class="addbtn ghost">Deep check (uses model)</button>
</div>
<div id="src-result"></div>
</div>
</main>
<footer>
goodNews · metadata &amp; links only, no stored articles ·
<a href="/docs">API</a>
</footer>
<script>
const PREFS_KEY = "goodnews:prefs";
const browse = { topic: null, flavor: null };
const el = (id) => document.getElementById(id);
// ---- preferences (the canonical FilterPrefs shape) ----
function blankPrefs() {
return { include_topics: [], include_flavors: [], mute_topics: [],
mute_flavors: [], avoid_terms: [], pauses: [] };
}
function loadPrefs() {
let p;
try { p = JSON.parse(localStorage.getItem(PREFS_KEY)) || {}; } catch { p = {}; }
const prefs = Object.assign(blankPrefs(), p);
// prune expired pauses so localStorage stays tidy
const now = Date.now();
prefs.pauses = (prefs.pauses || []).filter(x => x && x.until && new Date(x.until).getTime() > now);
return prefs;
}
let prefs = loadPrefs();
function savePrefs() { localStorage.setItem(PREFS_KEY, JSON.stringify(prefs)); }
function prefsActive() {
return prefs.mute_topics.length || prefs.mute_flavors.length ||
prefs.avoid_terms.length || prefs.pauses.length ||
prefs.include_topics.length || prefs.include_flavors.length;
}
function prefsParam() {
return prefsActive() ? "&prefs=" + encodeURIComponent(JSON.stringify(prefs)) : "";
}
function hoursFromNow(h) { return new Date(Date.now() + h * 3600e3).toISOString(); }
// ---- gentle actions: the humane ladder ----
function notToday(topic) { // pause this topic 24h
if (!topic) return;
prefs.pauses = prefs.pauses.filter(p => !(p.kind === "topic" && p.value === topic));
prefs.pauses.push({ kind: "topic", value: topic, until: hoursFromNow(24) });
savePrefs(); refreshAll();
}
function lessLikeThis(flavor) { // ease off this flavor for a few days
if (!flavor) return;
prefs.pauses = prefs.pauses.filter(p => !(p.kind === "flavor" && p.value === flavor));
prefs.pauses.push({ kind: "flavor", value: flavor, until: hoursFromNow(72) });
savePrefs(); refreshAll();
}
function alwaysHide(topic) { // standing mute, undoable in the panel
if (!topic || prefs.mute_topics.includes(topic)) return;
prefs.mute_topics.push(topic);
savePrefs(); refreshAll();
}
async function getJSON(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(await r.text());
return r.json();
}
// Build cards with the DOM API (textContent) rather than HTML strings, so
// feed-supplied text can never inject markup even if upstream cleaning misses.
const node = (tag, cls, text) => {
const e = document.createElement(tag);
if (cls) e.className = cls;
if (text != null) e.textContent = text;
return e;
};
function articleCard(a, showRank) {
const article = node("article");
const meta = node("div", "meta");
if (showRank && a.rank) meta.append(node("span", "rank-badge", a.rank));
[a.topic, a.flavor].filter(Boolean).forEach(t => meta.append(node("span", "tag", t)));
meta.append(node("span", null, a.source));
if (a.published_at) meta.append(node("span", null, "· " + a.published_at.slice(0, 10)));
article.append(meta);
const h3 = node("h3");
const link = node("a", null, a.title);
link.href = (typeof a.url === "string" && /^https?:\/\//.test(a.url)) ? a.url : "#";
link.target = "_blank"; link.rel = "noopener";
h3.append(link);
article.append(h3);
if (a.description) article.append(node("p", "desc", a.description));
if (a.reason_text) article.append(node("div", "why", a.reason_text));
const acts = node("div", "actions");
const btn = (label, act, key, val) => {
const b = node("button", null, label);
b.dataset.act = act;
if (key) b.dataset[key] = val;
return b;
};
if (a.topic) acts.append(btn("Not today", "notToday", "topic", a.topic));
if (a.flavor) acts.append(btn("Less like this", "lessLikeThis", "flavor", a.flavor));
if (a.topic) acts.append(btn("Always hide " + a.topic, "alwaysHide", "topic", a.topic));
article.append(acts);
return article;
}
function renderList(target, items, showRank) {
target.replaceChildren();
if (!items.length) {
target.append(node("div", "empty", "Nothing here right now — try easing a filter."));
return;
}
items.forEach(a => target.append(articleCard(a, showRank)));
}
// delegated clicks for the per-article gentle actions
function wireActions(container) {
container.addEventListener("click", (e) => {
const b = e.target.closest("button[data-act]");
if (!b) return;
const act = b.dataset.act;
if (act === "notToday") notToday(b.dataset.topic);
else if (act === "lessLikeThis") lessLikeThis(b.dataset.flavor);
else if (act === "alwaysHide") alwaysHide(b.dataset.topic);
});
}
async function loadBrief() {
try {
const b = await getJSON(`/api/brief?limit=5${prefsParam()}`);
if (b.title) el("brief-block").querySelector(".section-title").textContent = b.title;
renderList(el("brief"), b.items, true);
} catch { el("brief").innerHTML = `<div class="empty">No brief yet.</div>`; }
}
async function loadFeed() {
const params = new URLSearchParams({ limit: "30" });
if (browse.topic) params.set("topic", browse.topic);
if (browse.flavor) params.set("flavor", browse.flavor);
const f = await getJSON(`/api/feed?${params.toString()}${prefsParam()}`);
renderList(el("feed"), f.items, false);
}
function chip(label, active, onClick) {
const c = document.createElement("button");
c.className = "chip" + (active ? " active" : "");
c.textContent = label;
c.onclick = onClick;
return c;
}
function pill(label, onRemove) {
const span = document.createElement("span");
span.className = "pill";
span.append(label);
const x = document.createElement("button");
x.textContent = "×"; x.title = "remove"; x.onclick = onRemove;
span.append(x);
return span;
}
function renderPanel() {
const pauses = el("panel-pauses"); pauses.innerHTML = "";
if (!prefs.pauses.length) pauses.append(Object.assign(document.createElement("span"),
{ className: "hint", textContent: "Nothing paused." }));
prefs.pauses.forEach((p, i) => {
const when = new Date(p.until).toLocaleString();
pauses.append(pill(`${p.kind}: ${p.value} (until ${when})`, () => {
prefs.pauses.splice(i, 1); savePrefs(); refreshAll();
}));
});
const mutes = el("panel-mutes"); mutes.innerHTML = "";
const allMutes = [...prefs.mute_topics.map(v => ["topic", v]), ...prefs.mute_flavors.map(v => ["flavor", v])];
if (!allMutes.length) mutes.append(Object.assign(document.createElement("span"),
{ className: "hint", textContent: "Nothing hidden." }));
allMutes.forEach(([kind, v]) => {
mutes.append(pill(`${kind}: ${v}`, () => {
const arr = kind === "topic" ? prefs.mute_topics : prefs.mute_flavors;
arr.splice(arr.indexOf(v), 1); savePrefs(); refreshAll();
}));
});
const terms = el("panel-terms"); terms.innerHTML = "";
if (!prefs.avoid_terms.length) terms.append(Object.assign(document.createElement("span"),
{ className: "hint", textContent: "No avoided words yet." }));
prefs.avoid_terms.forEach((t, i) => terms.append(pill(t, () => {
prefs.avoid_terms.splice(i, 1); savePrefs(); refreshAll();
})));
}
function renderNote() {
const n = prefsActive()
? "Calm filters on — your feed is personalized on this device."
: "";
el("calm-note").textContent = n;
el("calm-toggle").classList.toggle("on", !!prefsActive());
}
async function refreshAll() {
renderPanel(); renderNote();
await Promise.all([loadBrief(), loadFeed(), refreshCounts()]);
}
async function refreshCounts() {
// counts reflect the same filters so chip-able categories match the feed
try { await getJSON(`/api/category-counts?${prefsParam().slice(1)}`); } catch {}
}
async function init() {
wireActions(el("feed"));
wireActions(el("brief"));
const cats = await getJSON("/api/categories");
const tc = el("topic-chips");
tc.appendChild(Object.assign(document.createElement("span"),
{ className: "chip-group-label", textContent: "Topic" }));
tc.appendChild(chip("all", true, () => setTopic(null)));
cats.topics.forEach(t => tc.appendChild(chip(t.key, false, () => setTopic(t.key))));
const fc = el("flavor-chips");
fc.appendChild(Object.assign(document.createElement("span"),
{ className: "chip-group-label", textContent: "Flavor" }));
fc.appendChild(chip("all", true, () => setFlavor(null)));
cats.flavors.forEach(f => fc.appendChild(chip(f.key, false, () => setFlavor(f.key))));
// source preview controls
el("src-quick").onclick = () => previewSource(false);
el("src-deep").onclick = () => previewSource(true);
el("src-url").addEventListener("keydown", (e) => { if (e.key === "Enter") previewSource(false); });
// panel controls
el("calm-toggle").onclick = () => el("panel").classList.toggle("open");
el("term-add").onclick = addTerm;
el("term-input").addEventListener("keydown", (e) => { if (e.key === "Enter") addTerm(); });
el("reset").onclick = () => { prefs = blankPrefs(); savePrefs(); refreshAll(); };
refreshAll();
}
async function previewSource(deep) {
const url = el("src-url").value.trim();
const out = el("src-result");
out.replaceChildren();
if (!/^https?:\/\//i.test(url)) {
out.append(node("div", "empty", "Enter a URL starting with http:// or https://"));
return;
}
out.append(node("div", "empty", deep ? "Deep checking with the model — this can take a moment…" : "Checking…"));
try {
const p = await getJSON(`/api/source-preview?url=${encodeURIComponent(url)}&classify=${deep}`);
renderPreview(out, p);
} catch (e) {
out.replaceChildren(node("div", "empty", "Could not read that feed."));
}
}
function renderPreview(out, p) {
out.replaceChildren();
const box = node("div", "preview-metrics");
const stat = (label, value) => {
const d = node("div", "stat");
d.append(node("span", "label", label + " "));
d.append(document.createTextNode(String(value)));
box.append(d);
};
stat("Mode:", p.classified ? "model (accurate)" : "heuristic (quick, conservative)");
stat("Acceptance:", p.acceptance_rate == null
? `— (all held · ${p.accepted}/${p.sampled})`
: `${Math.round(p.acceptance_rate * 100)}% (${p.accepted}/${p.sampled})`);
stat("Freshness:", `${p.recent_7d}/${p.sampled} in last 7 days · newest ${(p.newest_published||"unknown").slice(0,10)}`);
stat("Calm averages:", `cortisol ${p.avg_cortisol} · ragebait ${p.avg_ragebait} · PR ${p.avg_pr_risk}`);
const mix = (m) => Object.entries(m).map(([k, v]) => `${k} ${v}`).join(" · ") || "—";
if (p.classified) {
stat("Topics:", mix(p.topic_mix));
stat("Flavors:", mix(p.flavor_mix));
}
if (p.examples_accepted.length) {
box.append(node("div", "stat label", "Would surface:"));
const ul = node("ul");
p.examples_accepted.forEach(t => ul.append(node("li", null, t)));
box.append(ul);
}
if (p.examples_rejected.length) {
box.append(node("div", "stat label", "Would skip:"));
const ul = node("ul");
p.examples_rejected.forEach(e => ul.append(node("li", null, e.title)));
box.append(ul);
}
out.append(box);
}
function addTerm() {
const v = el("term-input").value.trim();
if (v && !prefs.avoid_terms.includes(v)) { prefs.avoid_terms.push(v); savePrefs(); }
el("term-input").value = "";
refreshAll();
}
function refreshActive(containerId, value) {
[...el(containerId).querySelectorAll(".chip")].forEach(c => {
c.classList.toggle("active", c.textContent === (value || "all"));
});
}
function setTopic(t) { browse.topic = t; refreshActive("topic-chips", t); loadFeed(); }
function setFlavor(f) { browse.flavor = f; refreshActive("flavor-chips", f); loadFeed(); }
init();
</script>
</body>
</html>