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.)
New per-row "Articles" button on the Sources table expands a read-only inline
panel of the source's ACTUAL ingested articles — so the automated metrics
(paywall/image/acceptance/duplicate) can be verified against evidence instead of
trusted blind. Distinct from "Check" (which re-samples the LIVE feed for
would-pass quality); this shows what's already in the DB, which is what the table
metrics are computed from.
- Backend: GET /api/admin/sources/{id}/articles?filter=&limit=&offset= (admin,
read-only). queries.source_articles + source_articles_summary — per article:
title, url, date, accepted, reason (the "why"), topic/flavor, paywalled
(domain rule), has_image, duplicate. Summary = counts + source-level paywall
rule.
- Frontend: expandable panel with a summary header ("27 ingested · 18 accepted
· … · paywall rule: ON (domain)"), filter chips (All/Accepted/Rejected/No
image/Duplicates), compact rows with title→link + badges + reason, Load more.
So "100% paywall" or "0% images" becomes clickable evidence: open two articles
to tell a real paywall from a mis-flagged domain, or a true image gap from an
enrichment failure. Test: test_source_articles_inspector. 241 pytest + 11 vitest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Play hub: word cards now surface IN-PROGRESS games too (not just won/lost) so
"continue on another device" shows at a glance — card reads "5:3…" and the
selection option says "Continue · 3/6".
- Word Search generator: replace "prefer any crossing" with a SCORED placement —
score = overlap*4 - local crowding (filled neighbours that aren't crossings) —
then pick among the best ~20%. Keeps the organic interlocking but spreads words
across the board instead of clumping around the first-placed (longest) words.
Every word still placed (tests green). NOTE: changes today's grid layouts, so
an in-progress word search resets once.
237 pytest + 11 vitest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Reported sync gaps: the hub showed "Play" until you opened each game, and a
game synced only if you'd reopened it on that device (so a desktop win that was
never reopened never reached the server). Root cause: the /play hub read card
status from localStorage only and never talked to the server — sync happened
exclusively inside the game components on mount.
Now the hub itself reconciles every game (word 5/6 + wordsearch small/med/large)
with the server on load (signed-in): pushes this device's local state and writes
the merged result back to localStorage, then refreshes the cards. So statuses
appear cross-device WITHOUT opening each game, and local progress uploads even
for games not reopened. Word Search card status derived from the (completion-
gated) ms. 237 pytest + 11 vitest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Don't trust client JSON at the storage layer:
- sanitize_game_state() runs before merge AND on the merged result (heals legacy
rows). Word Search: keep only finds whose cells actually spell a real word in
that day's grid (validated when the puzzle exists, shape-only 4-12 alpha +
cell-length otherwise), dedupe, renumber ci. Word: validate status enum, guess
count/length/alpha, colour-row shape, terminal answer/why.
- Completion is now derived from the real puzzle word count (foundWords ==
expected), not a client-sent `ms` — so stats can't be inflated by junk.
- Date validated as YYYY-MM-DD at the API (400 otherwise) — no junk/future rows.
Tests: sanitizer-rejects-junk + bad-date 400; existing tests updated to use
real-shaped data (the sanitizer is a good forcing function). 237 pytest + 11
vitest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two game polish items:
- Word Search: overlapping cells now multiply-blend the crossing words' colours
(deepening to a darker shade with readable text) instead of the newest colour
stomping the rest — matches the new interlocking grids.
- Cross-device game-state sync (signed-in): per-puzzle progress + stats now
follow you between devices. New game_state table; server-side merge on every
save so two devices converge regardless of push order, tailored per game:
* Word Search → UNION of finds (monotonic; can't un-find), earliest start,
best completion time.
* Word → furthest-progress wins (terminal beats in-progress; more guesses
beats fewer) — picks one device's game whole, never splices guesses.
Stats (streak/distribution/best) derived server-side from the synced states,
so they're consistent instead of per-device counters. Endpoints GET/PUT
/api/games/state + GET /api/games/stats (signed-in; size-capped). Frontend is
local-first: games paint instantly from localStorage, then reconcile in the
background; both game components push debounced on each move and adopt the
merge. Conflict handling unit-tested + an API two-device convergence test.
235→ tests + 11 vitest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two things found while chasing the recurring ~15min slowness:
- dedup.py: cluster_duplicates re-ran an O(n²) cosine pass over ALL ~3.7k
articles and rewrote duplicate_of for every one of them EVERY cycle — even
when nothing new arrived (embedded=0) — ~53s CPU + a large WAL commit that
starved live API reads (/api/brief 2-7s). Now skip the re-cluster entirely
when nothing new was embedded (clusters can't have changed). Verified: cycle
drops from ~53s to ~1s and /api/brief stays at 20ms through a cycle, vs 2-7s
before. (A real new article still triggers a full re-cluster.)
- games.py _build_grid: word placement took the first random valid spot, so
words rarely crossed. Now gather valid placements and PREFER ones that cross
an already-placed word (shared matching letter), falling back to any valid
spot — so the grid interlocks like a real word search. Every word still
placed (tests green). NOTE: changes today's grid layouts, so an in-progress
word search resets once.
Also added a systemd drop-in (Nice=19/CPUWeight=20/IOWeight=10/ionice-idle) to
deprioritize the batch cycle — minor, the dedup skip is the real fix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Word Search: unfound words were tinted (accent-soft background) like found
ones, so the remaining words were hard to spot as the board filled. Unfound
chips are now plain (transparent + a light outline); found words keep their
grid colour. Easy to see what's left.
- Auth: a refresh briefly flashed the signed-out header until /api/auth/me
returned. Now the last-known user is cached and hydrated immediately, so the
signed-in UI paints at once; the session is still revalidated every load (a
stale/expired one corrects within a beat) and the cache is cleared on logout.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex spotted the one remaining unsigned BRIEF_VIEW_KEY write — in replaceArticle.
Not a safety issue (instant-paint requires cached.sig, so an unsigned entry just
won't instant-paint), but it meant the next load after a Replace fell back to
"Gathering…" until a fresh /api/brief re-saved the signed version. Now every
brief save includes sig: briefSig() (computed after dismissed updates), so an
edited brief still instant-paints. All three save paths verified signed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex caught a trust bug: instant-painting a brief saved under OLD boundaries
could briefly flash content the reader's current settings should hide — and
boundaries are trust-critical for this product. Add a filter signature
(prefs param + sorted dismissals) saved alongside the brief; instant-paint and
the merge-fallback only reuse a saved brief when the signature still matches the
current settings. A mismatch falls through to "Gathering…" + a fresh fetch.
Also closes the same latent leak in the merge's `?? it` fallback. Briefs saved
before this change lack a sig → won't instant-paint until re-saved (fails safe).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex's point: edge-caching /api/brief helps anonymous visitors, but a logged-in
reader with prefs/dismissals makes it a personalized (private, origin-bound)
request — so it won't fix Jay's own "Gathering the good news…" delay. The real
fix isn't more CDN tuning, it's not blocking the first paint on the network.
The brief is already saved locally (BRIEF_VIEW_KEY). Now on the Today view we
render that saved brief immediately and refresh /api/brief in the background;
"Gathering…" only shows on a true first visit with nothing cached. A failed
background refresh stays invisible (loadToday returns if a brief is already
painted; onMount won't blank painted content) so a slow/offline origin never
wipes a good view.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two concrete latency wins found by measuring (server compute is 2-17ms; the time
is in the path, not the box):
- Admin panel fired its 6 API calls SEQUENTIALLY (await chain) — so it paid the
uncached origin round-trip six times back-to-back. Now one Promise.all batch.
This is the admin lag.
- /api/brief (the home "Gathering the good news…" content) wasn't edge-cached, so
a distant anonymous visitor triggered a Cloudflare→residential-origin pull.
Same global/shareable boundary as /api/feed: public s-maxage=45 when no
prefs/exclude, else private,no-store. (Needs /api/brief added to the CF cache
rule path list to take effect at the edge.)
Tests: test_brief_cache_boundary. 228 pytest + 11 vitest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Per Codex + Jay: the SW was added for nice-to-have PWA/offline caching, but it
sat in the boot path and put first loads at risk (post-deploy tiny-chunk stalls
of 4-10s — fast HTML, then delayed chunks). For a young site where a handful of
visitors a day IS the audience, a broken first impression is a huge share of
traffic. The site's value doesn't need offline caching; browser HTTP cache +
the Cloudflare edge are enough.
Removed cleanly (not just deleted — that strands the old worker on existing
clients):
- Delete src/service-worker.js → SvelteKit stops auto-registering.
- static/service-worker.js is now a one-shot KILL SWITCH: takes over, wipes all
caches, unregisters itself, no fetch handler (requests go straight to network/
browser cache). Served no-cache so existing clients pick it up.
- app.html boot script unregisters any worker + clears caches on load, as a
backstop so no returning visitor stays stuck on the old boot path.
The boot seatbelt (timeout card, preloadError reload-once, telemetry) stays —
that, not the SW, was the real blank-screen protection. Build clean, 11 vitest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Telemetry isolated the last boot-slow source (id9): warm edge, fast shell
(60ms), but chunks took 2.6-4.7s on the LAN-fast dev box, 26min post-deploy —
i.e. NOT network/origin. Cause: on the first load of a new build the new SW
ran skipWaiting()+clients.claim(), activating mid-boot — deleting the old
cache and seizing the loading page, yanking the cache from under the ~16
in-flight chunk requests (the sz0 + clustered-start + staggered-finish
signature).
Fix (Codex-approved): remove skipWaiting() and clients.claim() so a new worker
installs quietly and takes control on the NEXT navigation, never mid-boot. The
post-deploy first load then completes under the stable old worker (cache
intact) against the warmed edge. Cache cleanup stays in activate (now runs only
at the deferred, safe activation); old immutable chunks live 14 days at the
origin regardless, so a slightly-behind worker still loads safely. Trade-off —
SW/shell update applies one navigation later — is fine: the shell is
edge-cached and the SW's only job is offline/slow-network fallback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"Gathering the good news…" waits on the home's startup API calls, which were all
DYNAMIC → a round-trip to the residential origin every load (the occasional 2-3s
linger). These responses depend only on the URL, never the session, so they're
safe to share at the edge:
- /api/moods, /api/categories (static config) → public, s-maxage=900
- /api/lanes, /api/families (global, data-derived counts) → public, s-maxage=120
- /api/feed → public, s-maxage=45 ONLY when shareable (no following / prefs /
exclude); the following feed (reads the session) and personal filters stay
private, no-store.
Hard personalization boundary, explicit per-endpoint (no blanket /api/* rule).
Pairs with a Cloudflare cache rule (added separately) making these paths
eligible. Tests assert the global endpoints are public+s-maxage and the feed
boundary (default/topic public; following/prefs/exclude private). 227 pytest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Post-deploy slow-load fix (telemetry-confirmed): boot-slow beacons showed the
shell arriving fast (33-79ms) but freshly-deployed chunks taking 3-5s, every
event within ~6-8min of a deploy, the same chunks fast HITs later. Cause: the
new shell went live pointing at chunk hashes not yet warm at the edge, so the
first visitor fetched them cold from the residential origin (modulepreload
fires them together → one unlucky "chunk warmer").
Reorder sync-static.sh: warm the immutable chunks at the edge BEFORE swapping in
the new shell, so a published shell never references cold chunks. Shell + routes
still warmed after publish. Pure deploy-script change — no runtime/SW changes.
Warms the origin's nearest POP (covers local users + our own post-deploy
testing); a distant POP still cold-fills once (inherent to a residential origin).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two small server-side tweaks so the endpoint matches the UI policy:
- Rename is refused (409) for promoted/rejected candidates — they're settled
history; the UI already hides Rename for them, now the server enforces it too.
- Name is capped at 160 chars before save, so an accidental pasted paragraph
can't wreck the queue layout.
Tests extended: 300-char name truncates to 160; renaming a promoted candidate
→ 409. 225 pytest + 11 vitest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A staged candidate could only be renamed by rejecting and re-adding it, which
churns the queue and discards the preview just to fix a typo. Add an inline
Rename on each candidate: a "Rename" pill swaps the name for an input
(Enter saves · Esc cancels), POST /api/admin/candidates/{id}/rename →
sources.rename_candidate(). Empty clears the name (promote then derives one
from the feed host). Preview is preserved; the fixed name carries into promotion.
Tests: test_candidate_rename (rename in place keeps preview, promotes with the
new name, gated + 404). 225 pytest + 11 vitest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>