A calm second daily game, same philosophy as Daily Word — LLM proposes, code
disposes.
* LLM proposes a hopeful theme + ~8 words; code validates (alpha/length/dedup)
and PLACES every word in a date-seeded grid, so the puzzle is always solvable.
Curated fallback themes if the LLM is thin. Only placed words are returned;
the solution cells (placements) are never sent to the client.
* GET /api/puzzle/wordsearch → {theme, words, grid, size}. No answer to hide:
the grid and word list are meant to be seen — the play is finding them, which
the client validates by reading the selected line off the grid.
* WordSearchGame.svelte: pointer-drag selection snapped to the 8 straight
directions (mouse + touch), found-word highlighting, no-fail, no pressure
timer — time is recorded quietly and shown at the end with a personal best.
Spoiler-free share. localStorage progress (restores found cells + timer).
* Hub's Word Search card is now live with today's status; cycle pre-generates
both games with the LLM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex's v2 hardening. The GET /api/puzzle/word response no longer carries
the answer at all — guesses POST to /api/puzzle/word/guess and the server
returns the colour pattern, computed against the day's answer. The answer (and
the "why") are revealed only once solved or the guesses are spent. This removes
the "open DevTools, read the answer" issue without pretending to be a fortress
(a deliberate crafted request can still peek; there's no leaderboard or prize,
so that's fine). Client keeps local progress/stats; dict validation stays
client-side. Trade-off accepted: each guess needs the API (the site already
depends on it for today's content).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The variant-watch $effect read `loading`, but load() flips `loading` false at
the end — which re-fired the effect, which called load() again, forever. The
board never rendered. Effect now tracks ONLY `variant`, so it loads once on
mount and once per variant toggle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A calm /play space — "after the brief, a small thing to enjoy." Framework-ready
for more games (Word Search next; zen/coloring later).
* Daily Word (5 letters / 6 guesses) + Long Word (6 / 7) — same Wordle mechanic,
Upbeat Bytes flavor (no "Wordle" in the UI). Hopeful answers; after solving, a
one-line "why this word matters."
* LLM proposes, code disposes: answers are picked deterministically by date-seed
from a hand-curated hopeful pool that's pre-validated ⊆ the guess dictionary
(always typeable), avoiding recent repeats; the LLM only adds the optional
"why" (with fallback). daily_puzzles(date, game, variant, payload) stores them
so everyone gets the same daily; the cycle pre-generates with the "why".
* Bundled guess dictionaries (words-5/6.json, ~12.6k/22.4k) for client-side guess
validation — never the LLM. Answer lightly obfuscated (base64) in the payload.
* Private, gentle stats (played/solved/streak, guess distribution); spoiler-free
emoji-grid share. No leaderboard, no timer, no streak-loss drama.
* Play in the bottom nav (replacing Browse, still on the lane rail) + the header.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two calm returning-reader features.
Since-last-visit (Highlights companion, not a nav lane — per Codex):
* queries.feed gains a `since` filter; GET /api/since?ts= returns the count +
a few accepted/non-dup/visible articles discovered since the reader's last
visit (boundary-respecting; invalid/future ts → 0, no error).
* Home stores last_seen in localStorage (reads prev, then stamps now); on
Highlights, a gentle "Since you were last here, N new calm reads came in"
note with a "See what's new" reveal of a compact inline section. Dismissible.
No badges, no unread counts, no "missed" language.
PWA:
* Real PNG icons (192/512 + full-bleed maskable) rasterized from favicon.svg;
manifest fixed (azure theme to match the brand, PNG icons); apple-touch-icon.
* Minimal service worker: precache the app shell, always-fresh API + /a/ pages.
* Gentle, dismissible install banner (beforeinstallprompt → Install; iOS → the
Share → Add to Home Screen hint). Never nags.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex: saveLanes() bounced to Highlights if the current view wasn't in the
customizable keys, but Following is a special pinned lane (like today/latest)
that's never in keys. Add it to the special-lanes exclusion so editing the lane
picker while viewing Following no longer navigates away.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex — turn accounts into a real reason to return, without an algorithmic
feed. Durable interests (sources + tags), not moods.
* DB: user_follows (user_id, kind source|tag, value, unique).
* queries.feed gains follow_sources/follow_tags → the Following feed is
"articles from a followed source OR carrying a followed tag", still respecting
calm filters/boundaries.
* API: GET/POST/DELETE /api/follows (sign-in required; source ids validated);
/api/feed?following=true resolves the user's follows (anon → empty, not error).
* Frontend: follows store (followKeys + toggleFollow, mirrors savedIds); a
Follow button on source + tag/topic views; a "Following" lane in the nav with
a tailored empty state; a Following management section in Account (unfollow).
Digest "From what you follow" deferred to v2 (brief stays first).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex polish: if someone taps 'Get tomorrow's brief' then dismisses sign-in
without authing, clear pendingDigestOptIn (guarded by !auth.user so a successful
sign-in still auto-enables via the $effect).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* On-site end-cap now says "You're caught up for now." — honest, since Highlights
refreshes through the day (the email keeps the daily "see you tomorrow").
* Anonymous "Get tomorrow's brief by email" now honors the one-tap promise:
sets a pending flag, opens sign-in, and auto-enables once auth resolves.
* Email compliance (RFC 2369/8058): send_email takes optional headers; the digest
sets List-Unsubscribe + List-Unsubscribe-Post=One-Click, and a POST
/api/digest/unsubscribe handles native one-click (GET still serves the page).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On phones the feed is a single column, so the banner no longer needs to keep a
uniform grid height. Below 540px: trim photo banners (16/9 → 2/1) and shrink
image-less placeholders to a slim 54px topic-label band. Desktop/2-column
unchanged (uniform heights still matter there).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex — a per-row Check button that previews a LIVE source on demand,
intentionally read-only and ephemeral.
* POST /api/admin/sources/{id}/preview — admin-gated, safe-fetch + heuristic
preview (reuses the candidate preview path), returns the result. Mutates
NOTHING: no DB write, no poll attempt, no health/state change. 404 on missing.
* UI: per-row Check button with a Checking… state; results in an inline row
under the source (sampled, would-pass %, recent-7d, example accept/skip
headlines) with dismiss; inline error on failure. "Checked just now" is
local UI state only. Heuristic v1 — model deep-check left for later.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex — make the table more decision-ready from data we already have.
Paywall is a domain-level hint, so it's a per-source flag (not a meaningful
rate): show image-coverage % plus a 🔒 marker for subscription domains in one
compact "Media" column (tooltip spells it out). source_health gains a
`paywalled` flag (is_paywalled on homepage/feed); also added to sources.csv.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex v1 — boring-in-the-best-way: inspect/archive operational data outside
the app. Admin-gated, Python csv module, text/csv + attachment disposition.
* GET /api/admin/export/sources.csv — current-state snapshot per source: name,
feed/homepage, status, visible, served/accepted/total, acceptance/duplicate/
accepted-dup/image-coverage %, last success/error, retry-after, review.
* GET /api/admin/export/audience.csv?days= — summary block (visitors, returning,
accounts, feedback, shares) + a blank line + the daily visits/opens series;
range applies to audience, sources is a snapshot.
* source_health now also returns feed_url/homepage. Small download links on the
Sources + Audience tabs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex's spec — a publisher saying "slow down" shouldn't make a feed look
broken, but repeated 429s stay visible via last_success_at / stale-source.
* Schema: sources.retry_after_at (nullable) + migration.
* feeds.parse_retry_after: delta-seconds OR HTTP-date → UTC stamp; ignores
invalid/negative/past; caps at now + MAX_BACKOFF_MINUTES.
* fetch_feed raises RateLimited (carrying the parsed time) on a 429.
* poll_source: on 429 set retry_after_at + last_error, status='rate_limited',
and do NOT increment consecutive_failures; on success clear retry_after_at;
non-429 failures unchanged.
* due_source_rows requires BOTH the streak backoff elapsed AND retry_after_at
passed (i.e. the later of the two).
* Admin: source_health returns retry_after_at; status reads
"rate-limited · rests until …" rather than "failed/resting".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex polish note: promote/reject/re-preview now Object.assign the
server-returned candidate onto the local one — keeps status/updated_at/preview
(and any future fields) in sync, while preserving the transient UI fields
(_cat/_activate/_err). Promote uses res.candidate then loadStats().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bring the supervised source-candidate flow into Sources (Codex's v1 scope), so
adding feeds no longer needs the CLI.
* feeds.safe_fetch_feed: SSRF-safe fetch for UNTRUSTED (admin-pasted) URLs —
http(s) only, every redirect hop re-validated via enrich._host_is_public,
body size-capped, bounded redirects, no cookies. preview_feed gains a
`fetcher` param; the API path passes safe_fetch_feed (NOT the raw fetch_feed
used for already-vetted polling).
* API (admin-gated): GET /candidates; POST /candidates (suggest+preview, gated
before the outbound fetch, no DB conn held during network); /{id}/preview
(explicit re-preview); /{id}/promote (paused by default, returns the new
source + updated candidate); /{id}/reject. rejected stays on candidates only.
* Admin Sources tab: "Add a source" field + a candidate queue showing the
preview (pass rate, recent count, example headlines) with Promote (as paused,
or Activate immediately) / Re-preview / Reject.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex's plan — introduce a lifecycle without a risky "change the source of
truth everywhere" moment.
* Schema: sources.status (active|paused|retired) + content_visible; migration
backfills status from active (active=1→active, else paused), content_visible=1.
* `active` is kept as a SYNCED MIRROR: status active→active=1, paused/retired→0,
so the scheduler/CLI/legacy code keep working unchanged.
* Retire stops polling but keeps articles visible (non-destructive). Hiding is a
separate, reversible lever: content_visible=0 drops a source's articles from
the public feed + brief (read AND build), behind a confirm. Personal saved/
history are untouched.
* API: /sources/{id}/status (validates, mirrors active) + /visibility, replacing
/active. source_health returns status + content_visible.
* Admin: status column (active/paused/retired + "hidden"), Retired filter,
Pause/Resume · Retire/Restore · Hide/Show actions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the Markdown composer with a small contenteditable WYSIWYG (Codex
greenlit for this narrow, admin-only surface).
* markup.py: render_reply_html → sanitize_reply_html + reply_html_to_text.
Allowlist rebuild via stdlib HTMLParser — keeps strong/em/p/br/ul/ol/li and
span ONLY with a whitelisted font-size (13/15/18/22px); normalizes b→strong,
i→em, div→p, <font size> → safe span; drops links/images/arbitrary styles
(content kept as escaped text) and discards script/style content entirely.
* API: FeedbackReplyBody.html (raw editor HTML); endpoint sanitizes → message_html,
derives plain text → stored message + the email text/plain part. Unchanged:
multipart send, store-on-success, conn released during SMTP, mark-read, 404/400/422.
* Frontend: contenteditable editor + toolbar (Bold/Italic/Size/• List/1. List),
execCommand with styleWithCSS=false for semantic tags, font size wraps the
selection in a fixed-px span, paste intercepted as plain text. No links yet.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex's optional note: alongside the ingest-wide duplicate_rate, expose
accepted_dup_rate — of what a source got ACCEPTED, how much was a duplicate of
already-served content (accepted_total − served). Nearly free (derived from
existing counts); surfaced as a tooltip on the Dup column so the table stays
uncluttered.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex: a constrained Markdown-ish composer rather than contenteditable.
* goodnews/markup.render_reply_html — escapes everything first, then introduces
only a tiny whitelist (**bold**, - bullets, #/##/### headings, paragraphs,
line breaks). No links, attributes, inline styles, or raw HTML passthrough.
* feedback_replies.message_html column (+ live migration); replies store both
the Markdown text and the rendered HTML.
* email_send.send_feedback_reply now sends multipart text/plain + text/html
(the sanitized render, wrapped in a trusted email template).
* Frontend: textarea + a small toolbar (Bold / • List / H) that inserts
Markdown; the reply thread renders the server-sanitized HTML.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex's polish note: flagging a source now opens a small inline popover for
the optional reason (consistent with the calm admin UI) instead of a native
prompt(). Clearing a flag stays immediate. Backdrop/Escape/Cancel close it;
Enter confirms. Optimistic with revert-on-failure, like the other actions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Reply to a reader from the admin inbox instead of a mailto. Per Codex: keep v1
plain text (no rich editor — defers the user's bold/bullets ask as a fast-follow).
* DB: feedback_replies table (feedback_id, user_id, message, sent_to, sent_at),
created on the live DB.
* email_send.send_feedback_reply: plain-text "Re: Your Upbeat Bytes feedback"
with a quoted context block, no analytics/account details.
* API: POST /api/admin/feedback/{id}/reply — admin-gated, requires the feedback
exists (404) and has a contact_email (400), trims+caps the message; sends via
SMTP and only records the reply on success (502 on send failure so the UI keeps
the draft); marks the item read. Feedback list now includes each item's replies.
* Frontend: inline composer (Send/Cancel, sending state, error keeps draft) +
reply thread under the message; Reply only shows when there's an address,
else "No reply address".
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/api/admin/stats accepts ?days= (clamped to 7/30/90, default 30) → passed to
admin_stats, which already windows visitors, retention, funnel, sharing, daily
trend, and the top lists by that span. Frontend adds a Window picker on the
analytics tabs (Overview/Content/Audience); changing it refetches and the
windowed labels ("Visitors (Nd)", "Last N days", "Returning visitors (Nd)")
follow. Corpus totals + source health are unaffected (not time-windowed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Turn the Sources tab into a real management console (per Codex):
* source_health now lists ALL sources (active + paused) with backing metrics:
served / accepted_total / total_articles / duplicates + acceptance & duplicate
rates + review_reason, alongside last success/attempt, next poll, failures.
* Admin endpoints (gated, 404 on missing): POST sources/{id}/active (pause/
resume) and /review (flag/clear with reason).
* Pausing only stops future polling — the feed query has no active filter, so a
paused source's accepted articles stay live.
* Frontend: metric table + Paused filter + per-row Pause/Resume & Flag/Clear
(optimistic, revert on failure). Attention 'resting' now scoped to active.
Retire/Delete intentionally deferred (distinct lifecycle state, later).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the admin Feedback section a real inbox.
* DB: feedback.read_at column (schema + idempotent migration).
* API: feedback list returns read_at; POST /api/admin/feedback/{id}/read
{read} toggles it; DELETE /api/admin/feedback/{id} removes a message
(both admin-gated). admin_stats gains feedback_unread; the Attention strip
and the tab badge now count UNREAD, not total.
* Frontend: unread messages are highlighted with an accent rail + dot; an
Unread filter joins the category chips; each message has Mark read/unread
and Delete (confirm), with optimistic updates that revert on failure.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex audit:
* Unknown ?section= values now clamp to Overview, so the page never renders the
tabs with an empty body.
* Summary/image coverage counts join through articles+scores and require
accepted=1 AND duplicate_of IS NULL, so percentages stay ≤100% and honest as
rejected/duplicate rows accrue summaries over time.
* A source that's both resting and flagged now shows "⚠ resting · review"
rather than hiding the review flag behind the resting state.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codex's remaining caveat: appNavDepth decremented on every popstate, so a
browser Back then Forward undercounted (in-page Back would jump to Highlights
early). Use the navigation's signed delta on popstate (Back -1, Forward +1,
±N for jumps) instead of a flat decrement, so the depth stays accurate through
any back/forward dance. Falls back to -1 if delta is unavailable (safe).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex audit follow-ups:
* Track in-app navigation depth (forward goto/link increments, popstate
unwinds, clamped at 0) and base the in-page Back on it instead of
history.length. A direct deep link (email/social/article) now sends the
in-page Back to Highlights rather than out of the site.
* Apply the same stale-load guard to the Today/Highlights path that feed views
have, and only scroll-to-top when the load is still current — avoids stale
error/scroll state during quick navigation.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per audit (user + Codex): the in-page Back and browser Back were two separate
histories, which is confusing — especially for less-technical users. Make the
URL the single source of truth so both traverse one history.
* The view derives from the URL (/?view=latest, /?tag=, /?source=, bare / for
Highlights); `selected` is $derived from $page.url.
* All navigation goes through goto(); afterNavigate is the single loader hook,
so in-app clicks AND browser back/forward reload the same way.
* The in-page Back button now just calls history.back() (fallback to Highlights)
— identical to the browser Back. Removed the private navStack.
* Stop stripping ?source= — the URL stays honest, so source/tag views are
shareable and survive reload.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Position the in-feed Back button to the right of the view title (inline with
it) instead of stacked above; the accent underline moves under the title text.
* Deep-linking a source feed from an article page (/?source=<id>) now seeds the
back history so the Back button appears (returns to Highlights).
* Strip the ?source= param after consuming it (replaceState) so it can't linger
and make the browser back/forward behave oddly.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Back button on feed views: drilling into a tag or source from a card now
remembers where you came from (a small history stack), and a "← Back" appears
in the view header to return there — chains of drill-ins included. Top-level
nav (rail/bottom bar) resets the history.
* Article page: the source name is now a link into that source's in-app feed
(/?source=<id>); the SPA reads the param on load and opens the source view
(label falls back to the loaded feed's source name). Completes the
"cards-only v1" — source is clickable on /a/ too now.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Click a source name on any card → a feed of just that source's articles,
newest-first, still accepted / non-duplicate / boundary-filtered (the calm
promise isn't bypassed). A natural way to follow a publication's feel.
* queries.feed + /api/feed: source_id filter; Article output gains source_id.
* Frontend: source label is a button → transient 'source:<id>' view (like
'tag:<slug>'), rendered in the feed grid with Load more, header = source name.
* Ad-hoc, not a pinned lane. Foundation for a future source page (metadata) +
Follow; shareable /source/<slug> route and source_view analytics come then.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Carry the brief's uniform card language (compact photo banner or flat topic-
colored placeholder) onto every feed grid — Latest, topic/tag/mood views, and
the account Saved grid — so the whole site is visually consistent. Same quality
gate and uniform sizing; the brief hero remains the single large image.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Placeholder: bold, slightly larger initial letter on the topic word; make
health (teal) and environment (leaf green) clearly distinct and show more hue
in the deepened word so they're easy to tell apart.
* Article page: the source name was chopped to its first word ("Read the full
story at The") — use the full publisher name; open the source link in a new
tab so upbeatbytes.com stays put.
* Use the new SVG back arrow on the account and admin top bars (matching the
article page) instead of the old "←" glyph.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review: the summary and the "Why it's here" line were competing in a tight
card and both came up short. Remove "why" from cards (it still appears on the
article page when clicked in) and give the summary the room (image cards 2→4
lines). Also flatten the placeholder banner — solid topic-color tint instead of
a gradient-to-blank, with the topic word in a deep near-black shade of that
color so it reads clearly instead of washed out.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per review, mixed photo/no-photo rows read as ragged ("the ones without feel
lacking"). Make every brief rest card carry a banner so the grid is uniform: a
compact 16:9 photo when available, otherwise a calm placeholder tinted by the
card's topic accent color (the same per-topic hue as the accent line) with the
topic word set faint in serif. A failed/blocked image falls back to the same
placeholder, so cards never look broken and heights stay identical. Hero remains
the single large image. Light desaturation on photos; no heavy tint.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Restructure the nav around two permanent lanes, then the reader's chosen ones:
"Highlights" (the curated daily brief — formerly "Today") and "Latest" (the
freshest accepted stories, newest-first). Now that the gate is tight, a
chronological "incoming" feed is safe to expose.
* feed(): new sort="latest" (pure recency) alongside the default best-first
rank; /api/feed exposes sort=ranked|latest (validated). Still accepted-only
and boundary-respecting either way.
* lanes.py: two pinned lanes (Highlights + Latest) instead of one.
* Home: "Latest" view + "Load more" pagination for every feed view (offset-
paged, de-duped). Mobile bottom bar gains a Latest tab.
* LanePicker shows both pinned lanes; nav rail renders them first.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Date fix: introduce GOODNEWS_TZ (goodnews/localtime.py) so the brief's "today"
rolls over in a pinned zone (Eastern) instead of UTC — robust to host-clock
resets. The home page now formats the brief's date in each VISITOR's local
timezone (from its UTC freshness stamp), so nobody ever sees "tomorrow."
* Admin "Content served": articles live, fresh (7d), ingested (24h), summaries,
active sources, today's brief size — queries.content_stats().
* Admin "Source health": per active source, the failure streak, last error,
accepted contribution, and computed next-poll time (so backoff / "resting
until" is visible), via queries.source_health() reusing the feeds backoff
math. Failing sources sort to the top; times render in the viewer's zone.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pre-traffic cleanup from an audit:
* Scheduler: poll_due_sources now keys on the last *attempt* (success or
failure), not the last success, and scales the wait by the consecutive-
failure streak (capped at a day). A failing feed (e.g. Phys.org's HTTP 429s)
used to be retried every cycle because it had no successful run; it now backs
off and recovers on its own. Extracted due_source_rows() + tests.
* FK hygiene: deleting a daily_brief is supposed to cascade to its items, but
SQLite enforces foreign keys per-connection — connect() already sets the
pragma, so the cascade is correct going forward; added a regression test.
(Orphaned items + Phys.org settings were cleaned directly on the live DB.)
* a11y: modal/drawer dialogs are now focusable (tabindex), close on Escape
(window) and on backdrop click via a target check (dropping the inner
stopPropagation handlers). Build is warning-free.
* tests: conftest points any un-mocked LLM client at a closed port with a 1s
timeout, so an accidental real call fails fast instead of hanging the suite.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a "Lanes" section under Account that reuses LanePicker inline, completing
the round trip with Boundaries. Refactor LanePicker to support an `inline`
variant (bare panel vs modal) and apply changes immediately on toggle — so the
account panel needs no explicit save and the home modal now previews the nav
rail live as you pick. Selection still persists through the shared prefs store.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Readers can now choose which quick-access lanes sit above the feed; "Today"
stays pinned. The pool (goodnews/lanes.py, served at /api/lanes) is one source
of truth over three lane kinds the feed already renders: moods, primary topics,
and high-volume Discovery tags. Selection lives in the existing prefs blob
(localStorage + /api/prefs sync); the filter parser ignores the new `lanes`
field, so it rides along harmlessly. Default = today's moods, unchanged.
Food/Space stay grouping tags rather than primary topics (per review): `space`
already existed; added `food` to the Mind & Craft family so the classifier
assigns it, and seeded the Food lane by re-tagging the two food sources.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the summary pages discoverable so traffic compounds passively:
- /today: a server-rendered, shareable + indexable digest of today's brief —
each item's title (→ /a summary), our summary, and a source link. OG/Twitter
meta + self-canonical.
- /sitemap.xml: dynamic — home, /today, and every accepted non-duplicate /a page
with lastmod. robots.txt allows all and points to it.
- Home (SPA shell) gains canonical + OG/Twitter tags for cleaner unfurls.
- Caddy routes /today + /sitemap.xml to the API. 133 tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>