Commit Graph

194 Commits

Author SHA1 Message Date
thejayman77 ba1a29d12a Admin Sources: constrained scroll panel with sticky header
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>
2026-06-11 20:57:37 -04:00
thejayman77 d558c99b8e SW: mutable no-cache files bypass the SW cache (Codex audit) + beacon build id
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>
2026-06-11 20:26:27 -04:00
thejayman77 5393b63cee Telemetry: boot-slow beacon names the 3 slowest resources
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>
2026-06-11 20:01:24 -04:00
thejayman77 628cc5722c Reliability: slow ≠ failed — SW nav timeout, slow-boot telemetry, de-bot stats
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>
2026-06-11 19:23:33 -04:00
thejayman77 90da4be083 Pool admin: empty-pool safety net + honest removal copy (Codex audit)
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>
2026-06-11 19:04:49 -04:00
thejayman77 2461584052 Pool admin: delete any word (tombstones + restore) + bulk import
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>
2026-06-11 17:17:16 -04:00
thejayman77 fb781f48b8 Daily Word pool: enrich with obvious positive words the LLM pass missed
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>
2026-06-11 13:50:16 -04:00
thejayman77 f71e760847 Admin: Word Search theme authoring + tidy word-pool chips
* 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>
2026-06-11 13:36:07 -04:00
thejayman77 61f575ba6d Observability + warming guardrails (Codex)
* 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>
2026-06-11 12:31:32 -04:00
thejayman77 370d62270b Reliability/speed: warm CF cache on deploy + lighten SW (no precache storm)
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>
2026-06-11 12:20:29 -04:00
thejayman77 9e387a0a09 Boot-failure seatbelt: no future crash becomes a silent white screen
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>
2026-06-11 12:10:46 -04:00
thejayman77 254db67055 Deploy: stage static sync (assets→shell→SW) to avoid deploy-race blank screens
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>
2026-06-11 12:03:55 -04:00
thejayman77 c7e00e7fdc Fix intermittent blank screens: cache the SPA shell in the service worker
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>
2026-06-11 12:02:12 -04:00
thejayman77 903b27fc8d Admin: Daily Word pool curation (lookup + add/remove)
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>
2026-06-11 11:42:52 -04:00
thejayman77 7e4d3e2cd9 Word Search: bolder in-game theme title + cell letters scale with cell size
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>
2026-06-11 09:51:14 -04:00
thejayman77 6ef58e3915 Word Search: pin per-size counts in test + fix stale .playing-word comment
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>
2026-06-11 09:26:19 -04:00
thejayman77 28b0ef6766 Word Search select: put Today's theme in a standout accent card
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>
2026-06-11 09:23:11 -04:00
thejayman77 52a8bc5326 Word Search mobile: focused viewport, theme placement, unique-per-size words
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>
2026-06-11 09:15:06 -04:00
thejayman77 1dda91fd96 Play: app-safe in-app Back + canonicalize shareable URLs (Codex audit)
* 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>
2026-06-11 08:57:12 -04:00
thejayman77 d6015dd44e Play: make Back step through the game screens (URL-driven views)
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>
2026-06-11 08:50:48 -04:00
thejayman77 dc2e382dea Daily Word: bring back the tall, prominent Backspace/Enter keys
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>
2026-06-11 08:44:48 -04:00
thejayman77 924f885400 Daily Word controls: matched SVG icons + row-aligned Backspace/Enter
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>
2026-06-11 08:42:08 -04:00
thejayman77 dbf8ab6085 Daily Word: enlarge the ↵ Enter glyph to match the ⌫ backspace
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>
2026-06-11 08:37:42 -04:00
thejayman77 15fe2d1ae5 Daily Word keyboard: QWERTY, slim ↵ controls, floated up & centered
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>
2026-06-11 08:34:14 -04:00
thejayman77 ac418591a8 Daily Word: keyboard fills full width + kill the last overflow/scrollbar
* 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>
2026-06-11 08:20:50 -04:00
thejayman77 35c5dc0c74 Daily Word: give the keyboard + board some JUICE
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>
2026-06-11 07:55:44 -04:00
thejayman77 abfbcabad0 Daily Word keyboard: maxed-out game layout + no-overflow board
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>
2026-06-11 07:43:39 -04:00
thejayman77 067e77ed5a Daily Word mobile: true viewport + flat warm keyboard + height-aware tiles
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>
2026-06-11 07:27:39 -04:00
thejayman77 bd2a477570 Stability: cache-control at origin + non-hanging startup
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>
2026-06-10 22:13:21 -04:00
thejayman77 a52226ce61 Play: Game-Selection step + mobile sticky on-brand keyboard
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>
2026-06-10 21:58:44 -04:00
thejayman77 98441fae15 Extract + unit-test the padding-aware cell geometry (Codex nice-to-have)
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>
2026-06-10 21:43:28 -04:00
thejayman77 b909b7e64b Word Search bug-fixes + Codex polish
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>
2026-06-10 21:09:33 -04:00
thejayman77 9f7eb11155 Word Search polish: constant cell size, 28-word themes, per-size variety, palette
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>
2026-06-10 20:57:44 -04:00
thejayman77 f43f645d69 Games batch: neutral words/themes, Word Search sizes + per-word colours
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>
2026-06-10 20:32:53 -04:00
thejayman77 33d5d55c33 Word Search: extract pure selection/match logic + pin with vitest
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>
2026-06-10 20:23:13 -04:00
thejayman77 90cd0291a3 Play hub Phase 2: Word Search (LLM theme/words, code places the grid)
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>
2026-06-10 20:15:19 -04:00
thejayman77 1bc9925e40 Daily Word: server-adjudicate guesses (answer no longer in the response)
Per Codex's v2 hardening. The GET /api/puzzle/word response no longer carries
the answer at all — guesses POST to /api/puzzle/word/guess and the server
returns the colour pattern, computed against the day's answer. The answer (and
the "why") are revealed only once solved or the guesses are spent. This removes
the "open DevTools, read the answer" issue without pretending to be a fortress
(a deliberate crafted request can still peek; there's no leaderboard or prize,
so that's fine). Client keeps local progress/stats; dict validation stays
client-side. Trade-off accepted: each guess needs the API (the site already
depends on it for today's content).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:48:47 -04:00
thejayman77 bccf03fb77 Fix Daily Word stuck on "Loading…" (infinite reload loop)
The variant-watch $effect read `loading`, but load() flips `loading` false at
the end — which re-fired the effect, which called load() again, forever. The
board never rendered. Effect now tracks ONLY `variant`, so it loads once on
mount and once per variant toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:44:31 -04:00
thejayman77 a7fb8e5739 Daily Word: grow the hopeful answer pool + lock it with a validation test
Per Codex. Pool grown 51/44 → 115/104 hopeful answers (5/6 letter) via the
agreed workflow: LLM proposes themed candidates → code filters to the bundled
guess dictionary (length/alpha/dedup) → human spot-check prunes tone-drift
("growl", "plain", "color"…). ~3.5-month runway before repeats per variant.

test_wordpool.py locks the invariant in CI: every answer must be lowercase
alpha, correct length, unique, and present in words-5/6.json — so no future
addition can become an unguessable puzzle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 17:34:44 -04:00
thejayman77 215a5c4d64 Play hub + Daily Word game (Phase 1 of the games feature)
A calm /play space — "after the brief, a small thing to enjoy." Framework-ready
for more games (Word Search next; zen/coloring later).

* Daily Word (5 letters / 6 guesses) + Long Word (6 / 7) — same Wordle mechanic,
  Upbeat Bytes flavor (no "Wordle" in the UI). Hopeful answers; after solving, a
  one-line "why this word matters."
* LLM proposes, code disposes: answers are picked deterministically by date-seed
  from a hand-curated hopeful pool that's pre-validated ⊆ the guess dictionary
  (always typeable), avoiding recent repeats; the LLM only adds the optional
  "why" (with fallback). daily_puzzles(date, game, variant, payload) stores them
  so everyone gets the same daily; the cycle pre-generates with the "why".
* Bundled guess dictionaries (words-5/6.json, ~12.6k/22.4k) for client-side guess
  validation — never the LLM. Answer lightly obfuscated (base64) in the payload.
* Private, gentle stats (played/solved/streak, guess distribution); spoiler-free
  emoji-grid share. No leaderboard, no timer, no streak-loss drama.
* Play in the bottom nav (replacing Browse, still on the lane rail) + the header.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 16:06:20 -04:00
thejayman77 d0fb153e46 "Since you last visited" cue + PWA install (add to home screen)
Two calm returning-reader features.

Since-last-visit (Highlights companion, not a nav lane — per Codex):
* queries.feed gains a `since` filter; GET /api/since?ts= returns the count +
  a few accepted/non-dup/visible articles discovered since the reader's last
  visit (boundary-respecting; invalid/future ts → 0, no error).
* Home stores last_seen in localStorage (reads prev, then stamps now); on
  Highlights, a gentle "Since you were last here, N new calm reads came in"
  note with a "See what's new" reveal of a compact inline section. Dismissible.
  No badges, no unread counts, no "missed" language.

PWA:
* Real PNG icons (192/512 + full-bleed maskable) rasterized from favicon.svg;
  manifest fixed (azure theme to match the brand, PNG icons); apple-touch-icon.
* Minimal service worker: precache the app shell, always-fresh API + /a/ pages.
* Gentle, dismissible install banner (beforeinstallprompt → Install; iOS → the
  Share → Add to Home Screen hint). Never nags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:38:12 -04:00
thejayman77 008364e922 Why-it-belongs: top-up requires all three fields (idempotency fix)
Per Codex: generate_summary treated why_belongs alone as a complete explanation,
but get_explanation requires all three — so a partial older row (e.g. only
why_belongs) would never top up and the page would fall back forever. Now the
fully-cached check requires summary + what_happened + why_matters + why_belongs.
Test covers the partial-row top-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:10:27 -04:00
thejayman77 337dc3f901 Article pages: structured "Why it belongs" editorial read
Per Codex — make /a/<id> feel like Upbeat Bytes has editorial judgment, not just
a summary wrapper. Trust-building, short, not an essay.

* article_summaries gains what_happened / why_matters / why_belongs (+ migration).
* summarize.explain_article: a separate, fallback-able LLM pass producing three
  short notes (parsed from a labelled WHAT/MATTERS/BELONGS format). generate_summary
  now stores them alongside the summary, and tops up older summaries on next view.
  get_explanation returns them only when all three are present.
* API: share_page + /api/summary expose the explanation.
* share.py: renders the three-part section (accent rule) when complete; otherwise
  the single "Why it's here" reason line is the calm fallback. The page polls and
  swaps in both the summary and the section as they cache.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 20:05:26 -04:00
thejayman77 9befbffd94 Deploy: smoke-check the digest masthead asset (logo-email.png)
Per Codex — guard against the static logo silently going missing (which would
break the newsletter masthead). Non-fatal curl check after Caddy reload.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:39:15 -04:00
thejayman77 1efe0a76eb Digest: darker section rule before 'From what you follow'
Per user — a heavier, darker divider (2px #9aa6b2) marks the brief→personal
section change, distinct from the light #e8e3d8 item separators above.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:37:33 -04:00
thejayman77 2feccedcc7 Digest: more space below the 'From what you follow' heading
Per user — separate the heading a touch more from the first followed entry
(bottom margin 18px → 28px).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:34:52 -04:00
thejayman77 71f140e8d0 Digest: restore azure on the enlarged 'From what you follow' heading
Keep the 20px bold size, bring back the brand blue (#0083ad) for differentiation.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:32:41 -04:00
thejayman77 c042b947a2 Digest: enlarge 'From what you follow' heading to a proper section size
It was 11px uppercase — smaller than the 14px links, backwards for a heading.
Now 20px bold ink, clearly a section header above its items.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:23:55 -04:00
thejayman77 a7d72f2f84 Digest v2: restrained "From what you follow" section
Per Codex — give follows an immediate payoff without making the email feel
algorithmic. The editorial brief stays the star: subject + brief count unchanged.

* followed_digest_items(conn, user_id, exclude_ids, limit=3): recent items from
  the user's followed sources/tags, same accepted/non-dup/content-visible gate,
  excludes anything in the brief, capped to one per source so a single follow
  can't dominate. Empty → section omitted (no empty state in email).
* build_digest gains an optional `followed` list → a small "From what you follow"
  section AFTER the brief, only when there are items. Item rendering factored into
  shared _item_html / _item_text_lines helpers.
* send_due_digests computes the followed items per user (excluding the brief).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:17:17 -04:00
thejayman77 35725e15dc Following lane: don't bounce out of it when editing lanes
Per Codex: saveLanes() bounced to Highlights if the current view wasn't in the
customizable keys, but Following is a special pinned lane (like today/latest)
that's never in keys. Add it to the special-lanes exclusion so editing the lane
picker while viewing Following no longer navigates away.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 19:10:58 -04:00