Feedback:
- feedback table; POST /api/feedback (anonymous-ok, optional category/email,
honeypot + per-day flood cap) stores + emails the admin; GET /api/admin/feedback.
- Shared feedback store + FeedbackModal; a speech-bubble opens it from the desktop
header, the mobile top bar (logo moves left), the footer, and /account. Feedback
section in /admin.
Stats (additive, same privacy model — no IP/UA/referrer/raw terms):
- Event vocab: summary_viewed (fired on /a load), full_story (card → source),
not_today/less_like_this/hide_topic, replace_used/replace_none, paywall_replace,
paywalled_source_open. Card title/image opens /a (no double-count); history
records via keepalive so it survives the nav.
- Dashboard: Accounts card (counts only), reading funnel (summary→source rate),
emotional-mix & friction, paywall, returning-visitor buckets. (Health metrics
deferred to a future monitoring dashboard.) 131 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make summaries the core reading experience (summary-first, source-forward):
- Cycle pre-warms summaries for Today's 7 (idempotent → only new ones hit the LLM).
- /api/brief items carry their cached summary; Today cards (hero + tiles) show it
inline, so Today reads as a calm briefing.
- Card title/image now open the /a summary page (the canonical artifact), with a
visible "Full story" link straight to the source on every card (the escape hatch).
- /a gains related-grouping chips + a Copy-link/share control.
- Tighten the summary prompt: original, factual, no quotations / no close paraphrase.
Long tail stays lazy+cached. No article bodies stored. 129 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The inline Boundaries/History panels lived on the home page, so opening them while
scrolled left you stranded. Move everything "yours" behind the account icon:
- Home header slims to: Saved (opens a right-side flyout, signed-in) · shield
(Boundaries indicator — filled when active — linking to the Boundaries section) ·
avatar. The inline panels + the home "saved" view are gone.
- /account is now a sectioned hub (left sidebar on desktop, top tabs on mobile),
OPEN TO EVERYONE with each section self-gating: Profile (sign-in), Saved (sign-in),
History (device/account), Boundaries (device/account), Admin (admins). This keeps
Boundaries/History usable without an account (they're device-local) while
consolidating the UI — and every section loads at the top, fixing the scroll bug.
- Lift Calm Filters and History into shared stores (prefs.svelte.js, history.svelte.js)
so the home feed (applies/records) and the account page (edits/manages) share one
source of truth. New SavedFlyout component. Card boundary actions only render when a
handler is provided.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
clearSession only reset the device-local history; for a signed-in user the panel
shows the account history (serverHistory), which was never cleared and never sent
to the server — so it looked like nothing happened. Add DELETE /api/history
(clear all) and have clearSession reset serverHistory + call it when signed in.
- users.is_admin (+ migration); admin = is_admin OR email in GOODNEWS_ADMIN_EMAILS
(normalized). is_admin exposed on /api/auth/me. Server-authorized GET
/api/admin/stats (403 for non-admins).
- queries.admin_stats: visitors (today/7d/30d), returning vs one-and-done, top
opened articles, popular groupings + topics (derived from article_id at query
time), share breakdown, daily opens/visits trend — all aggregate, no PII.
- /admin page (gated, redirects non-admins): stat cards, CSS bar lists, a daily
trend; "Admin dashboard" link on /account for admins. 129 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- events table (kind, article_id, visitor_hash, day) with a UNIQUE key that dedups
to one row per visitor-day — caps volume and makes counts mean distinct
visitor-days. NO ip/ua/referrer/url. Groupings derived from article_id at query
time, never stored.
- POST /api/events (public): whitelisted kinds (visit/open/share_ub/copy_source/
native_share/source_click); visitor token hashed server-side (never raw).
- Frontend analytics.js: random localStorage visitor token; track() via sendBeacon;
visit once/day; open on article click; share_ub/copy_source/native_share from the
share menu; /a landing pages fire source_click. 127 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
client._chat() JSON-parses every response (for the classifier), so the plain-text
summary was rejected ("model did not return JSON") even though the model returned
a perfect summary. Split out _raw_content() and add chat_text() for free-form
output; summaries use it. _chat keeps parsing for classification.
The /a/<id> page now carries an original short summary so it stands on its own,
without republishing the publisher's article:
- summarize.py: transient SSRF-guarded fetch of the article text → local LLM
writes a 2-4 sentence ORIGINAL summary (our words). Cached in article_summaries
forever; we store only our summary, never the body. Generated lazily (only for
shared/viewed articles), de-duped so concurrent hits don't double-generate.
- /a serves cached-or-pending; when pending it shows a calm "summary on its way,
read at {source}" note and self-polls /api/summary/<id>, swapping the summary
in the moment it's ready (never blocks the page on the batch-tier LLM).
- Share menu warms generation on open so recipients usually get the rich version.
- Container reaches the arbiter at arbiter:8080 over caddy_web (LLM env added to
the API container). 124 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Server-rendered /a/<id> "pointer" page (FastAPI): OG/Twitter meta from the
article's title/why/image, self-canonical (UB pages are the canonical for
themselves), a prominent "Read the full story at {source}" button and a quiet
"Explore more on Upbeat Bytes". No article body. Unknown/rejected/duplicate/
malformed ids → a calm 404 (no stack traces). Text-card preview when no image.
- Caddy routes /a/* to the API.
- Card Share control → menu: native Share… (where available), Copy link (the UB
card page), Copy source link. Boundary actions now hide-on-hover via a .mute
class so Save/Replace/Share stay visible. 122 tests pass.
(Event tracking for shares/opens lands with the analytics step next.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
find_or_create_user returned early when the identity already existed, so a
returning Google sign-in never refreshed the profile picture (the name had been
set earlier, at link time — which is why name worked but avatar stayed null).
Now profile bits refresh on every sign-in. Also fall back to the OIDC userinfo
endpoint for the picture if the ID token omits it. 119 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Capture the Google profile picture (picture claim) into users.avatar_url; an
Avatar component shows it, falling back to the initial. Used in the desktop
header and the mobile "You" tab (which now shows the user when signed in).
- Move account/settings to its own route /account (robust + scrolls to top),
reached by the desktop avatar and the mobile You tab; drop the inline "You"
sheet. AccountPanel gains a Sign out action; the page links to Saved/History/
Boundaries via home intent params (?view= / ?open=).
- db: users.avatar_url (schema + idempotent migration). 118 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Prefs sync: GET/PUT /api/prefs store Calm Filters/Boundaries on the account.
On sign-in the client adopts the account's prefs if present, else seeds them
from the device; every change PUTs to the account so tuning follows you across
devices. (Login side-effects run under untrack so browsing doesn't re-trigger.)
- Account panel: GET /api/account (email, connected sign-in methods, saved count,
active sessions); Export my data (GET /api/account/export → JSON download);
Sign out everywhere (revoke all sessions); Delete account (cascades to all
account data) with an inline confirm. Reachable from You → Account.
Deferred to a follow-up: link/unlink a provider (OAuth link-mode) and per-session
revoke. 118 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
History was logging every article merely displayed, which made it noise. Split
the two concepts cleanly:
- "displayed" (seenIds) still tracks everything shown, but only to stop Replace
recycling stories — it no longer feeds history.
- "history" now records only deliberate events: articles the user OPENED (card
click) or ones they REPLACED away (recoverable accidental swaps).
Also: per-item removal (× in the History panel; DELETE /api/history/{id}), and
when signed in the panel shows the account (cross-device) history. First-sign-in
import now folds the meaningful history (not everything shown). Copy updated.
115 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- API (auth-required): GET/POST/DELETE /api/saved (+/api/saved/ids), GET/POST
/api/history, POST /api/import — all FK-safe (skip ids that no longer exist).
queries.saved/saved_ids/history reuse the feed article shape.
- Frontend: reactive savedIds store (SvelteSet) + optimistic toggleSave; a Save
control on cards for signed-in users; a "Saved" view (You sheet) with its own
empty state; newly-seen items mirror to account history (cross-device); and a
one-time import folds this device's anonymous history into the account on first
sign-in. Anonymous browsing unchanged. 115 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- oauth_google.py (stdlib): PKCE, auth URL, code exchange, ID-token claim
validation (iss/aud/exp/email_verified — token comes straight from Google's
token endpoint over TLS, so no signature re-verify / JWKS needed).
- API: GET /api/auth/google/start (302 to Google, PKCE + signed state cookie
binding the flow to the browser) and /callback (CSRF-checked state, exchange,
find-or-create by verified email → links to an existing magic-link account,
session cookie, redirect home). Errors land on /auth/verify?error=google.
- SignIn modal: "Continue with Google" + an "or email link" divider.
- 112 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The SMTP send (connect → TLS → login → handoff to the relay) ran synchronously
inside POST /api/auth/email/start, so the "Sending…" button waited the whole
handshake. Move it to a FastAPI BackgroundTask: the token is created + committed,
the request returns immediately, and the email sends off the request path. Reply
stays identical (no account enumeration). Tests pass (TestClient runs the task).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Shared reactive auth store (auth.user) + postJSON helper (sends the cookie).
- SignIn modal: email -> "check your inbox" (calm, no password); Google slots in
here in Phase 2.
- /auth/verify route exchanges the magic-link token for a session, then home.
- Header shows "Sign in" or an account avatar; the You sheet gains "Signed in as
…" + Sign out (or a Sign in row). Anonymous browsing is unchanged.
- POST /api/auth/email/start — validate email, rate-limit, email a single-use
magic link (identical reply regardless, so no account enumeration).
- POST /api/auth/email/verify — consume token, find-or-create user, open a
session, set an httpOnly cookie (web) and return a bearer token (app).
- GET /api/auth/me, POST /api/auth/logout.
- Session resolved from cookie OR Authorization: Bearer; cookie is Secure in
prod (https), relaxed for http so tests round-trip. CORS now allows POST.
Live SMTP send verified against the DNSExit relay (587/STARTTLS). 108 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Groundwork for self-hosted accounts (magic link + Google later), no third parties.
- db: account tables (users, identities, login_tokens, sessions, saved_articles,
user_history, user_prefs); identities link multiple sign-in methods to one user
by verified email. connect() now enables WAL + busy_timeout so the API can write
account data alongside the host ingestion cycle.
- auth.py: users/identities (find-or-create + link), single-use magic-link tokens,
opaque sessions — all secrets stored only as SHA-256 hashes.
- email_send.py: minimal STARTTLS SMTP sender + the magic-link email.
Secrets (SMTP, Google, session) live in the API container's env_file, not git.
API endpoints + sign-in UI come next. 105 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BBC's og:image comes from the "branded_news" CDN path with a "BBC NEWS" logo
baked into the picture (shows as "…EWS" once the hero crops it). The identical
photo is served under "cpsprodpb" with no logo, so rewrite branded_news →
cpsprodpb. Best of both: full-resolution hero, no burned-in branding. Re-enriched
recent briefs so live images swap over. 99 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
og:image extraction rejected any URL containing "branded_news" as a generic share
image, but that's BBC's normal CDN path for real article photos. So every BBC hero
fell back to the 240px RSS thumbnail (blurry when shown large). Drop that marker;
keep the genuine placeholder markers (facebook-default, og-default, etc.). Updated
the test to assert BBC branded_news paths pass through. 99 tests pass.
(One-time: cleared image_checked_at on the 57 previously-checked articles and
re-enriched recent briefs so existing thumbnails upgrade to og:images.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause (Codex audit): the client pins the brief by generated_at, but image
enrichment populates image_url AFTER the brief is built without bumping
generated_at — so a verbatim pinned copy stays imageless even once the server
has the image. The reclassify rebuilt the brief and the early pin stuck.
- Frontend: when reusing a pinned brief (same generated_at), refresh server-owned
metadata by article id (esp. image_url) while preserving the user's order and
replacements. Re-saves the merged view so it stays current.
- enrich_brief_images: default limit 5 -> 7 (any brief item can become the hero
via the client fallback or a replace, so cover the whole brief).
- Don't cache image failures forever: retry brief items still missing an image
after a TTL (retry_days=2) instead of stamping them imageless permanently.
Pairs with the hero image fallback (dd0087b). 99 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Some sources hotlink-protect their images (e.g. Guardian's i.guim.co.uk → 401),
so a perfectly-enriched lead could still render an imageless hero. The browser is
the only true judge of loadability, so on a hero image error, promote the next
brief item that has an image into the hero slot; the failed lead becomes a text
tile. Resets to the lead on each fresh brief.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- The accent line is now tinted by the article's primary topic (muted sand/sea/sun
tones), adding quiet variety across the grid. Falls back to the brand azure for
unknown/untagged topics.
- Raise the card-header height (84→94px) so the centered pills sit comfortably
clear of the accent line and divider.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Centering inside .tags could never look right: the accent line (.body::before),
its margin, and the body gap lived OUTSIDE the centering context, but the eye
measures the band from accent line to divider. Per Codex's audit, restructure
into one .cardhead unit — a fixed-height grid (accent row + a 1fr row that
centers the pill block) that owns the divider. Now the centered band is the band
you see, so 1-, 2-, and 3-pill cards sit evenly with aligned dividers and titles.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
align-content:center is unreliable on wrap containers (a single wrapped line is
treated as single-line and ignored), which left pills top-aligned. Wrap the pills
in a .pillrow and vertically center that block with a column flex +
justify-content:center on the fixed-height zone — no single-line ambiguity. Pills
now sit evenly centered for 1-, 2-, and 3-pill cards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
54px barely cleared two rows, so 3-pill cards filled the zone edge-to-edge while
1-pill rows had slack. Raise the zone to 64px so the wrapped case keeps symmetric
top/bottom margins; centering then reads evenly across 1-, 2-, and 3-pill cards.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
min-height was shorter than two rows of pills + padding, so two-row cards grew
taller than one-row cards and their dividers/titles dropped lower. Size the zone
to fully contain two rows and drop the asymmetric bottom padding; with centering,
single-row pills get even space above and below and every card's divider and
title line up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Single-row pill cards now sit centered in the two-row zone instead of pinned to
the top, so they balance visually against two-row cards. Titles already aligned;
this aligns the pills themselves.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Builds + rsyncs the static site without the API container rebuild/Caddy reload
that publish.sh does — for quick UI/CSS/copy iteration. Use publish.sh when
backend changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Cards with a third tag that wrapped to a second row pushed their title/source
down, breaking alignment across the grid. Reserve a consistent two-row min-height
for the tag zone on tiles (pills top-aligned) and close it with a hairline, so
titles line up regardless of pill count. Hero opts out.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "wander" layer for the multi-tag model, sitting beneath the brief:
- Cards show up to 3 tappable grouping pills (the article's tags), falling back
to the primary topic for articles the re-tag hasn't reached. Tap a pill →
that tag's lane. Tags read as little doorways, not metadata confetti.
- New tag-lane view (select 'tag:<slug>' → /api/feed?tag=) with a calm heading
and the parent family's description as subtitle.
- Replace the flat "Explore by topic" strip with four calm family bands
(Discovery & Wonder / People & Kindness / Solutions & Progress / Mind & Craft)
from /api/families; zero-count tags hide until tagging fills them in.
- Mood nav stays the primary emotional layer; the brief stays the front door.
- /api/families fetch is non-fatal so the page degrades gracefully when the B1
backend isn't deployed yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Header logo sized up to read clearly (54px desktop / 46px mobile, bars to match).
- Self-host Inter (variable, latin) — no external font calls — and use it for
the category tags as uppercase Light (300) kickers with tight tracking, for a
clean, polished label feel.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Add the real Upbeat Bytes lockup (logo.svg) and use it in the header,
replacing the placeholder inline mark + text wordmark.
- New square favicon: the logo's rising sun (bright gold) on azure.
- Recolor the design system around the logo's #0083ad azure: rename the
--sage* accent vars to --accent*, with deep/soft azure tints; navy ink
(#16263a) echoing the logo's "Bytes"; cool slate muted text; a deep gold
for text-weight accents plus --gold-bright for decorative fills; warm
sand paper background. No urgency colors.
- Retint the hero image overlay and the no-image card gradients to match.
- theme-color → azure.
Built clean; frontend tests/build pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three-layer organization: primary topic (one per article, for ranking and
brief balance) + grouping tags (1-4 per article from a controlled vocabulary,
the organic "wandering" axis) + tonal flavor.
- taxonomy: add technology + learning topics; 4 calm tag families
(Discovery & Wonder, People & Kindness, Solutions & Progress, Mind & Craft)
defined in code, not the DB; ALLOWED_TAGS union + coerce_tags validation.
- db: article_tags(article_id, tag) join table + tag index.
- llm: tags added to the classifier json_schema (enum-constrained, maxItems 4)
and system prompt; normalize_scores coerces tags; upsert_article_score
replaces a row's tags atomically on every (re)classification.
- queries: feed gains a tag filter and exposes tags via group_concat; tag_counts.
- api: Article.tags, feed tag param, and /api/families with per-tag counts.
- tests: coerce/normalize/upsert/tag-filter/reclassify-replace/tag_counts +
/api/families. 99 passing.
Corpus reclassify (re-tag + new primary topics) runs separately against the
local LLM. Frontend (B2) pairs with this; the live site is unchanged until then.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Logo mark: SVG rising-dots wave (sage dots + warm gold peak = 'upbeat bytes'),
used as favicon/PWA icon and in the header.
- Header: full-width app bar — mark + mixed-type wordmark (Upbeat serif ink /
Bytes sans sage) on the left, housed Boundaries/History utility cluster on the
right (desktop). No more floating text links.
- Mobile: fixed bottom tab bar (Today / Browse / You); utilities move into a
'You' sheet. One-handed, modern, calm.
- Browse: moods stay the primary front door; added a quiet 'Explore by topic'
section (existing topics) below the content — selecting a topic loads its feed.
- Layout trimmed (header now in-page, full width); footer keeps clearance for the
bottom bar.
Phase A of the consensus pass; Phase B (add technology + learning topics and
reclassify) is next. Live site untouched until publish.sh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
One command to rebuild the frontend, sync it to the live Caddy site, refresh the
API container, and reload Caddy. README documents the upbeatbytes.com topology.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lets the API run as a read-only replica against a shared DB owned by the
ingestion CLI — needed for the production split (Caddy-proxied API container
reading the host-written database).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Masthead, page <title>, PWA manifest name, and footer now say 'Upbeat Bytes';
README headline updated. The internal Python package/CLI stay 'goodnews' (no
functional reason to rename, and it avoids churn).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
dismissed.size is read in the template (the History 'Clear' control), so the Set
must be $state for Svelte 5 to track .add()/reassignment. Build is warning-clean
again. Frontend only — rebuild + refresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the agreed model: the brief is server-authoritative and a client Replace is
a soft override that yields when genuinely new data arrives.
- build_daily_brief is now idempotent: if the composed selection is unchanged it
leaves the brief (and its created_at) alone, so the timer's 15-min rebuilds are
no-ops when no new data landed.
- /api/brief exposes generated_at (the brief's created_at = a content-change
stamp). The client pins its view against generated_at and keeps it across plain
refreshes, but drops it and shows the fresh server brief when generated_at
advances. Missed stories remain in the mood feeds.
Tests: idempotent rebuild (no-op vs content change) — 93 total.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Persisting only 'dismissed' kept swapped-away stories out but let the brief
recompose on refresh — so a chosen replacement (and the hero) could change
unexpectedly. Now the reader's actual brief view is persisted per day:
- loadToday keeps the saved view for the same brief_date (swaps and hero hold
steady); re-fetches fresh on a new day or when forced.
- A boundary change forces a fresh re-fetch (and re-pins); Replace pins the new
view; Clear-session drops the pin so it re-composes fresh.
Frontend only — rebuild + refresh (no server restart needed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mood feeds now honor the same dismissed list as the brief: /api/feed accepts an
exclude param (over-fetching to stay full), and the client passes the persisted
dismissed set. Swapping a story away now keeps it gone everywhere — brief and
browse — not just on the home view. Also simplified the feed filter path to the
shared _prefs_sql_kw helper.
Tests: feed exclude (91 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A reader who swaps a story away should keep that swap after a refresh; before,
the server re-served the original brief.
- localStorage now persists seen / dismissed / history (loadJSON/saveJSON).
- /api/brief accepts an exclude list; dismissed (replaced-away) ids are dropped
and the highlights refill around them, so swaps stick and stay full.
- Replace records the swap to dismissed+seen and persists; the seen-set
(persisted) keeps Replace from recycling across refreshes too.
- History panel survives refresh and gains 'Clear what I've seen (start fresh)'
so it never feels suffocating. Saved history/favorites still come with sign-in.
Tests: brief exclude + refill (90 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
When a reader's boundary (avoid-term, muted topic/flavor, pause) removes a brief
item, top the highlights back up with other readable, boundary-respecting good
news instead of showing fewer cards — so 'Highlights from Today' stays full and
still honors what they don't want to see. (Reverses the earlier filter-down-only
MVP, now that the count is fixed at seven.)
- /api/brief: after filtering by prefs, refill from the accepted pool (same
categorical SQL filters + avoid-terms) excluding already-shown items.
- Shared _prefs_sql_kw helper for feed/replacement/brief filters.
- Tests: refill stays full and respects mute + avoid-terms (89 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 'npm run dev' now binds the network (vite dev --host) so the HMR dev server is
reachable from another machine.
- README documents the two-terminal loop (serve --reload + npm run dev via the
/api proxy), so iterating no longer needs build + restart + hard-refresh.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- og:image enrichment now skips branded/generic share images (BBC 'branded_news'
with its burned-in logo, NPR 'facebook-default', etc.) and keeps the first
real article image — so no competitor logo lands on our hero. Cleared the few
already-stored branded URLs so they re-enrich.
- Hero selection now prefers a gentle + readable story that also HAS a (clean)
image, falling back to gentle-readable, then gentle. The lead is visual when
possible, typographic otherwise — never branded.
(The '7 cards' report was a stale browser cache: the brief stores 7 and the
built JS requests 7; a hard refresh shows all seven.)
Tests: branded/generic image rejection (87 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Hero blur fix: brief enrichment now prefers a page's og:image even when a
feed thumbnail exists (feed thumbs are often tiny; the hero is shown large).
Verified: BBC hero upgrades to the 1024px share image, ScienceDaily to 1920px.
- Today is now 'Highlights from Today' — hero + 6 (brief size 7), which also
makes the secondary grid a balanced 3+3 instead of an orphaned 3+1.
- Replace now excludes every article seen this session (a client-side seen-set),
so it never cycles back to something already shown.
- New session History panel (this tab only, no account): lists everything seen,
including swapped-away stories, so they stay recoverable. Persistent
history/favorites are tabled for sign-in later.
Tests: og:image upgrade of an existing feed image (86 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The grid stays typographic; the hero is the one intentional visual slot. At
brief-build time we fetch a hero-quality image for the daily five that lack one:
- enrich.py reads ONLY a page's <head> og:image/twitter:image and stores just
the URL (never the body).
- SSRF-guarded: http(s) only, 6s timeout, 300KB cap, <=3 manual redirects each
re-validated, and hosts rejected if any resolved address is private, loopback,
link-local, multicast, reserved, or unspecified.
- image_checked_at column caches success AND failure, so an article is never
retried forever.
- Wired into build-brief and cycle (brief items only, only if image missing and
unchecked). Everything else stays metadata-only.
- Verified live: today's five all carry images (feed + enriched).
Tests: og:image parser, head-only scope, IP guard across internal ranges, and
enrich success + failure-caching (85 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>