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>
From field testing.
* Keyboard redesigned to fill its area like a real phone game: a left block of
larger, square, alphabetical letter keys (9 per row) + a right column with
Backspace (top) / Enter (bottom) tucked in. Still flat, warm, on-brand.
(Alphabetical per request; QWERTY is a one-line swap if it feels off to type.)
* Fixed the few-mm horizontal scroll on Long Word: the tile width budget now
accounts for the inter-tile gaps + page padding, so the board can never exceed
the viewport width.
* Board sits up toward the top (flex-start) instead of vertically centered, so
the taller 6-letter board no longer crowds the keyboard.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make Daily Word feel like a focused mobile app screen, not a page with a keyboard.
* True viewport: while view==='play' && game==='word', a $effect locks body scroll
and hides the site footer (mobile only), so the keyboard is genuinely pinned, not
riding the document scroll. Effect cleanup ALWAYS removes the class on re-run or
unmount, so leaving /play (back button OR any navigation) can never strand it.
* Keyboard restyled on-brand + modern: flat off-white (--surface) keys with a
hairline border, soft 11px radius, no heavy raised shadow, ~46px tall, ↵ / ⌫
glyphs, centered (max-width 430) instead of a full-bleed beige slab.
* Tiles now size to fit BOTH width and the height left above the keyboard
(--tile = min(cap, width/cols, (100dvh-budget)/rows), gap 4px), so the active row
and keyboard are always visible — Long Word's 6×7 gets slightly smaller tiles.
Real-device Safari/Chrome is the final check (100dvh + safe-area handling).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Intermittent blank screens / long "Gathering the good news…" — two fixes:
Origin cache headers (Caddyfile, deployed separately): content-hashed
/_app/immutable/* → max-age=31536000, immutable; everything else (HTML shell,
service worker, version manifest, webmanifest, word lists, icons) → no-cache,
so a deploy can't leave a stale shell/SW pinned. (Cloudflare's 4h Browser Cache
TTL still overrides this until its dashboard setting is switched to "Respect
Existing Headers" — that's the actual root cause.)
App startup hardening:
* getJSON now has a 10s AbortController timeout — a stuck request can never hang
the loading state forever.
* Home onMount loads moods+categories in parallel then the view, with loading
ALWAYS cleared in finally; lanes/families dropped to non-blocking decoration.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mobile polish for the games.
* New flow: Play Hub → Game Selection → Game. The 5/6 (word) and S/M/L (search)
pickers move OFF the game screen onto a selection screen that shows each
option's today-status. Back-button reads "Game Selection" in a game and
"Play Hub" on the selection screen — buys vertical room for the keyboard.
* Daily Word on mobile now fills the height: the board scrolls in the middle and
the keyboard is pinned at the bottom, always reachable (no scrolling down to
type). Desktop stays inline.
* Keyboard restyled on-brand: warm cream keys (was cool generic grey), the label
font, an accent-tinted Enter, and the same green/gold/grey feedback as the
tiles; full-bleed, tactile press, safe-area aware.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pulled the pointer→cell math out of cellAt() into a pure cellFromPoint(rect, x,
y, n) in $lib/wordsearch.js (only getBoundingClientRect stays in the component),
and covered it with vitest — including the last-column case that was drifting
under the old overflowing layout, plus clamping and a scrolled-origin rect.
11 vitest tests now; real-device testing remains the final validator.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two reported bugs, same root cause: the fixed-cell grid overflowed its wrapper
on Large, so (a) the last column spilled past the border and (b) the pointer→cell
math drifted across the row, recording finds "off by a letter".
* Grid now uses 1fr columns with max-width = n·32px: the board grows with the
grid and can never overflow (shrinks to fit a narrow phone instead).
* cellAt() accounts for the grid padding/border, so selection is exact edge-to-edge.
* restore() now validates each saved find against the CURRENT grid and drops any
whose cells no longer spell the word — clears stale highlights if the day's
puzzle changed.
Codex follow-ups:
* _ws_propose now requires >= large.count + 4 valid words before accepting an LLM
proposal (else falls back to a curated theme), so a thin LLM result can't
underfill Large. Added a thin-LLM fallback test.
* Cleaned Svelte warnings: removed the now-unused .gamecard.soon CSS, added an
ARIA role/label to the grid, declared gridEl with $state. Build is warning-clean.
* Added a stale-load guard in WordSearchGame.load() so rapid size switches can't
let an older request overwrite the newer selection.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Playtesting fixes:
* Constant cell size (~32px) — the board GROWS with the grid instead of shrinking
letters into a fixed box. Fixes Small's oversized spacing; on a narrow phone the
largest grid gently scales to fit (the standard word-search compromise).
* Themes now gather ~28 words (LLM asked for 28; curated fallbacks ~22 each), and
each size samples its OWN subset — so every tier is a distinct puzzle. Large is
now reliably full (14 words on 14×14), fixing the "13 words / 11 listed" mismatch.
* Tiers: small 8×8/6, med 11×11/10, large 14×14/14.
* Word list is now a framed "Find these · n/total" palette panel (pill chips that
take on each found word's colour) instead of loose text under the grid.
* Size chips use qualitative labels (cosy / balanced / a longer sit) so no count
can ever contradict the actual puzzle.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
From playtesting findings:
* Pools nearly doubled (115/104 → 228/201) with calm/neutral everyday words
(claps, dance, drench, beach…), not just strictly-upbeat ones — more variety,
~7-month runway. The post-solve "why" prompt reworded to fit neutral words.
* Word Search now stores one theme + word list per day; the grid is built per
request for three SIZE tiers — Small (8×8, 6 words), Medium (11×11, 9),
Large (14×14, 13). Large packs more words = a longer sit ("too fast" fix).
All sizes share the day's theme; every size still code-placed + solvable.
* Word Search themes can now be neutral everyday scenes ("Around the house",
"At the beach", "In the kitchen", "A walk outdoors", "Making music"…), not
only hopeful — same shape as the articles.
* Each found word gets its own colour from a calm palette, in the grid and its
word-list chip. Per-size local progress + best time.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Per Codex's Phase 2 audit notes. Moved the drag-snap (lineFrom) and find-match
(matchWord) logic into $lib/wordsearch.js and added vitest coverage:
- lineFrom always yields a straight, in-bounds path — a non-straight drag snaps,
never returns bent; single cell and edge-clamping covered.
- matchWord matches forward + reversed selections, is a harmless no-op on an
already-found word (so completion/best-time can't double-record), and returns
null for non-words / too-short selections.
Restore behaviour audited: finish() (which records best-time) only runs when the
final word is found mid-play; on refresh, restore() repopulates found cells +
time and the derived status flips to done WITHOUT calling finish(), so best-time
never re-records. First JS test runner for the frontend (npm test → vitest run).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A calm second daily game, same philosophy as Daily Word — LLM proposes, code
disposes.
* LLM proposes a hopeful theme + ~8 words; code validates (alpha/length/dedup)
and PLACES every word in a date-seeded grid, so the puzzle is always solvable.
Curated fallback themes if the LLM is thin. Only placed words are returned;
the solution cells (placements) are never sent to the client.
* GET /api/puzzle/wordsearch → {theme, words, grid, size}. No answer to hide:
the grid and word list are meant to be seen — the play is finding them, which
the client validates by reading the selected line off the grid.
* WordSearchGame.svelte: pointer-drag selection snapped to the 8 straight
directions (mouse + touch), found-word highlighting, no-fail, no pressure
timer — time is recorded quietly and shown at the end with a personal best.
Spoiler-free share. localStorage progress (restores found cells + timer).
* Hub's Word Search card is now live with today's status; cycle pre-generates
both games with the LLM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>