Two jewel cards at a time, reader-advanced (no auto-motion); 3 cells total, wraps. Keeps
each card at generous size instead of cramming three.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Decorative tiles (no live answer = no spoilers); placeholder copy. Built from CD's
Play Card Graphic handoff.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- News summary + headline clamped to 3 lines so the long brief gist no longer stretches
the bento (option 1; placeholder-bubble option still available if preferred).
- News CTA "Read the brief" → "Read the good news" (clearer for first-time visitors).
- Art swatch crops a few px off every edge (::after inset) to remove the black scan edge
at the top of paintings.
- Logo 42→48px, nav 15→16.5px, card titles bumped + weight 500→600 so they pop on scan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Rebuilds the design handoff's preferred Frame A: Newsreader serif headlines + Hanken
Grotesk body (both self-hosted, OFL, no Google hotlink), warm cream canvas, per-card
accent tints (News teal, Art plum, Play amber, Moment green), bento grid.
- Uses our real /logo.svg instead of the mock's Bricolage wordmark + sunrise.
- Wired live: Good News pulls the top headline/summary/photo (respects the saved
Closer-to-Home filter); Daily Art pulls today's Met piece (title/artist/year + thumbnail).
- Hidden prototype (noindex), spacing tuned per the /home2 pass (hero pulled up, more air
before the bento). Sits beside /home2 for comparison.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- All four cards now tinted with a taller (18px), darker (~24%) top color strip.
- Photos (news top, art right) sit inset + rounded so the card colour frames them.
- Static cards: header centered; CTA pinned to the bottom so Play/Daily Moment links line
up. News tint = soft amber, Art = soft lilac, Play = sky, Moment = sage.
- Hub spacing: intro pulled up a touch, more gap between the intro and the cards.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Art photo moved to the RIGHT (text/icon left) so it no longer clusters with the news
photo in the center.
- Unified header: every card now has icon + title side-by-side; card titles switched from
serif (too 'document') to Manrope bold. Art icon/title now match Play/Daily Moment sizes.
- Static cards (Play/Daily Moment) gain a darker top color strip (Monopoly-card feel) over
the tinted body, derived from each tint via color-mix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Self-hosted Manrope (OFL) as the hub sans; nav lighter (weight 500, soft slate, not all
"on"). Logo up to 58px.
- News card: photo on top + headline below, and it now respects the reader's saved
Closer-to-Home filter (goodnews:home/homeScope) so the headline matches their Brief.
- Art card: rectangular cover-cropped thumbnail on the LEFT (crops ragged scan edges),
text on the right — variety against the photo-top news card.
- Play/Daily Moment: tinted backgrounds, bigger centered icon+title, blurb left-aligned.
- /fonts/* + /textures/* served immutable (Caddy live + snapshot).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New sections registry (lib/rooms.js): each room is one data entry (title/blurb/href/cta/
size/preview/icon) — add or resize by editing the list.
- Reusable RoomCard (lib/components/RoomCard.svelte) with size variants and hybrid previews:
Art shows today's live thumbnail, News shows today's top headline, others are blurb+CTA.
- /home2 hidden prototype (noindex, unlinked) with a bigger top bar (logo 50px, larger nav
labels) and a reflowing grid hub of the four rooms (News/Art/Play/Daily Moment). Iterate
the look here, then promote to / and remove the clone.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- /api/art/image/{id} now answers HEAD as well as GET (was 404 on HEAD) — mirrors the
/a/{id} fix. Added tests/test_art_api.py (GET+HEAD+size=full fallback + today payload).
- /textures/* served immutable (long cache) instead of no-cache; excluded from the
revalidate matcher. Live Caddyfile + repo snapshot both updated.
- Lightbox: Escape closes it, and focus moves to it on open (keyboard-friendly).
- Trimmed the gallery's top padding so "Daily Art" sits closer to the bar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Woods are now built from four real mitered rails clipped to 45° joints, each showing the
grain along its length — horizontal on top/bottom (rotated texture wood-grain-h.jpg),
vertical on the sides — so the grain TURNS at every corner like real cut moulding. Each
rail carries a directional bevel (lit top-left) for a rounded profile; per-species recolor
moved onto the rails. Rendered via a Svelte snippet in both page + full-screen frames.
- Gold/Silver gain fine brushed-metal striations over the existing sheen.
- New asset: wood-grain-h.jpg (the CC0 texture rotated 90°), credited in textures/CREDITS.txt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- No frame in full-screen now fills the screen (img max 96vw / 88vh instead of the
framed cap).
- Full-screen backdrop is a soft top-lit gallery wall (light, not dark) so the Black
frame — and every frame — reads like a piece hung on a real wall; caption text darkened
to match.
- Mat now sits recessed below the moulding: a rabbet groove line at the lip, frame shadow
on the mat's top/left, catch-light on bottom/right — a small chiseled step in place of
the flat frame→mat transition.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Black frame (no grain) added after Silver — a satin charcoal moulding in the metals family.
- Full-screen rail bumped (clamp 18–36px) so the moulding reads as thick as the page view
against the much larger image; mat held.
- Thickness slider extended to 1.9× and the mat now caps at 1.5× (min()), so the top of
the slider thickens only the wood while the white border stays put. Applies to the
full-screen frame too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Replace procedural grain with a real fine-grain wood texture (CC0, Poly Haven —
static/textures/wood-grain.jpg). The three woods share it, recolored per species via
CSS filter on ::before: Walnut (as-is dark), Oak (lightened/warmed), Mahogany (new —
deepened/reddened). Miters now apply to all three woods; mat sits between texture and
miters via z-index.
- Full-screen sizing fixed: image capped (max-height 66vh) so the bottom rail always
fits — no more cutoff. Rail/mat dialed back to ~1:1 wood:white (was mat-heavy), only
slightly larger than the page so the proportion matches the small view.
Texture credit (CC0, no attribution required) recorded in static/textures/CREDITS.txt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Oak grain is now a real feTurbulence noise field (organic wood, no drawn-on lines),
with a soft light/shade for moulding roundness; miters kept.
- Thickness slider (0.7–1.5×) scales rail + mat via --frame-scale, remembered locally;
applies to both the page and the full-screen view.
- No-jump layout: every frame — including "No frame" — reserves the same footprint
(rail + mat as CSS vars), so switching never reflows the page. "No frame" is the bare
art floating with a soft shadow.
- Full-screen wears a thicker rail + mat so the white border keeps its proportion at
large size; "No frame" still maximizes there.
- Frame chips redesigned as beveled beads with a clean offset selection ring.
- Subtle fading hairline divider between the title and the artwork.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New "Oak" frame: real wood-grain along the rails plus 45° mitered corner joints
(CSS conic-free miter via per-corner diagonal seam gradients sized to the rail width),
so it reads as four lengths of moulding meeting at the corners.
- The lightbox now wears the selected frame too — the same moulding + cream mat around
the full-resolution image, which makes the piece pop on a dark full screen. Image is
capped to leave room for the frame; "No frame" still maximizes size.
- Frame picker wraps gracefully now that there are five options.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Virtual frames (Walnut/Gold/Silver/None), selectable + remembered in localStorage,
built as a beveled moulding around a cream museum mat.
- Header uses the real /logo.svg wordmark; the "No ads" pill is replaced by an
account icon (the pill doesn't need to follow every page).
- Lightbox now opens a full-resolution copy that fills the screen: art._download_image
caches a hi-res {id}-full copy alongside the web-large display copy, served via
/api/art/image/{id}?size=full (image_url_large in /api/art/today).
- Centered the placard bullet separators (explicit .sep spans, equal margins).
- Image no longer shifts on hover; a quiet "Click to expand" affordance sits on the art.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A calm "walk up to one piece on a quiet wall" page for the Daily Art engine. Bright,
modern, cream canvas (not a dim brown gallery) so the artwork's own colors are the pop
— the new look prototyped, scoped to /art so it doesn't touch the rest of the site yet.
- Fetches /api/art/today; large soft-shadowed frame (click -> lightbox), a museum
"placard" (serif title, artist · date, medium, "from The Met · Public Domain (CC0)",
View-at-museum link). Calm loading/empty states. Unlinked from the homepage (safe to
iterate). Ships the (Codex-cleared) art backend too.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hardening before it runs further on the cycle:
- DB-lock/network: all HTTP (metadata + image) happens before any write; the write txn
opens only at the brief INSERT and commits immediately. Images download to a temp file
then atomic os.replace into cache (a reader never sees a half-written file).
- Site-timezone "daily" already used local_today() (same rhythm as the Brief) — confirmed.
- Attribution from day one: store + return title/artist/date/medium/department/credit/
source_url/object_id/source + museum name + is_public_domain license marker + the full-
res source URL (for a richer /art view later). UI can show: Title · Artist · The Met.
- "highlight != always beautiful": added a manual `blocked` flag on art_pool (excluded
from picks) as the cheap curation lever; a featured override can follow.
Schema migrated (existing art tables get the new columns). 373 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The engine for the /art room (design-independent; deploy held for Codex review).
- goodnews/art.py: harvest a curated pool of public-domain HIGHLIGHT artworks from the
Met (isHighlight+isPublicDomain+hasImages -> masterworks, never potsherds; CC0). Daily
deterministic pick from the least-recently-shown (no soon-repeats, same for everyone),
fetch metadata + download the image to OUR cache (data/art_cache) so the homepage never
waits on or hotlinks the museum. Bulletproof: bad object/image falls through candidates;
a failed day keeps the last piece (room never empty). Injectable HTTP for tests.
- Schema: art_pool + daily_art. /api/art/today (edge-cacheable) + /api/art/image/{id}
(served from cache, immutable). CLI `art [--harvest] [--force]` + a non-fatal cycle step.
- Tests (5, mocked HTTP) + verified live against the Met: harvested 1641 works,
picked/cached "Repose" by John White Alexander. 371 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per the brand-name standard (camelCase, one word). Updated the SMTP From default and
the digest email body/subject strings. Live env From values (auth.env + goodnews.env)
updated to match. (Web/OG brand strings in share.py + app.html are the remaining sweep.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Hero constraint: _pick_lead now runs only within the CLOSEST non-empty section of a
personalized Brief, so a "gentler" wider-region/world story can never be floated into
the hero slot above a local one. Only widens if the closest section is empty.
- Dial gains a visible Clear (alongside Change) so a reader never feels locked into
personalization; "World" stays the keep-home-but-go-global option.
366 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex-approved evolution: the reader controls the "emotional radius" of the landing.
- Census-region "Regional" grain (geo.region_of / region_states). Scope-aware tiering
(queries.home_tiers): closest->widest lead, confidence-gated on state + region, never
a hard filter — blends outward so the set is always full. 'world' = the global brief.
- queries.home_brief takes a scope; /api/brief gains a scope param (nearby|region|
country|world). Country-only / non-US homes collapse to country.
- Homepage dial replaces the 2-button toggle: adaptive stops (4 with a US state, else
Country/World), persisted scope, "Good news closest first" framing. Concrete, soft
section labels (Around New Jersey / Across the Northeast / Across the US / Around the
world) so the reader sees the dial worked.
Backend 366 + frontend tests green. (Latest feed still on v1 local-first; aligning it
to the dial is the immediate follow-up.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per the owner's call (overrides the earlier "Brief sacred" stance): when a home is
set, the homepage opens with local good news first, not global. This is the hook —
you land and see awesome stories from YOUR corner first.
- queries.home_brief: local-first highlights (high/medium-confidence near, blended
out to country then world so it's always a full, strong set), preferring already-
summarized stories so the calm read stays rich. Recent window, ranked within tier.
- /api/brief gains a `home` param: private/no-store when set; over-fetches + caps so
dismissal/boundary filtering never thins it; falls back to global top-up if needed.
- Landing UI: a Local <-> Global toggle ("📍 Near you / 🌍 Everywhere") when a home
is set, the calm picker invite when not (dismissible), and Change. Default leads
local; one tap back to the global brief. No home set => exactly today's behavior.
Backend + frontend tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex polish before deploy: anything elevated as Near you / Close to home must have
geo_confidence in (high, medium) — the feature's promise is relevance. Country-only
mode now gates "near" too; since it has no "country" tier, the "world" scope is
widened to absorb low-confidence home-country stories so they surface there instead
of vanishing between tiers (the same edge-case class, fixed). State mode unchanged.
364 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes "Closer to Home" (Codex UX: obvious once, quiet forever).
- Opt-in home (country + optional US state) in localStorage; empty = default feed.
- Calm inline prompt above the browse feed ("Want good news closer to home?")
with a country/state picker; dismissible and remembered (no nagging). Once set,
a slim "📍 Showing local first · Change · Clear" indicator replaces it.
- Browse feed passes home and pages by next_offset (the near/country lead block
never skews world paging); soft section headers (Near you / Elsewhere in your
country / Around the world) render only for tiers that exist.
- Only affects the default browse lane; Brief, topic lanes, and the shareable
default feed are untouched.
Follow-up nicety: mirror the Home setting inside the Boundaries panel.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the server side of "Closer to Home". /api/feed gains a `home` param
('US' or 'US-NY'); when set the response is private (like prefs) and sectioned:
- Near you (+ Elsewhere in your country when a state is set) is a ONE-TIME lead
block on page 0; the world is the paginated body. next_offset tells the client
where to continue, so the lead block never skews world paging.
- Thin tiers fold down (MIN_TIER=3) so a header is never shown empty (lead, don't trap).
- State match counts only on high/medium geo confidence; the "country" tier excludes
exactly what went to "near", so a low-confidence home-state story still surfaces
(it doesn't vanish between tiers — caught + tested).
- Items carry a `section` tag; paywalled sort is now within-section. No home => exact
prior behavior (section null, default/edge-cached feed unchanged), Brief untouched.
364 tests green. Frontend next: Home picker + sectioned feed rendering.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Foundation for "Closer to Home" (server-side, Codex-approved). No behavior change
yet — geo_scope defaults None, so the default/edge-cached feed is identical.
- queries.feed now returns each article's geo (breadth, confidence, and ISO-coded
places) via a LEFT JOIN + places subquery. Article.from_row parses geo_places
into [{country, state}]. Brief query doesn't select geo, so the Brief stays bare.
- queries.feed gains home-scope filters (home_country/home_state/geo_scope =
near|country|world): STATE match only counts on high/medium geo confidence;
untagged articles fall to 'world' so nothing is lost during backfill.
Next: API composition (home param + near/country/world sectioning with soft/blended
headers + a next_offset pagination model) and the Home picker UI. 360 tests green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"Closer to Home" foundation (audit greenlit by Codex). Durable geography, kept
decoupled from volatile scoring.
- Schema: article_geo (breadth/confidence/rationale/geo_version) + article_places
(0..N ISO-coded places), separate from article_scores so re-runs/audits never
disturb scoring or acceptance. "local" is never stored — it's relative to the
reader; the UI computes "Near you" later.
- geo.py: LLM proposes place NAMES, code disposes to ISO codes (country alpha-2,
US state 2-letter); region words like "Europe" can never become a country.
'global'/placeless is first-class, not failure. Confidence calibrated so 'high'
needs an explicit location. Geo is its OWN LLM pass, not merged into the scoring
prompt (durable metadata, re-runnable, keeps the sensitive prompt untouched).
- store_geo replaces places (geo is re-derivable, unlike scores). tag_articles is
idempotent by geo_version, only touches accepted non-duplicate articles.
- CLI `geo` command (cycle-locked, --limit/--reclassify) for backfill, plus a
bounded geo step in the cycle (--geo-limit 60, --no-geo). scripts/geo_audit.py
is the prototype audit tool.
360 tests green; live smoke tagged real articles correctly (Gaza->PS, London->GB,
placeless science->global). No UI / SEO pages yet — ranking/personalization only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sharpen the existing daily-game share loop into something measurable (per Codex's
"instrument what you have, then feed people into it" plan), ahead of a Show HN launch.
Analytics:
- Per-game funnel events <game>_{arrival,started,completed,shared} (article_id=0).
arrival = landed via a shared link (utm_source=game_share); started = first move
(guess/find/flip); completed = solved/cleared/Full Bloom; shared = on share success.
- trackVisit() moved into the global layout so direct /play landings count; the
server-rendered /a/ share page now creates a visitor token + sends a daily visit
beacon (first-time /a/-only visitors were previously dropped).
- Admin "Games funnel" panel: arrivals / engaged / completed / shared, per game.
Sharing:
- Memory Match gains a Share button (it was the only game without one).
- All shares deep-link to the exact game+variant with a full https:// URL +
utm_source=game_share (gameShareUrl helper), instead of a bare /play.
- "shared" is counted only after navigator.share()/clipboard.writeText() succeeds.
/play social metadata:
- /play served homepage canonical/OG (static SPA, ssr=false). postbuild script
patches build/play.html's head to /play canonical/title/description/OG; fails the
build if the homepage tags drift. Caddy try_files now serves {path}.html so /play
is served from the patched file (snapshot in deploy/caddy/).
Tests: backend 352, frontend 27.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The Articles inspector revealed paywall is domain-coarse: nytimes.com is flagged,
so NY Times Learning's free Word-of-the-Day inherits 🔒 — and that flag isn't
cosmetic, it deprioritizes the content in feed sort + lead selection. Add a
per-source override so admins can correct it after inspecting.
- sources.paywall_override: NULL (domain rule) | 'free' | 'paywalled'.
- paywall.py: keep low-level is_paywalled(url) (domain); add is_paywalled_for_source
(url, override) for the EFFECTIVE decision — never patched the domain helper
globally (per Codex), so "domain says X" stays distinguishable from "overridden".
- Threaded everywhere ranking/UI touches paywall, via src.paywall_override on the
shared _ARTICLE_COLUMNS + the source-aware helper: feed sort, /api/since, replace,
lead selection, Article badge, brief composition (briefs.py), digest, source_health
(table 🔒), the Articles inspector, and the review/attention check — so ranking and
UI always agree.
- Endpoint POST /api/admin/sources/{id}/paywall {override}; admin UI: a select in the
inspector header (Use domain rule / Treat as free / Treat as paywalled) + the basis
("ON (domain)" / "OFF (override)"), optimistic so the panel stays open.
Test: domain rule → paywalled in table+inspector+feed badge; 'free' → off in all
three; validation 422 + 404. 242 pytest + 11 vitest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Article titles render as external links ONLY for http(s) URLs (matches
ArticleCard's safeHref rule); anything else is plain text — no unsafe schemes.
- reason_text clamped to 2 lines with a full-text title tooltip, so a long
classifier reason can't make the panel visually noisy.
(Admin gate + source-scoped query confirmed already in place.)