Commit Graph

72 Commits

Author SHA1 Message Date
thejayman77 59ff48ae90 Game share-loop: instrument funnel, deep-link shares, /play metadata
Sharpen the existing daily-game share loop into something measurable (per Codex's
"instrument what you have, then feed people into it" plan), ahead of a Show HN launch.

Analytics:
- Per-game funnel events <game>_{arrival,started,completed,shared} (article_id=0).
  arrival = landed via a shared link (utm_source=game_share); started = first move
  (guess/find/flip); completed = solved/cleared/Full Bloom; shared = on share success.
- trackVisit() moved into the global layout so direct /play landings count; the
  server-rendered /a/ share page now creates a visitor token + sends a daily visit
  beacon (first-time /a/-only visitors were previously dropped).
- Admin "Games funnel" panel: arrivals / engaged / completed / shared, per game.

Sharing:
- Memory Match gains a Share button (it was the only game without one).
- All shares deep-link to the exact game+variant with a full https:// URL +
  utm_source=game_share (gameShareUrl helper), instead of a bare /play.
- "shared" is counted only after navigator.share()/clipboard.writeText() succeeds.

/play social metadata:
- /play served homepage canonical/OG (static SPA, ssr=false). postbuild script
  patches build/play.html's head to /play canonical/title/description/OG; fails the
  build if the homepage tags drift. Caddy try_files now serves {path}.html so /play
  is served from the patched file (snapshot in deploy/caddy/).

Tests: backend 352, frontend 27.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 16:22:06 -04:00
thejayman77 89c0fbe1f6 Sync repo to deployed state: SEO recovery, Publishing Desk, Play games, emoji picker
The deploy pipeline runs from the working tree, so a wave of shipped features
had never been committed. This snapshots git to what's actually running.

SEO impression recovery (live + verified):
- Duplicate /a/{id} now 301-redirect to their canonical twin instead of 404
  (a hard 404 silently dropped already-indexed URLs and tanked impressions).
- Dedup representative selection reworked: accepted/serveable -> established
  rep (URL stability) -> quality score, so an accepted page never retires to a
  rejected rep and an indexed canonical doesn't churn when a newer twin arrives.
- HEAD /a/{id} returns the same status as GET (api_route GET+HEAD) instead of
  falling through to the static mount and 404ing.
- `dedup --force-recluster`: cycle-locked, model-free re-cluster to re-apply the
  policy to the existing corpus (shared cycle_lock context manager).
- CLI honors GOODNEWS_DB for its default --db (was silently ignored).

Publishing Desk (admin tool to post highlights to X via Web Intents):
- publishing.py queue/rank/handle-resolution; admin UI; full searchable emoji
  picker (bundled data, no CDN) for the blurb editor.

Play games + site:
- Bloom (word-wheel), Memory Match, daily ritual set, Zen Den (dev-gated).
- English-only language gate; source prospecting; paywall + dedup hardening.

