- 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>
Composition (Codex's priority — content mix was the louder problem):
- _select_diverse now guards the daily five's emotional tone: at most 1 health,
at most 2 science+health combined, at most 2 of any topic, distinct sources —
so at least three of the five are community/culture/animals/environment when
available. Caps relax (mix, then source) only to fill on thin days.
- Verified live: today's five went to environment x2, health, animals, science.
UI:
- Source moved to its own line below the tags, left-justified, for uniform
rhythm across hero and tiles (was sometimes trailing the tags, right-aligned).
- Watermark kept as-is (intentionally subtle; liked).
Tests updated for the emotional-mix contract (80 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Frontend (the premium baseline):
- The hero is now the ONLY image slot. Soft feed images get an atmospheric
gradient overlay; no over-reliance on inconsistent RSS image quality.
- Every secondary/lane card is a uniform typographic editorial tile: no
thumbnails, equal visual weight, a faint topic wordmark watermark, a slim
sage top accent, consistent source, reason text as the trust signal, visible
Replace with quiet tuning actions. Fixes the jarring mixed-media row rhythm
and removes muddy thumbnails entirely.
Backend (composition):
- _select_diverse now balances topics: no more than 2 of one topic while other
topics have candidates (relaxing source then topic caps only to fill), so the
daily five stop clustering medical/science items. Candidates now carry s.topic.
Tests updated for the topic-balance contract (79 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- ArticleCard: derive safeHref from article.url and reset image-failure state
when the article changes, so in-place replacements re-evaluate correctly
(clears the Svelte capture warning; build is warning-free again).
- Downweight paywalled stories below readable ones (stable sort) when composing
the daily five and in feed results — the brief now leads readable and rarely
hands over a locked door.
- review_sources gains a 'paywall-heavy' advisory flag (Nature, New Scientist
flag at 100%); never auto-deactivates.
- New Scientist/Nature kept active but no longer reach the daily five; they
remain browsable with the label + Replace.
- Tests: brief readability preference + paywall-heavy flag (79 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- paywall.py: conservative domain-level paywall detection (New Scientist,
Nature, and common hard/soft paywalls). Never fetches pages — an honest hint.
- API: Article gains a 'paywalled' flag; the brief now leads with a gentle AND
readable story (paywalled/charged stories stay in the five, just not first).
- New GET /api/replacement returns the next-best readable, unshown article
(honors mood+prefs via the merged prefs param; gentle=true for hero swaps).
- UI: paywalled cards show 'May need a subscription'; a Replace / 'Find one I
can read' action (always visible, while tuning actions stay tucked) swaps the
card for a readable alternative, with a gentle notice when none remain.
- Tests: paywall detection + replacement behavior (77 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Today is just the day's five highlights (hero + four) — the preview lanes are
gone; other categories appear only when their mood is selected.
- Each view leads with a large serif heading (Today / Wonder / ...) and a
subtitle, with a quiet sage rule — switching moods retitles the page.
- Drop the now-unused Lane usage from the home.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Text-first lane cards were rendering the full source description, ballooning
them to several times the height of image cards.
- Show the source description only on the hero; lane/grid cards show title + the
one-sentence 'why', which is a tidy uniform summary.
- Line-clamp lane card titles and 'why' to 3 lines (hero description to 6) so
variable text length can't blow up a card. Rows now stay even.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per the calm north star (images support reading, never become a stimulation
layer; metadata-only stays the posture):
- Image-less cards are now designed, not missing: secondary cards are text-first
(no empty media band), and an image-less hero becomes a fully typographic lead
with a faint topic wordmark behind it (CSS attr(data-topic)). No big empty
image space is ever reserved.
- Opportunistic extraction: parse the first <img src> from a feed's
content/description HTML when present, canonicalized — never fetching the
article page. Applies to new ingests (existing rows keep their current image).
- Held by deliberate choice: og:image page enrichment, stock/AI imagery, and any
image-coverage requirement for sources.
Tests: feed HTML image extraction (72 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- New BoundariesPanel.svelte: gentle, device-local controls. Avoid words/phrases
first (the trust-critical piece), then 'Paused for now' and 'Always hidden',
each with easy remove. Reassures 'nothing leaves this device'; adding a term
refreshes the brief/feed immediately.
- Quiet 'Boundaries' toggle (active indicator) replaces the old calm bar, keeping
the first viewport calm.
- Wording stays gentle throughout: avoid / pause / hide / boundaries — never
blocked/banned/blacklist.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Hero guardrail (core to the promise, not cosmetic):
- New hero.py: the lead story is chosen with a stricter filter than the rest of
the brief — very low cortisol/ragebait and no grief/medical/violence terms
(cancer, glioblastoma, death, diagnosis, ...). Such constructive-but-charged
stories stay among the five; they just never lead by default.
- /api/brief applies user avoid-terms FIRST, then lead_with_gentle, so personal
boundaries always take precedence over the general guardrail.
- Verified live: the brief no longer leads with a glioblastoma story.
Card polish (per review):
- Secondary cards with no real image are now text-first (no row of empty media
bands); hero still always shows media or a typographic fallback.
- Inline tuning actions are quiet until hover/focus on pointer devices, and stay
visible (softer) on touch — less interface machinery.
Tests: hero safety + lead reordering (70 total).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Show the typographic fallback for missing images too (not only on load
error), driven by component state instead of imperative class mutation —
which also clears the unused-CSS-selector build warning.
- Only render external links for http(s) URLs, else href=#.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>