276 Commits

Author SHA1 Message Date
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 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 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 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 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
thejayman77 d8e246b4ff Follow source/topic — account-backed personalization (v1)
Per Codex — turn accounts into a real reason to return, without an algorithmic
feed. Durable interests (sources + tags), not moods.

* DB: user_follows (user_id, kind source|tag, value, unique).
* queries.feed gains follow_sources/follow_tags → the Following feed is
  "articles from a followed source OR carrying a followed tag", still respecting
  calm filters/boundaries.
* API: GET/POST/DELETE /api/follows (sign-in required; source ids validated);
  /api/feed?following=true resolves the user's follows (anon → empty, not error).
* Frontend: follows store (followKeys + toggleFollow, mirrors savedIds); a
  Follow button on source + tag/topic views; a "Following" lane in the nav with
  a tailored empty state; a Following management section in Account (unfollow).

Digest "From what you follow" deferred to v2 (brief stays first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:34:46 -04:00
thejayman77 69ed202c4e Digest masthead: use the real logo as a hosted PNG
Per Codex — cleanest, most brand-consistent option. Rasterized logo.svg to a
small transparent logo-email.png (360px wide, shown at 180px for retina), served
at /logo-email.png. alt="Upbeat Bytes" keeps the brand if a client blocks
images. SVG isn't email-safe; a hosted PNG is the standard approach.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 17:24:43 -04:00
thejayman77 0199e5108c Digest CTA: clear pending opt-in if sign-in is cancelled
Per Codex polish: if someone taps 'Get tomorrow's brief' then dismisses sign-in
without authing, clear pendingDigestOptIn (guarded by !auth.user so a successful
sign-in still auto-enables via the $effect).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:46:25 -04:00
thejayman77 1956d7fd23 Digest polish: honest on-site wording, one-tap opt-in after sign-in, List-Unsubscribe
* On-site end-cap now says "You're caught up for now." — honest, since Highlights
  refreshes through the day (the email keeps the daily "see you tomorrow").
* Anonymous "Get tomorrow's brief by email" now honors the one-tap promise:
  sets a pending flag, opens sign-in, and auto-enables once auth resolves.
* Email compliance (RFC 2369/8058): send_email takes optional headers; the digest
  sets List-Unsubscribe + List-Unsubscribe-Post=One-Click, and a POST
  /api/digest/unsubscribe handles native one-click (GET still serves the page).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:35:05 -04:00
thejayman77 cf5cbb33c0 Daily digest (opt-in) + finite "you're caught up" ending
Reader-retention as ritual, not capture (Codex's framing). Opt-in calm morning
email of today's brief; the on-site twin is the finite end-of-feed nudge.

* Schema: users.digest_enabled + digest_unsub_token; digest_sends (dedupe +
  visibility). auth.get_user now returns the digest fields.
* goodnews/digest.py: build (dated calm subject, items w/ summary + "why it's
  here" + UB/source links + one-click unsubscribe, "you're caught up" sign-off)
  and send_due_digests (morning-window gated, >=4-item floor or skip quietly,
  deduped, reuses SMTP). No streaks/urgency/"you missed".
* API: /auth/me exposes digest_enabled; POST /api/account/digest toggle;
  GET /api/digest/unsubscribe (token, no login, calm confirmation page).
* CLI: cycle gains a morning-gated digest step (--no-digest) + a send-digests
  command (--force).
* Frontend: digest toggle on the Account profile; the Highlights end-cap now
  says "you're caught up — see you tomorrow" with a one-tap "Get tomorrow's
  brief by email" (signed-in → enable; anon → sign in).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:17:46 -04:00
thejayman77 5d36e6b639 Cards: tighter media on phones (single-column)
On phones the feed is a single column, so the banner no longer needs to keep a
uniform grid height. Below 540px: trim photo banners (16/9 → 2/1) and shrink
image-less placeholders to a slim 54px topic-label band. Desktop/2-column
unchanged (uniform heights still matter there).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:55:14 -04:00
thejayman77 ee00d8e89b Sources: "Check source" read-only spot-check action
Per Codex — a per-row Check button that previews a LIVE source on demand,
intentionally read-only and ephemeral.

* POST /api/admin/sources/{id}/preview — admin-gated, safe-fetch + heuristic
  preview (reuses the candidate preview path), returns the result. Mutates
  NOTHING: no DB write, no poll attempt, no health/state change. 404 on missing.
* UI: per-row Check button with a Checking… state; results in an inline row
  under the source (sampled, would-pass %, recent-7d, example accept/skip
  headlines) with dismiss; inline error on failure. "Checked just now" is
  local UI state only. Heuristic v1 — model deep-check left for later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:11:08 -04:00
thejayman77 eacf91225a Sources table: Media column (image coverage % + paywall marker)
Per Codex — make the table more decision-ready from data we already have.
Paywall is a domain-level hint, so it's a per-source flag (not a meaningful
rate): show image-coverage % plus a 🔒 marker for subscription domains in one
compact "Media" column (tooltip spells it out). source_health gains a
`paywalled` flag (is_paywalled on homepage/feed); also added to sources.csv.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:58:19 -04:00
thejayman77 1cd7f1d89a Admin CSV export (sources snapshot + audience time-series)
Per Codex v1 — boring-in-the-best-way: inspect/archive operational data outside
the app. Admin-gated, Python csv module, text/csv + attachment disposition.

* GET /api/admin/export/sources.csv — current-state snapshot per source: name,
  feed/homepage, status, visible, served/accepted/total, acceptance/duplicate/
  accepted-dup/image-coverage %, last success/error, retry-after, review.
* GET /api/admin/export/audience.csv?days= — summary block (visitors, returning,
  accounts, feedback, shares) + a blank line + the daily visits/opens series;
  range applies to audience, sources is a snapshot.
* source_health now also returns feed_url/homepage. Small download links on the
  Sources + Audience tabs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 13:05:09 -04:00
thejayman77 38abc26ddd Honor Retry-After on HTTP 429 (polite rest, not a failure)
Per Codex's spec — a publisher saying "slow down" shouldn't make a feed look
broken, but repeated 429s stay visible via last_success_at / stale-source.

* Schema: sources.retry_after_at (nullable) + migration.
* feeds.parse_retry_after: delta-seconds OR HTTP-date → UTC stamp; ignores
  invalid/negative/past; caps at now + MAX_BACKOFF_MINUTES.
* fetch_feed raises RateLimited (carrying the parsed time) on a 429.
* poll_source: on 429 set retry_after_at + last_error, status='rate_limited',
  and do NOT increment consecutive_failures; on success clear retry_after_at;
  non-429 failures unchanged.
* due_source_rows requires BOTH the streak backoff elapsed AND retry_after_at
  passed (i.e. the later of the two).
* Admin: source_health returns retry_after_at; status reads
  "rate-limited · rests until …" rather than "failed/resting".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:47:40 -04:00
thejayman77 373571b476 Candidate UI: sync local state from server-returned candidate
Per Codex polish note: promote/reject/re-preview now Object.assign the
server-returned candidate onto the local one — keeps status/updated_at/preview
(and any future fields) in sync, while preserving the transient UI fields
(_cat/_activate/_err). Promote uses res.candidate then loadStats().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:39:32 -04:00
thejayman77 1a8d1b3bf1 Promote-candidate UI: add-a-source pipeline in the admin console
Bring the supervised source-candidate flow into Sources (Codex's v1 scope), so
adding feeds no longer needs the CLI.

* feeds.safe_fetch_feed: SSRF-safe fetch for UNTRUSTED (admin-pasted) URLs —
  http(s) only, every redirect hop re-validated via enrich._host_is_public,
  body size-capped, bounded redirects, no cookies. preview_feed gains a
  `fetcher` param; the API path passes safe_fetch_feed (NOT the raw fetch_feed
  used for already-vetted polling).
* API (admin-gated): GET /candidates; POST /candidates (suggest+preview, gated
  before the outbound fetch, no DB conn held during network); /{id}/preview
  (explicit re-preview); /{id}/promote (paused by default, returns the new
  source + updated candidate); /{id}/reject. rejected stays on candidates only.
* Admin Sources tab: "Add a source" field + a candidate queue showing the
  preview (pass rate, recent count, example headlines) with Promote (as paused,
  or Activate immediately) / Re-preview / Reject.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:28:00 -04:00
thejayman77 9ed817c051 Source Retire lifecycle (Phase 1: status + content_visible, active mirrored)
Per Codex's plan — introduce a lifecycle without a risky "change the source of
truth everywhere" moment.

* Schema: sources.status (active|paused|retired) + content_visible; migration
  backfills status from active (active=1→active, else paused), content_visible=1.
* `active` is kept as a SYNCED MIRROR: status active→active=1, paused/retired→0,
  so the scheduler/CLI/legacy code keep working unchanged.
* Retire stops polling but keeps articles visible (non-destructive). Hiding is a
  separate, reversible lever: content_visible=0 drops a source's articles from
  the public feed + brief (read AND build), behind a confirm. Personal saved/
  history are untouched.
* API: /sources/{id}/status (validates, mirrors active) + /visibility, replacing
  /active. source_health returns status + content_visible.
* Admin: status column (active/paused/retired + "hidden"), Retired filter,
  Pause/Resume · Retire/Restore · Hide/Show actions.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:58:15 -04:00
thejayman77 a5cea7cd74 Feedback reply: admin-only WYSIWYG editor (server stays the adult)
Replace the Markdown composer with a small contenteditable WYSIWYG (Codex
greenlit for this narrow, admin-only surface).

* markup.py: render_reply_html → sanitize_reply_html + reply_html_to_text.
  Allowlist rebuild via stdlib HTMLParser — keeps strong/em/p/br/ul/ol/li and
  span ONLY with a whitelisted font-size (13/15/18/22px); normalizes b→strong,
  i→em, div→p, <font size> → safe span; drops links/images/arbitrary styles
  (content kept as escaped text) and discards script/style content entirely.
* API: FeedbackReplyBody.html (raw editor HTML); endpoint sanitizes → message_html,
  derives plain text → stored message + the email text/plain part. Unchanged:
  multipart send, store-on-success, conn released during SMTP, mark-read, 404/400/422.
* Frontend: contenteditable editor + toolbar (Bold/Italic/Size/• List/1. List),
  execCommand with styleWithCSS=false for semantic tags, font size wraps the
  selection in a fixed-px span, paste intercepted as plain text. No links yet.

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