Tests: full suite green (349). Ignores tightened (node_modules, data/*.db).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:32:27 -04:00
thejayman77 ddcfab3a11 Admin: source Articles inspector (verify metrics against real evidence)
New per-row "Articles" button on the Sources table expands a read-only inline
panel of the source's ACTUAL ingested articles — so the automated metrics
(paywall/image/acceptance/duplicate) can be verified against evidence instead of
trusted blind. Distinct from "Check" (which re-samples the LIVE feed for
would-pass quality); this shows what's already in the DB, which is what the table
metrics are computed from.

- Backend: GET /api/admin/sources/{id}/articles?filter=&limit=&offset= (admin,
  read-only). queries.source_articles + source_articles_summary — per article:
  title, url, date, accepted, reason (the "why"), topic/flavor, paywalled
  (domain rule), has_image, duplicate. Summary = counts + source-level paywall
  rule.
- Frontend: expandable panel with a summary header ("27 ingested · 18 accepted
  · … · paywall rule: ON (domain)"), filter chips (All/Accepted/Rejected/No
  image/Duplicates), compact rows with title→link + badges + reason, Load more.

So "100% paywall" or "0% images" becomes clickable evidence: open two articles
to tell a real paywall from a mis-flagged domain, or a true image gap from an
enrichment failure. Test: test_source_articles_inspector. 241 pytest + 11 vitest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 21:37:51 -04:00
thejayman77 dd0df64d76 Games: cross-device sync + overlap colour-blend
Two game polish items:

- Word Search: overlapping cells now multiply-blend the crossing words' colours
  (deepening to a darker shade with readable text) instead of the newest colour
  stomping the rest — matches the new interlocking grids.

- Cross-device game-state sync (signed-in): per-puzzle progress + stats now
  follow you between devices. New game_state table; server-side merge on every
  save so two devices converge regardless of push order, tailored per game:
  * Word Search → UNION of finds (monotonic; can't un-find), earliest start,
    best completion time.
  * Word → furthest-progress wins (terminal beats in-progress; more guesses
    beats fewer) — picks one device's game whole, never splices guesses.
  Stats (streak/distribution/best) derived server-side from the synced states,
  so they're consistent instead of per-device counters. Endpoints GET/PUT
  /api/games/state + GET /api/games/stats (signed-in; size-capped). Frontend is
  local-first: games paint instantly from localStorage, then reconcile in the
  background; both game components push debounced on each move and adopt the
  merge. Conflict handling unit-tested + an API two-device convergence test.

235→ tests + 11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:35:20 -04:00
thejayman77 39d682f353 Polish: plain unfound word-search chips + no signed-out flash on refresh
- 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>
2026-06-12 12:21:43 -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 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 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 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 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 38889f76e5 Source feeds: click a source to see its publication feed
Click a source name on any card → a feed of just that source's articles,
newest-first, still accepted / non-duplicate / boundary-filtered (the calm
promise isn't bypassed). A natural way to follow a publication's feel.

* queries.feed + /api/feed: source_id filter; Article output gains source_id.
* Frontend: source label is a button → transient 'source:<id>' view (like
  'tag:<slug>'), rendered in the feed grid with Load more, header = source name.
* Ad-hoc, not a pinned lane. Foundation for a future source page (metadata) +
  Follow; shareable /source/<slug> route and source_view analytics come then.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 08:30:33 -04:00
thejayman77 224a95d64a Placeholder: settle on the clean flat word
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:23:20 -04:00
thejayman77 b9168b56ea Placeholder: revert drop-cap; add soft orb+ring graphic motif
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:21:17 -04:00
thejayman77 6435840966 Placeholder initial: bold uppercase drop-cap, larger
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:16:29 -04:00
thejayman77 86a6bd3b45 Card + article-page polish
* Placeholder: bold, slightly larger initial letter on the topic word; make
  health (teal) and environment (leaf green) clearly distinct and show more hue
  in the deepened word so they're easy to tell apart.
* Article page: the source name was chopped to its first word ("Read the full
  story at The") — use the full publisher name; open the source link in a new
  tab so upbeatbytes.com stays put.
* Use the new SVG back arrow on the account and admin top bars (matching the
  article page) instead of the old "←" glyph.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 15:05:32 -04:00
thejayman77 101ebb33c2 Remove now-dead .why CSS selectors
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 14:00:52 -04:00
thejayman77 e41fcf6cea Brief cards: drop "why" for more summary; flat placeholder, darker word
Per review: the summary and the "Why it's here" line were competing in a tight
card and both came up short. Remove "why" from cards (it still appears on the
article page when clicked in) and give the summary the room (image cards 2→4
lines). Also flatten the placeholder banner — solid topic-color tint instead of
a gradient-to-blank, with the topic word in a deep near-black shade of that
color so it reads clearly instead of washed out.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:59:53 -04:00
thejayman77 d19099ee7d Images Phase 2: uniform brief tiles — photo or topic-colored placeholder
Per review, mixed photo/no-photo rows read as ragged ("the ones without feel
lacking"). Make every brief rest card carry a banner so the grid is uniform: a
compact 16:9 photo when available, otherwise a calm placeholder tinted by the
card's topic accent color (the same per-topic hue as the accent line) with the
topic word set faint in serif. A failed/blocked image falls back to the same
placeholder, so cards never look broken and heights stay identical. Hero remains
the single large image. Light desaturation on photos; no heavy tint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 13:13:18 -04:00
thejayman77 c25e14ed6a Add a permanent "Latest" lane beside "Highlights"
Restructure the nav around two permanent lanes, then the reader's chosen ones:
"Highlights" (the curated daily brief — formerly "Today") and "Latest" (the
freshest accepted stories, newest-first). Now that the gate is tight, a
chronological "incoming" feed is safe to expose.

* feed(): new sort="latest" (pure recency) alongside the default best-first
  rank; /api/feed exposes sort=ranked|latest (validated). Still accepted-only
  and boundary-respecting either way.
* lanes.py: two pinned lanes (Highlights + Latest) instead of one.
* Home: "Latest" view + "Load more" pagination for every feed view (offset-
  paged, de-duped). Mobile bottom bar gains a Latest tab.
* LanePicker shows both pinned lanes; nav rail renders them first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 15:56:48 -04:00
thejayman77 452e5a3fe4 Hardening pass: scheduler backoff, FK cascade, a11y, test safety net
Pre-traffic cleanup from an audit:

* Scheduler: poll_due_sources now keys on the last *attempt* (success or
  failure), not the last success, and scales the wait by the consecutive-
  failure streak (capped at a day). A failing feed (e.g. Phys.org's HTTP 429s)
  used to be retried every cycle because it had no successful run; it now backs
  off and recovers on its own. Extracted due_source_rows() + tests.

* FK hygiene: deleting a daily_brief is supposed to cascade to its items, but
  SQLite enforces foreign keys per-connection — connect() already sets the
  pragma, so the cascade is correct going forward; added a regression test.
  (Orphaned items + Phys.org settings were cleaned directly on the live DB.)

* a11y: modal/drawer dialogs are now focusable (tabindex), close on Escape
  (window) and on backdrop click via a target check (dropping the inner
  stopPropagation handlers). Build is warning-free.

* tests: conftest points any un-mocked LLM client at a closed port with a 1s
  timeout, so an accidental real call fails fast instead of hanging the suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 19:18:18 +00:00
thejayman77 978edc8f4a Mirror lane picker into the Account page
Add a "Lanes" section under Account that reuses LanePicker inline, completing
the round trip with Boundaries. Refactor LanePicker to support an `inline`
variant (bare panel vs modal) and apply changes immediately on toggle — so the
account panel needs no explicit save and the home modal now previews the nav
rail live as you pick. Selection still persists through the shared prefs store.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:26:38 +00:00
thejayman77 722bcf6317 Customizable nav lanes: pin moods / topics / discovery tags
Readers can now choose which quick-access lanes sit above the feed; "Today"
stays pinned. The pool (goodnews/lanes.py, served at /api/lanes) is one source
of truth over three lane kinds the feed already renders: moods, primary topics,
and high-volume Discovery tags. Selection lives in the existing prefs blob
(localStorage + /api/prefs sync); the filter parser ignores the new `lanes`
field, so it rides along harmlessly. Default = today's moods, unchanged.

Food/Space stay grouping tags rather than primary topics (per review): `space`
already existed; added `food` to the Mind & Craft family so the classifier
assigns it, and seeded the Food lane by re-tagging the two food sources.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 18:19:58 +00:00
thejayman77 427210ac3e User feedback + expanded privacy-respecting admin stats
Feedback:
- feedback table; POST /api/feedback (anonymous-ok, optional category/email,
  honeypot + per-day flood cap) stores + emails the admin; GET /api/admin/feedback.
- Shared feedback store + FeedbackModal; a speech-bubble opens it from the desktop
  header, the mobile top bar (logo moves left), the footer, and /account. Feedback
  section in /admin.

Stats (additive, same privacy model — no IP/UA/referrer/raw terms):
- Event vocab: summary_viewed (fired on /a load), full_story (card → source),
  not_today/less_like_this/hide_topic, replace_used/replace_none, paywall_replace,
  paywalled_source_open. Card title/image opens /a (no double-count); history
  records via keepalive so it survives the nav.
- Dashboard: Accounts card (counts only), reading funnel (summary→source rate),
  emotional-mix & friction, paywall, returning-visitor buckets. (Health metrics
  deferred to a future monitoring dashboard.) 131 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:58:49 +00:00
thejayman77 cfde4e22db Summary briefing layer: Today pre-summarized, /a is the canonical read
Make summaries the core reading experience (summary-first, source-forward):
- Cycle pre-warms summaries for Today's 7 (idempotent → only new ones hit the LLM).
- /api/brief items carry their cached summary; Today cards (hero + tiles) show it
  inline, so Today reads as a calm briefing.
- Card title/image now open the /a summary page (the canonical artifact), with a
  visible "Full story" link straight to the source on every card (the escape hatch).
- /a gains related-grouping chips + a Copy-link/share control.
- Tighten the summary prompt: original, factual, no quotations / no close paraphrase.
Long tail stays lazy+cached. No article bodies stored. 129 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 19:48:32 +00:00
thejayman77 3924d927aa Consolidate Boundaries + History under the account; sectioned /account
The inline Boundaries/History panels lived on the home page, so opening them while
scrolled left you stranded. Move everything "yours" behind the account icon:

- Home header slims to: Saved (opens a right-side flyout, signed-in) · shield
  (Boundaries indicator — filled when active — linking to the Boundaries section) ·
  avatar. The inline panels + the home "saved" view are gone.
- /account is now a sectioned hub (left sidebar on desktop, top tabs on mobile),
  OPEN TO EVERYONE with each section self-gating: Profile (sign-in), Saved (sign-in),
  History (device/account), Boundaries (device/account), Admin (admins). This keeps
  Boundaries/History usable without an account (they're device-local) while
  consolidating the UI — and every section loads at the top, fixing the scroll bug.
- Lift Calm Filters and History into shared stores (prefs.svelte.js, history.svelte.js)
  so the home feed (applies/records) and the account page (edits/manages) share one
  source of truth. New SavedFlyout component. Card boundary actions only render when a
  handler is provided.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 01:59:53 +00:00
thejayman77 1a778e1334 Admin step A: privacy-respecting first-party event logging
- events table (kind, article_id, visitor_hash, day) with a UNIQUE key that dedups
  to one row per visitor-day — caps volume and makes counts mean distinct
  visitor-days. NO ip/ua/referrer/url. Groupings derived from article_id at query
  time, never stored.
- POST /api/events (public): whitelisted kinds (visit/open/share_ub/copy_source/
  native_share/source_click); visitor token hashed server-side (never raw).
- Frontend analytics.js: random localStorage visitor token; track() via sendBeacon;
  visit once/day; open on article click; share_ub/copy_source/native_share from the
  share menu; /a landing pages fire source_click. 127 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:21:49 +00:00
thejayman77 1d71575982 Share pages: lazy, cached, our-own-words article summaries
The /a/<id> page now carries an original short summary so it stands on its own,
without republishing the publisher's article:
- summarize.py: transient SSRF-guarded fetch of the article text → local LLM
  writes a 2-4 sentence ORIGINAL summary (our words). Cached in article_summaries
  forever; we store only our summary, never the body. Generated lazily (only for
  shared/viewed articles), de-duped so concurrent hits don't double-generate.
- /a serves cached-or-pending; when pending it shows a calm "summary on its way,
  read at {source}" note and self-polls /api/summary/<id>, swapping the summary
  in the moment it's ready (never blocks the page on the batch-tier LLM).
- Share menu warms generation on open so recipients usually get the rich version.
- Container reaches the arbiter at arbiter:8080 over caddy_web (LLM env added to
  the API container). 124 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 18:08:40 +00:00
thejayman77 3d9900cdfc Article sharing: branded /a/<id> page + share menu
- Server-rendered /a/<id> "pointer" page (FastAPI): OG/Twitter meta from the
  article's title/why/image, self-canonical (UB pages are the canonical for
  themselves), a prominent "Read the full story at {source}" button and a quiet
  "Explore more on Upbeat Bytes". No article body. Unknown/rejected/duplicate/
  malformed ids → a calm 404 (no stack traces). Text-card preview when no image.
- Caddy routes /a/* to the API.
- Card Share control → menu: native Share… (where available), Copy link (the UB
  card page), Copy source link. Boundary actions now hide-on-hover via a .mute
  class so Save/Replace/Share stay visible. 122 tests pass.

(Event tracking for shares/opens lands with the analytics step next.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 15:27:30 +00:00
thejayman77 15728c3bcb User avatar (Google picture), avatar in mobile You tab, /account page
- Capture the Google profile picture (picture claim) into users.avatar_url; an
  Avatar component shows it, falling back to the initial. Used in the desktop
  header and the mobile "You" tab (which now shows the user when signed in).
- Move account/settings to its own route /account (robust + scrolls to top),
  reached by the desktop avatar and the mobile You tab; drop the inline "You"
  sheet. AccountPanel gains a Sign out action; the page links to Saved/History/
  Boundaries via home intent params (?view= / ?open=).
- db: users.avatar_url (schema + idempotent migration). 118 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:41:43 +00:00
thejayman77 bb008cfaa5 Accounts Phase 4: prefs sync + account/settings panel
- Prefs sync: GET/PUT /api/prefs store Calm Filters/Boundaries on the account.
  On sign-in the client adopts the account's prefs if present, else seeds them
  from the device; every change PUTs to the account so tuning follows you across
  devices. (Login side-effects run under untrack so browsing doesn't re-trigger.)
- Account panel: GET /api/account (email, connected sign-in methods, saved count,
  active sessions); Export my data (GET /api/account/export → JSON download);
  Sign out everywhere (revoke all sessions); Delete account (cascades to all
  account data) with an inline confirm. Reachable from You → Account.

Deferred to a follow-up: link/unlink a provider (OAuth link-mode) and per-session
revoke. 118 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 14:02:38 +00:00
thejayman77 1aa250ca67 Rework history: opened + replaced only, with per-item removal
History was logging every article merely displayed, which made it noise. Split
the two concepts cleanly:
- "displayed" (seenIds) still tracks everything shown, but only to stop Replace
  recycling stories — it no longer feeds history.
- "history" now records only deliberate events: articles the user OPENED (card
  click) or ones they REPLACED away (recoverable accidental swaps).

Also: per-item removal (× in the History panel; DELETE /api/history/{id}), and
when signed in the panel shows the account (cross-device) history. First-sign-in
import now folds the meaningful history (not everything shown). Copy updated.
115 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 13:27:39 +00:00
thejayman77 409bb11444 Accounts Phase 3: save articles, account history, device import
- API (auth-required): GET/POST/DELETE /api/saved (+/api/saved/ids), GET/POST
  /api/history, POST /api/import — all FK-safe (skip ids that no longer exist).
  queries.saved/saved_ids/history reuse the feed article shape.
- Frontend: reactive savedIds store (SvelteSet) + optimistic toggleSave; a Save
  control on cards for signed-in users; a "Saved" view (You sheet) with its own
  empty state; newly-seen items mirror to account history (cross-device); and a
  one-time import folds this device's anonymous history into the account on first
  sign-in. Anonymous browsing unchanged. 115 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 12:56:31 +00:00
thejayman77 b635d8f574 Accounts Phase 2: Google sign-in (OAuth 2.0 / OIDC)
- oauth_google.py (stdlib): PKCE, auth URL, code exchange, ID-token claim
  validation (iss/aud/exp/email_verified — token comes straight from Google's
  token endpoint over TLS, so no signature re-verify / JWKS needed).
- API: GET /api/auth/google/start (302 to Google, PKCE + signed state cookie
  binding the flow to the browser) and /callback (CSRF-checked state, exchange,
  find-or-create by verified email → links to an existing magic-link account,
  session cookie, redirect home). Errors land on /auth/verify?error=google.
- SignIn modal: "Continue with Google" + an "or email link" divider.
- 112 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 01:31:52 +00:00
thejayman77 9237180608 Accounts Phase 1c: sign-in UI, magic-link landing, auth store
- Shared reactive auth store (auth.user) + postJSON helper (sends the cookie).
- SignIn modal: email -> "check your inbox" (calm, no password); Google slots in
  here in Phase 2.
- /auth/verify route exchanges the magic-link token for a session, then home.
- Header shows "Sign in" or an account avatar; the You sheet gains "Signed in as
  …" + Sign out (or a Sign in row). Anonymous browsing is unchanged.
2026-06-03 01:19:30 +00:00