"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>
Three follow-ups from Codex's audit of the deep-preview/search/dedup work:
- Promote-time duplicate guard: promote_candidate() now re-checks
find_existing_feed() and raises DuplicateFeedError → 409, so an
old/CLI/direct-DB candidate or a race can't bypass the add-time check and
silently overwrite a live source's settings via upsert. (sources scanned
first, so a real source collision wins over the candidate matching itself.)
- postJSON/putJSON/delJSON gain opt-in {timeout} (AbortController, default
none so other calls are unchanged); deep preview uses 120s and surfaces a
calm "timed out" message instead of pinning the button on "Deep-checking…"
if the LAN model stalls.
- feed_key() now lowercases the host only, not the whole URL — paths/queries
can be case-significant; scheme/www/trailing-slash/host-case still collapse.
Tests: test_candidate_deep_preview_and_dedup extended — promote succeeds once,
then a re-promote of the same candidate is refused 409. 224 pytest + 11 vitest.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three admin Sources upgrades:
- Deep preview: a per-candidate "🔬 Deep preview" button runs the REAL
classifier on an 8-item sample (the same model that judges live articles),
versus the fast keyword heuristic the add/Re-preview path uses. Preview now
carries `classified`, surfaced as a "model-checked" vs "quick estimate"
badge — so the acceptance % is no longer ambiguously heuristic. conn is
released during the ~30-60s model pass; postJSON has no client timeout.
- Search: free-text box over the sources table (name / category / feed URL /
homepage), folded into the existing status filter, with a live match count
and empty state. Makes "is this already added?" a glance.
- Duplicate-add guard: sources.find_existing_feed() + feed_key() normalize
scheme/www/trailing-slash/case, so re-adding a feed that's already a live
source or a queued candidate is refused with a 409 naming where it lives
(DB already enforced exact-URL uniqueness; this catches the near-miss
variants and overwrite-on-promote footgun).
Tests: test_candidate_deep_preview_and_dedup (deep flag wires the model +
uses the small sample; exact/www/slash/case variants all 409). 224 pytest +
11 vitest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sources table scrolled horizontally but had no height cap, so its horizontal
scrollbar sat at the bottom of a 46-row table — you had to page the whole window
down to reach it. Make .tablewrap a self-contained scroll panel (max-height 65vh,
overflow auto, bordered card) so both scrollbars stay on-screen; pin the header
row sticky (box-shadow divider survives position:sticky under border-collapse).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex's finding: cache-as-you-go would pin files Caddy deliberately serves
no-cache (version.json, manifest, word lists, icons) in the SW cache until the
next SW version — silently defeating the revalidate policy for controlled
clients. version.json is the critical one (it's how the app detects a fresh
deploy); stale word lists could drift from the server's validated answer pool.
New isMutablePath() exclusion: the SW steps aside and the browser HTTP cache
revalidates these per their headers.
Telemetry polish (also Codex): the boot beacon now fills the app_version
column with the entry chunk's hashed filename scraped from the shell's own
modulepreload link (no extra fetch) — deploy-correlated load errors become
obvious. Admin list returns + shows it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The first boot-slow capture (5763ms total, html 68ms) proved the white screen
happens AFTER the shell arrives — but not which fetch eats the time. Append
the 3 slowest resource entries (path, start→end, transferSize; sz0 ≈ served
from SW/cache) so the next slow boot names its culprit. Reason cap 300→500
client+server to fit the detail.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Root cause of the intermittent white screen: the shell HTML is no-cache
(cf-cache-status: DYNAMIC), so every page-open does a synchronous round-trip
to the residential origin before any pixel renders — and the SW's network-first
navigation only fell back to the cached shell on REJECTION, never on slowness.
A stalled fetch meant staring at white with a perfectly good shell in cache.
The boot seatbelt couldn't see it either: it lives inside the HTML that hadn't
arrived yet, so slow boots left no telemetry.
- service-worker: race navigation fetch vs 2.5s grace timer. Network wins →
fresh HTML as before; timer/5xx/failure → cached shell instantly, network
response still refreshes the cache in the background. Safe due to the 14-day
immutable-chunk grace window. Caps the white screen at ~2.5s for repeat
visitors on any network.
- app.html: beacon `boot-slow: Nms (html Nms) on 4g` when mount takes >4s —
the "white screen, then it loaded" glitches finally leave a trace, with
HTML-arrival timing to separate slow-origin from slow-JS.
- admin: bot UAs (HeadlessChrome/bot/spider/crawl/…) excluded from the
headline "Load errors today" count — throttled crawlers trip the 10s boot
check routinely (the one recorded error was HeadlessChrome on X11, not a
phone). Bots stay visible in the list, tagged + dimmed.
Tests: telemetry test extended for bot flag + filtered counts. 223 pytest +
11 vitest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two hardening fixes from Codex's audit:
- _pick_answer falls back to the curated baseline if the live pool is empty,
so an admin tombstoning every answer in a variant can't divide-by-zero the
daily picker. Test added (test_picker_survives_empty_live_pool). Chosen over
a minimum-count block: robust without refusing legitimate removals.
- Removal copy is now honest — "Removed from future puzzles (today's answer is
already set)" — since a tombstone doesn't rewrite today's generated
daily_puzzles row. Panel intro updated to match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Daily Word pool curation, full add/delete/import — no redeploys to fix tone:
- Remove ANY pool word, curated or admin-added, via a word_pool_removed
tombstone table. Runtime pool = (static ∪ added) − removed, so even a
baked-in word can be pulled on negative feedback. Reversible: a "Removed"
list with one-tap Restore lifts the tombstone. Lookup now surfaces a Remove
button when in-pool, Restore when removed.
- Import a vetted list (paste or .txt/.csv upload, read client-side): validates
each word (alpha · 5–6 · in guess dictionary), ignores duplicates, and reports
rejects with reasons. Re-adding/importing a removed word lifts its tombstone.
- Word Search theme delete already existed (Edit/Remove per theme) — verified.
Pool stays the clean 251/224; today's noisy LLM enrichment is discarded.
Tests: +tests/test_pool_admin.py, extended test_word_pool_admin. 222 pytest +
11 vitest green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The earlier LLM-generated pool had poor recall on plainly-positive words (champ,
shines, elated, kudos, jovial, frolic, upbeat, winner, medal…). Hand-curated a
batch of obvious uplifting words + synonyms, dictionary-validated and deduped:
228/201 → 251/224. The admin lookup/add tool remains for ongoing edge cases.
(The LLM is unreliable for exhaustive recall here, so human curation leads.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* New "Word Search themes" panel in the Games tab: enter a theme name + words,
with live validation (4–8 letters, alpha, deduped) and a count vs the 28 needed
to fill all three sizes. An "✨ Suggest a word" button asks the LLM for one
fresh word that fits the theme. Save/edit/remove; authored themes join the daily
fallback rotation alongside the curated ones (wordsearch_themes table). The
system still handles word distribution across sizes + placement.
* Daily Word pool's added-word chips now scroll within a bounded area so the
console stays tidy as the list grows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* client_error details, not just a count: new client_errors table + POST
/api/client-error (reason/path/user-agent/time) + GET /api/admin/client-errors.
The boot-seatbelt beacon now sends the reason + path (once per page); the admin
Overview lists the recent errors so we can tell chunk vs SW vs API vs JS — the
truth meter for the next day as the new SW propagates.
* Deploy warming now also hits the shell, routes (/play /account /admin), SW,
version.json, word lists, and icons/logo/font — not just immutable chunks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The post-deploy blank/slow load: new hashed chunks weren't in Cloudflare yet, so
the first visitor pulled them cold from the residential origin — AND the service
worker simultaneously precached ~30 of those cold assets (a request storm),
pushing past the 7s boot timeout.
* sync-static.sh now warms the CF edge cache (fetches every immutable asset
through the public domain) so the first visitor gets HITs, not cold-origin.
* Service worker no longer bulk-precaches on install (the browser already caches
immutable assets for a year); it caches the shell + assets lazily as used. No
more storm.
* Boot-recovery timeout 7s → 10s so a merely-slow load doesn't flash the card.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex. A branded recovery card in app.html shows if the app hasn't mounted
in 7s, or on a pre-mount JS error/unhandledrejection — with a "Refresh Upbeat
Bytes" button. A chunk/preload failure (vite:preloadError) reloads once
(sessionStorage-guarded). +layout calls window.__ubBooted() on mount to clear
the card + timer. A pre-mount failure also fires a tiny anonymous client_error
beacon; the admin Overview now shows "Load errors today" (red if >0) so we can
see if blank-risk is happening in the wild.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex. Shared deploy/sync-static.sh used by both publish scripts: sync new
hashed chunks first WITHOUT pruning old ones (grace window so in-flight/old
clients keep chunks they still need), then other assets, then index.html, then
service-worker.js last — so a new shell never appears before its chunks exist.
Old immutable chunks pruned after 14 days to bound disk growth.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Root cause (SPA mode, fallback: index.html): the SW precached build+files but
NEVER the shell HTML, so its navigation fallback `caches.match('/')` resolved to
nothing — any failed navigation fetch (transient WAN/CF blip) returned an empty
response → blank white screen.
Fix: precache `/` on install, and on every successful navigation keep the
freshest *real 200 text/html* response as the cached shell; on a failed fetch,
serve that cached shell instead of blank. Also expanded the server-owned path
exclusions (the SW now passes through /docs, /openapi.json, /healthz, /today,
/sitemap.xml in addition to /api/ and /a/) so it never caches non-SPA responses
as the shell.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
First games admin tool. A "Games" tab in the operator console for the Daily Word
answer pool.
* Lookup: is a word real (in the guess dictionary), the right length (5/6), and
already in the pool — instant as you type.
* Add: appends to the pool, enforcing the invariant (alpha · 5/6 letters · in the
guess dict) so the daily answer is always guessable. Remove: drops admin-added
words (curated static ones stay).
* Additions persist in a new word_pool table (survives redeploys, unlike the
baked-in JSON); the daily picker reads static pool ∪ DB additions. Guess dicts
shipped with the package (goodnews/data/words-5/6.json) for server-side
validation. Admin-gated endpoints + tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per field feedback + Codex.
* In-game theme title bigger and weighted (flat → title): 1.6rem/600 desktop,
1.5rem mobile.
* Cell letters now scale to ~42% of the cell (container-query units, grid as the
inline-size query container) so the letter-to-spacing ratio is uniform across
sizes. Large is preserved (its current ratio ≈ 0.42); Small/Med letters grow to
match, so Small is the easiest to read. @supports-guarded with a clamp() fallback
for browsers without container-query units.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex audit. test_wordsearch_endpoint now asserts the exact promise — small 6,
med 9, large 13, pairwise-disjoint. app.css comment updated to .playing-game (the
class was renamed when the focused viewport was generalized to both games).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The theme was floating between the title and the size options; give it its own
soft accent-tinted card so it reads as the day's headline, distinct from the
size choices.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per field feedback.
* Each day is now THREE distinct puzzles: the three sizes draw DISJOINT word
slices from a date-shuffled pool (small/med/large = 6/9/13, sum 28 unique).
Curated fallback themes expanded to 30 words each; LLM proposals accepted only
if they supply >= 28 unique words, else fall back. No more repeats across sizes.
* Word Search is now a focused game screen on mobile (same as Daily Word): body
scroll locked + footer hidden (generalized .playing-game), and the grid sizes
to the largest square that fits between the theme and the palette (container
query) — the whole puzzle is on screen, no page scroll.
* Theme placement: full "Today's theme · <name>" on the size-selection screen;
just the theme name on the puzzle itself, saving vertical space for Large.
* cosy → cozy. 🇺🇸
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* In-app Back arrow is now deterministic on deep links: if there's in-app history
it pops (history.back); otherwise it navigates to the parent screen (game →
selection → hub) instead of leaving the site. Device Back stays native.
* Canonicalize ?game/?v: unknown game → hub; an invalid v for the game (e.g.
word&v=large or wordsearch&v=5) → the game's default, via replaceState so the
URL is clean and local-storage keys/status match. Derived variant/size are also
clamped so a bad URL can never crash the game with an invalid variant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Hub → Game Selection → Game screens were internal $state with no history
entries, so the device/browser Back button skipped straight out of /play. Now
the screen is derived from the URL (?game=…&v=…) and forward moves use goto, so
each screen is a real history entry: Back goes Game → Selection → Hub → site,
matching the rest of the app. The in-app Back button uses history.back() so it
mirrors the device button. Statuses refresh on every navigation (incl. Back).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Keep the matched SVG icons, but restore the full-height split control column
(each key ~1.5 rows) — they read as prominent and set apart, which is the look
that was landing.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The ⌫ and ↵ glyphs never matched (different font metrics → different size +
baseline). Replaced both with matching 25px line-icon SVGs, perfectly centered.
Also made each control key one row tall — Backspace aligned to the top row,
Enter pushed to the bottom row — so they line up with the letters and look
proportional instead of two oversized half-height blocks.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The return arrow renders smaller than the backspace glyph at the same font size;
bump it so the two controls look balanced.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Final mobile keyboard pass per field feedback:
* Back to QWERTY (natural muscle memory) — uniform key width, with the shorter
rows auto-centered under the top row.
* Slimmer control column + Enter is now the ↵ glyph (not "ENTER"), so the letter
keys get a little extra width.
* Keyboard floats up and sits centered in the space between the bottom of the
board and the bottom of the screen (kbzone flex-centers it), instead of being
jammed against the bottom edge.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* Keyboard was shrinking to content + centering (dead horizontal space, squished
letters): auto side-margins on a flex item in the mobile flex-column don't
stretch. Now full width (align-self: stretch, margin sides 0), so the 9 letter
keys spread across the screen and the controls look proportional.
* Board tile width budget bumped (56→64) to cover page padding + the 5px gaps, so
Long Word (6 wide) can't trigger the few-px horizontal scroll.
* Hid the play-area scrollbar (scrollbar-width: none) so no stray "divider" shows.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The keyboard read as a timid little afterthought. Now it's a confident game board:
* Bigger, bolder keys (taller, larger font) with a tactile press — a soft bottom
edge + shadow that compresses on tap (translateY). Enter is a solid accent key
with its own depth; feedback keys keep matching depth.
* Board tiles a touch larger to fill the screen better.
* Real game feedback animations: tiles POP as you type, the row REVEALS with a
staggered bounce when you submit, and shakes on an invalid word. Respects
prefers-reduced-motion.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>