Commit Graph

345 Commits

Author SHA1 Message Date
thejayman77 79fb9b44fc home3 Play card: 'bleeding boards' graphic (Wordle + BYTES word-search + Memory Match)
Decorative tiles (no live answer = no spoilers); placeholder copy. Built from CD's
Play Card Graphic handoff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 22:18:12 -04:00
thejayman77 d85e174a35 home3 refinements: clamp summary, bigger titles/logo/nav, crop art edge, clearer CTA
- News summary + headline clamped to 3 lines so the long brief gist no longer stretches
  the bento (option 1; placeholder-bubble option still available if preferred).
- News CTA "Read the brief" → "Read the good news" (clearer for first-time visitors).
- Art swatch crops a few px off every edge (::after inset) to remove the black scan edge
  at the top of paintings.
- Logo 42→48px, nav 15→16.5px, card titles bumped + weight 500→600 so they pop on scan.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 21:35:55 -04:00
thejayman77 b83a3797e4 home3: Claude Design "Frame A" homepage (editorial, with colour) — real logo + live data
- Rebuilds the design handoff's preferred Frame A: Newsreader serif headlines + Hanken
  Grotesk body (both self-hosted, OFL, no Google hotlink), warm cream canvas, per-card
  accent tints (News teal, Art plum, Play amber, Moment green), bento grid.
- Uses our real /logo.svg instead of the mock's Bricolage wordmark + sunrise.
- Wired live: Good News pulls the top headline/summary/photo (respects the saved
  Closer-to-Home filter); Daily Art pulls today's Met piece (title/artist/year + thumbnail).
- Hidden prototype (noindex), spacing tuned per the /home2 pass (hero pulled up, more air
  before the bento). Sits beside /home2 for comparison.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:32:32 -04:00
thejayman77 84e251e8ec home2: drop the darker top strip on cards (keep tints + inset photos)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:19:59 -04:00
thejayman77 e298d25c92 home2 cards round 4: all cards tinted + top strip, inset photos, centered static headers
- All four cards now tinted with a taller (18px), darker (~24%) top color strip.
- Photos (news top, art right) sit inset + rounded so the card colour frames them.
- Static cards: header centered; CTA pinned to the bottom so Play/Daily Moment links line
  up. News tint = soft amber, Art = soft lilac, Play = sky, Moment = sage.
- Hub spacing: intro pulled up a touch, more gap between the intro and the cards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:13:14 -04:00
thejayman77 4684a216b8 home2 cards round 3: photo-right art, unified side-by-side header, Manrope titles, top strip
- Art photo moved to the RIGHT (text/icon left) so it no longer clusters with the news
  photo in the center.
- Unified header: every card now has icon + title side-by-side; card titles switched from
  serif (too 'document') to Manrope bold. Art icon/title now match Play/Daily Moment sizes.
- Static cards (Play/Daily Moment) gain a darker top color strip (Monopoly-card feel) over
  the tinted body, derived from each tint via color-mix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:57:18 -04:00
thejayman77 b172c5eefd home2 round 2: Manrope nav, bigger logo, photo-top news / photo-left art, tinted static cards
- Self-hosted Manrope (OFL) as the hub sans; nav lighter (weight 500, soft slate, not all
  "on"). Logo up to 58px.
- News card: photo on top + headline below, and it now respects the reader's saved
  Closer-to-Home filter (goodnews:home/homeScope) so the headline matches their Brief.
- Art card: rectangular cover-cropped thumbnail on the LEFT (crops ragged scan edges),
  text on the right — variety against the photo-top news card.
- Play/Daily Moment: tinted backgrounds, bigger centered icon+title, blurb left-aligned.
- /fonts/* + /textures/* served immutable (Caddy live + snapshot).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:46:51 -04:00
thejayman77 5a8e178f51 Homepage hub: /home2 prototype — sections-as-data + bigger shell + hybrid room cards
- New sections registry (lib/rooms.js): each room is one data entry (title/blurb/href/cta/
  size/preview/icon) — add or resize by editing the list.
- Reusable RoomCard (lib/components/RoomCard.svelte) with size variants and hybrid previews:
  Art shows today's live thumbnail, News shows today's top headline, others are blurb+CTA.
- /home2 hidden prototype (noindex, unlinked) with a bigger top bar (logo 50px, larger nav
  labels) and a reflowing grid hub of the four rooms (News/Art/Play/Daily Moment). Iterate
  the look here, then promote to / and remove the clone.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 19:26:31 -04:00
thejayman77 dd8706e2fc Art post-audit polish (Codex): image HEAD, texture immutable cache, lightbox a11y, spacing
- /api/art/image/{id} now answers HEAD as well as GET (was 404 on HEAD) — mirrors the
  /a/{id} fix. Added tests/test_art_api.py (GET+HEAD+size=full fallback + today payload).
- /textures/* served immutable (long cache) instead of no-cache; excluded from the
  revalidate matcher. Live Caddyfile + repo snapshot both updated.
- Lightbox: Escape closes it, and focus moves to it on open (keyboard-friendly).
- Trimmed the gallery's top padding so "Daily Art" sits closer to the bar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 18:17:30 -04:00
thejayman77 39c666b4ee Art frame final polish: four mitered wood rails (grain turns at corners) + brushed metals
- Woods are now built from four real mitered rails clipped to 45° joints, each showing the
  grain along its length — horizontal on top/bottom (rotated texture wood-grain-h.jpg),
  vertical on the sides — so the grain TURNS at every corner like real cut moulding. Each
  rail carries a directional bevel (lit top-left) for a rounded profile; per-species recolor
  moved onto the rails. Rendered via a Svelte snippet in both page + full-screen frames.
- Gold/Silver gain fine brushed-metal striations over the existing sheen.
- New asset: wood-grain-h.jpg (the CC0 texture rotated 90°), credited in textures/CREDITS.txt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:56:23 -04:00
thejayman77 006c1b2792 Art full-screen: light gallery wall, No-frame goes big, recessed-mat depth
- No frame in full-screen now fills the screen (img max 96vw / 88vh instead of the
  framed cap).
- Full-screen backdrop is a soft top-lit gallery wall (light, not dark) so the Black
  frame — and every frame — reads like a piece hung on a real wall; caption text darkened
  to match.
- Mat now sits recessed below the moulding: a rabbet groove line at the lip, frame shadow
  on the mat's top/left, catch-light on bottom/right — a small chiseled step in place of
  the flat frame→mat transition.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:44:14 -04:00
thejayman77 c2c5b25ab1 Art frames: add Black frame; thicker full-screen rail; rail-only top of thickness slider
- Black frame (no grain) added after Silver — a satin charcoal moulding in the metals family.
- Full-screen rail bumped (clamp 18–36px) so the moulding reads as thick as the page view
  against the much larger image; mat held.
- Thickness slider extended to 1.9× and the mat now caps at 1.5× (min()), so the top of
  the slider thickens only the wood while the white border stays put. Applies to the
  full-screen frame too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:33:59 -04:00
thejayman77 90d3e8cbe6 Art frames: real photographic wood (CC0), Mahogany, full-screen fit + proportion fix
- Replace procedural grain with a real fine-grain wood texture (CC0, Poly Haven —
  static/textures/wood-grain.jpg). The three woods share it, recolored per species via
  CSS filter on ::before: Walnut (as-is dark), Oak (lightened/warmed), Mahogany (new —
  deepened/reddened). Miters now apply to all three woods; mat sits between texture and
  miters via z-index.
- Full-screen sizing fixed: image capped (max-height 66vh) so the bottom rail always
  fits — no more cutoff. Rail/mat dialed back to ~1:1 wood:white (was mat-heavy), only
  slightly larger than the page so the proportion matches the small view.

Texture credit (CC0, no attribution required) recorded in static/textures/CREDITS.txt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:20:27 -04:00
thejayman77 71fb16761b Art frames round 3: natural wood grain, thickness slider, no-jump layout, polish
- Oak grain is now a real feTurbulence noise field (organic wood, no drawn-on lines),
  with a soft light/shade for moulding roundness; miters kept.
- Thickness slider (0.7–1.5×) scales rail + mat via --frame-scale, remembered locally;
  applies to both the page and the full-screen view.
- No-jump layout: every frame — including "No frame" — reserves the same footprint
  (rail + mat as CSS vars), so switching never reflows the page. "No frame" is the bare
  art floating with a soft shadow.
- Full-screen wears a thicker rail + mat so the white border keeps its proportion at
  large size; "No frame" still maximizes there.
- Frame chips redesigned as beveled beads with a clean offset selection ring.
- Subtle fading hairline divider between the title and the artwork.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 17:02:26 -04:00
thejayman77 cd8175be81 Art frames: add wood-grain Oak with mitered corners + frame the full-screen view
- New "Oak" frame: real wood-grain along the rails plus 45° mitered corner joints
  (CSS conic-free miter via per-corner diagonal seam gradients sized to the rail width),
  so it reads as four lengths of moulding meeting at the corners.
- The lightbox now wears the selected frame too — the same moulding + cream mat around
  the full-resolution image, which makes the piece pop on a dark full screen. Image is
  capped to leave room for the frame; "No frame" still maximizes size.
- Frame picker wraps gracefully now that there are five options.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:42:31 -04:00
thejayman77 27788ba2a8 Art page round 2: virtual frames, real logo, hi-res zoom, spacing/affordance polish
- Virtual frames (Walnut/Gold/Silver/None), selectable + remembered in localStorage,
  built as a beveled moulding around a cream museum mat.
- Header uses the real /logo.svg wordmark; the "No ads" pill is replaced by an
  account icon (the pill doesn't need to follow every page).
- Lightbox now opens a full-resolution copy that fills the screen: art._download_image
  caches a hi-res {id}-full copy alongside the web-large display copy, served via
  /api/art/image/{id}?size=full (image_url_large in /api/art/today).
- Centered the placard bullet separators (explicit .sep spans, equal margins).
- Image no longer shifts on hover; a quiet "Click to expand" affordance sits on the art.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 16:25:31 -04:00
thejayman77 9bfec573e2 /art v1: single featured piece, framed (look-overhaul testbed)
A calm "walk up to one piece on a quiet wall" page for the Daily Art engine. Bright,
modern, cream canvas (not a dim brown gallery) so the artwork's own colors are the pop
— the new look prototyped, scoped to /art so it doesn't touch the rest of the site yet.

- Fetches /api/art/today; large soft-shadowed frame (click -> lightbox), a museum
  "placard" (serif title, artist · date, medium, "from The Met · Public Domain (CC0)",
  View-at-museum link). Calm loading/empty states. Unlinked from the homepage (safe to
  iterate). Ships the (Codex-cleared) art backend too.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:48:58 -04:00
thejayman77 db967bb7fa Daily Art: Codex guardrails (atomic image, attribution/license, blocked lever)
Hardening before it runs further on the cycle:
- DB-lock/network: all HTTP (metadata + image) happens before any write; the write txn
  opens only at the brief INSERT and commits immediately. Images download to a temp file
  then atomic os.replace into cache (a reader never sees a half-written file).
- Site-timezone "daily" already used local_today() (same rhythm as the Brief) — confirmed.
- Attribution from day one: store + return title/artist/date/medium/department/credit/
  source_url/object_id/source + museum name + is_public_domain license marker + the full-
  res source URL (for a richer /art view later). UI can show: Title · Artist · The Met.
- "highlight != always beautiful": added a manual `blocked` flag on art_pool (excluded
  from picks) as the cheap curation lever; a featured override can follow.

Schema migrated (existing art tables get the new columns). 373 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 15:28:38 -04:00
thejayman77 308516a263 Daily Art backend: curated Met pool, daily cached pick, /api/art (prototype)
The engine for the /art room (design-independent; deploy held for Codex review).

- goodnews/art.py: harvest a curated pool of public-domain HIGHLIGHT artworks from the
  Met (isHighlight+isPublicDomain+hasImages -> masterworks, never potsherds; CC0). Daily
  deterministic pick from the least-recently-shown (no soon-repeats, same for everyone),
  fetch metadata + download the image to OUR cache (data/art_cache) so the homepage never
  waits on or hotlinks the museum. Bulletproof: bad object/image falls through candidates;
  a failed day keeps the last piece (room never empty). Injectable HTTP for tests.
- Schema: art_pool + daily_art. /api/art/today (edge-cacheable) + /api/art/image/{id}
  (served from cache, immutable). CLI `art [--harvest] [--force]` + a non-fatal cycle step.
- Tests (5, mocked HTTP) + verified live against the Met: harvested 1641 works,
  picked/cached "Repose" by John White Alexander. 371 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 14:50:20 -04:00
thejayman77 0c68c22221 Brand consistency: emails say "upbeatBytes" (From + digest body)
Per the brand-name standard (camelCase, one word). Updated the SMTP From default and
the digest email body/subject strings. Live env From values (auth.env + goodnews.env)
updated to match. (Web/OG brand strings in share.py + app.html are the remaining sweep.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 11:38:16 -04:00
thejayman77 b4b02b5050 Scope dial polish (Codex): hero stays closest-first + visible Clear
- Hero constraint: _pick_lead now runs only within the CLOSEST non-empty section of a
  personalized Brief, so a "gentler" wider-region/world story can never be floated into
  the hero slot above a local one. Only widens if the closest section is empty.
- Dial gains a visible Clear (alongside Change) so a reader never feels locked into
  personalization; "World" stays the keep-home-but-go-global option.

366 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 22:06:06 -04:00
thejayman77 3486f3102a Scope dial v2: Nearby / Region / Country / World radius on the homepage
Codex-approved evolution: the reader controls the "emotional radius" of the landing.

- Census-region "Regional" grain (geo.region_of / region_states). Scope-aware tiering
  (queries.home_tiers): closest->widest lead, confidence-gated on state + region, never
  a hard filter — blends outward so the set is always full. 'world' = the global brief.
- queries.home_brief takes a scope; /api/brief gains a scope param (nearby|region|
  country|world). Country-only / non-US homes collapse to country.
- Homepage dial replaces the 2-button toggle: adaptive stops (4 with a US state, else
  Country/World), persisted scope, "Good news closest first" framing. Concrete, soft
  section labels (Around New Jersey / Across the Northeast / Across the US / Around the
  world) so the reader sees the dial worked.

Backend 366 + frontend tests green. (Latest feed still on v1 local-first; aligning it
to the dial is the immediate follow-up.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:59:32 -04:00
thejayman77 d2a6293a13 Local-first Brief: the landing leads with good news from your home
Per the owner's call (overrides the earlier "Brief sacred" stance): when a home is
set, the homepage opens with local good news first, not global. This is the hook —
you land and see awesome stories from YOUR corner first.

- queries.home_brief: local-first highlights (high/medium-confidence near, blended
  out to country then world so it's always a full, strong set), preferring already-
  summarized stories so the calm read stays rich. Recent window, ranked within tier.
- /api/brief gains a `home` param: private/no-store when set; over-fetches + caps so
  dismissal/boundary filtering never thins it; falls back to global top-up if needed.
- Landing UI: a Local <-> Global toggle ("📍 Near you / 🌍 Everywhere") when a home
  is set, the calm picker invite when not (dismissible), and Change. Default leads
  local; one tap back to the global brief. No home set => exactly today's behavior.

Backend + frontend tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 21:36:18 -04:00
thejayman77 2239549799 Closer to Home: gate "Near you" on high/medium confidence (both modes)
Codex polish before deploy: anything elevated as Near you / Close to home must have
geo_confidence in (high, medium) — the feature's promise is relevance. Country-only
mode now gates "near" too; since it has no "country" tier, the "world" scope is
widened to absorb low-confidence home-country stories so they surface there instead
of vanishing between tiers (the same edge-case class, fixed). State mode unchanged.

364 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:29:31 -04:00
thejayman77 3861ed4060 Closer to Home frontend: inline home prompt + sectioned feed
Completes "Closer to Home" (Codex UX: obvious once, quiet forever).

- Opt-in home (country + optional US state) in localStorage; empty = default feed.
- Calm inline prompt above the browse feed ("Want good news closer to home?")
  with a country/state picker; dismissible and remembered (no nagging). Once set,
  a slim "📍 Showing local first · Change · Clear" indicator replaces it.
- Browse feed passes home and pages by next_offset (the near/country lead block
  never skews world paging); soft section headers (Near you / Elsewhere in your
  country / Around the world) render only for tiers that exist.
- Only affects the default browse lane; Brief, topic lanes, and the shareable
  default feed are untouched.

Follow-up nicety: mirror the Home setting inside the Boundaries panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:23:29 -04:00
thejayman77 e7e8f5515e Geo Stage 4 (server): home-aware feed sectioning (Near you / country / world)
Completes the server side of "Closer to Home". /api/feed gains a `home` param
('US' or 'US-NY'); when set the response is private (like prefs) and sectioned:

- Near you (+ Elsewhere in your country when a state is set) is a ONE-TIME lead
  block on page 0; the world is the paginated body. next_offset tells the client
  where to continue, so the lead block never skews world paging.
- Thin tiers fold down (MIN_TIER=3) so a header is never shown empty (lead, don't trap).
- State match counts only on high/medium geo confidence; the "country" tier excludes
  exactly what went to "near", so a low-confidence home-state story still surfaces
  (it doesn't vanish between tiers — caught + tested).
- Items carry a `section` tag; paywalled sort is now within-section. No home => exact
  prior behavior (section null, default/edge-cached feed unchanged), Brief untouched.

364 tests green. Frontend next: Home picker + sectioned feed rendering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:35:22 -04:00
thejayman77 ad4e88c8f2 Geo Stage 4 (data layer): geo on feed responses + home-scope query filters
Foundation for "Closer to Home" (server-side, Codex-approved). No behavior change
yet — geo_scope defaults None, so the default/edge-cached feed is identical.

- queries.feed now returns each article's geo (breadth, confidence, and ISO-coded
  places) via a LEFT JOIN + places subquery. Article.from_row parses geo_places
  into [{country, state}]. Brief query doesn't select geo, so the Brief stays bare.
- queries.feed gains home-scope filters (home_country/home_state/geo_scope =
  near|country|world): STATE match only counts on high/medium geo confidence;
  untagged articles fall to 'world' so nothing is lost during backfill.

Next: API composition (home param + near/country/world sectioning with soft/blended
headers + a next_offset pagination model) and the Home picker UI. 360 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 19:30:43 -04:00
thejayman77 1c05554a28 Geo Stage 1-2: subject-geography model + classifier + pipeline wiring
"Closer to Home" foundation (audit greenlit by Codex). Durable geography, kept
decoupled from volatile scoring.

- Schema: article_geo (breadth/confidence/rationale/geo_version) + article_places
  (0..N ISO-coded places), separate from article_scores so re-runs/audits never
  disturb scoring or acceptance. "local" is never stored — it's relative to the
  reader; the UI computes "Near you" later.
- geo.py: LLM proposes place NAMES, code disposes to ISO codes (country alpha-2,
  US state 2-letter); region words like "Europe" can never become a country.
  'global'/placeless is first-class, not failure. Confidence calibrated so 'high'
  needs an explicit location. Geo is its OWN LLM pass, not merged into the scoring
  prompt (durable metadata, re-runnable, keeps the sensitive prompt untouched).
- store_geo replaces places (geo is re-derivable, unlike scores). tag_articles is
  idempotent by geo_version, only touches accepted non-duplicate articles.
- CLI `geo` command (cycle-locked, --limit/--reclassify) for backfill, plus a
  bounded geo step in the cycle (--geo-limit 60, --no-geo). scripts/geo_audit.py
  is the prototype audit tool.

360 tests green; live smoke tagged real articles correctly (Gaza->PS, London->GB,
placeless science->global). No UI / SEO pages yet — ranking/personalization only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:56:49 -04:00
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 2dbe73430c Sources: per-source paywall override (3-state) — fix domain-rule mis-flags
The Articles inspector revealed paywall is domain-coarse: nytimes.com is flagged,
so NY Times Learning's free Word-of-the-Day inherits 🔒 — and that flag isn't
cosmetic, it deprioritizes the content in feed sort + lead selection. Add a
per-source override so admins can correct it after inspecting.

- sources.paywall_override: NULL (domain rule) | 'free' | 'paywalled'.
- paywall.py: keep low-level is_paywalled(url) (domain); add is_paywalled_for_source
  (url, override) for the EFFECTIVE decision — never patched the domain helper
  globally (per Codex), so "domain says X" stays distinguishable from "overridden".
- Threaded everywhere ranking/UI touches paywall, via src.paywall_override on the
  shared _ARTICLE_COLUMNS + the source-aware helper: feed sort, /api/since, replace,
  lead selection, Article badge, brief composition (briefs.py), digest, source_health
  (table 🔒), the Articles inspector, and the review/attention check — so ranking and
  UI always agree.
- Endpoint POST /api/admin/sources/{id}/paywall {override}; admin UI: a select in the
  inspector header (Use domain rule / Treat as free / Treat as paywalled) + the basis
  ("ON (domain)" / "OFF (override)"), optimistic so the panel stays open.

Test: domain rule → paywalled in table+inspector+feed badge; 'free' → off in all
three; validation 422 + 404. 242 pytest + 11 vitest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 22:10:44 -04:00
thejayman77 7279b18fdc Articles inspector hardening (Codex audit): http(s)-only links + clamp reason
- Article titles render as external links ONLY for http(s) URLs (matches
  ArticleCard's safeHref rule); anything else is plain text — no unsafe schemes.
- reason_text clamped to 2 lines with a full-text title tooltip, so a long
  classifier reason can't make the panel visually noisy.
(Admin gate + source-scoped query confirmed already in place.)
2026-06-12 21:58:13 -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 64339aafb0 Games: in-progress hub status + distribution-aware word-search placement (Codex)
- Play hub: word cards now surface IN-PROGRESS games too (not just won/lost) so
  "continue on another device" shows at a glance — card reads "5:3…" and the
  selection option says "Continue · 3/6".
- Word Search generator: replace "prefer any crossing" with a SCORED placement —
  score = overlap*4 - local crowding (filled neighbours that aren't crossings) —
  then pick among the best ~20%. Keeps the organic interlocking but spreads words
  across the board instead of clumping around the first-placed (longest) words.
  Every word still placed (tests green). NOTE: changes today's grid layouts, so
  an in-progress word search resets once.

237 pytest + 11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 15:18:04 -04:00
thejayman77 de59cf49d8 Play hub: sync game status at the hub, not only on game-open
Reported sync gaps: the hub showed "Play" until you opened each game, and a
game synced only if you'd reopened it on that device (so a desktop win that was
never reopened never reached the server). Root cause: the /play hub read card
status from localStorage only and never talked to the server — sync happened
exclusively inside the game components on mount.

Now the hub itself reconciles every game (word 5/6 + wordsearch small/med/large)
with the server on load (signed-in): pushes this device's local state and writes
the merged result back to localStorage, then refreshes the cards. So statuses
appear cross-device WITHOUT opening each game, and local progress uploads even
for games not reopened. Word Search card status derived from the (completion-
gated) ms. 237 pytest + 11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 14:59:01 -04:00
thejayman77 065ab98598 Games sync hardening (Codex audit): server-side state normalization
Don't trust client JSON at the storage layer:
- sanitize_game_state() runs before merge AND on the merged result (heals legacy
  rows). Word Search: keep only finds whose cells actually spell a real word in
  that day's grid (validated when the puzzle exists, shape-only 4-12 alpha +
  cell-length otherwise), dedupe, renumber ci. Word: validate status enum, guess
  count/length/alpha, colour-row shape, terminal answer/why.
- Completion is now derived from the real puzzle word count (foundWords ==
  expected), not a client-sent `ms` — so stats can't be inflated by junk.
- Date validated as YYYY-MM-DD at the API (400 otherwise) — no junk/future rows.

Tests: sanitizer-rejects-junk + bad-date 400; existing tests updated to use
real-shaped data (the sanitizer is a good forcing function). 237 pytest + 11
vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:51:24 -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 2ef0efd909 Perf: skip needless dedup re-cluster + interlock word-search grids
Two things found while chasing the recurring ~15min slowness:

- dedup.py: cluster_duplicates re-ran an O(n²) cosine pass over ALL ~3.7k
  articles and rewrote duplicate_of for every one of them EVERY cycle — even
  when nothing new arrived (embedded=0) — ~53s CPU + a large WAL commit that
  starved live API reads (/api/brief 2-7s). Now skip the re-cluster entirely
  when nothing new was embedded (clusters can't have changed). Verified: cycle
  drops from ~53s to ~1s and /api/brief stays at 20ms through a cycle, vs 2-7s
  before. (A real new article still triggers a full re-cluster.)

- games.py _build_grid: word placement took the first random valid spot, so
  words rarely crossed. Now gather valid placements and PREFER ones that cross
  an already-placed word (shared matching letter), falling back to any valid
  spot — so the grid interlocks like a real word search. Every word still
  placed (tests green). NOTE: changes today's grid layouts, so an in-progress
  word search resets once.

Also added a systemd drop-in (Nice=19/CPUWeight=20/IOWeight=10/ionice-idle) to
deprioritize the batch cycle — minor, the dedup skip is the real fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 12:35:01 -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 7ffcc0be30 Home: sign the brief save on Replace too (Codex polish)
Codex spotted the one remaining unsigned BRIEF_VIEW_KEY write — in replaceArticle.
Not a safety issue (instant-paint requires cached.sig, so an unsigned entry just
won't instant-paint), but it meant the next load after a Replace fell back to
"Gathering…" until a fresh /api/brief re-saved the signed version. Now every
brief save includes sig: briefSig() (computed after dismissed updates), so an
edited brief still instant-paints. All three save paths verified signed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:58:42 -04:00
thejayman77 456b1a0547 Home: make instant-paint boundary-aware (Codex)
Codex caught a trust bug: instant-painting a brief saved under OLD boundaries
could briefly flash content the reader's current settings should hide — and
boundaries are trust-critical for this product. Add a filter signature
(prefs param + sorted dismissals) saved alongside the brief; instant-paint and
the merge-fallback only reuse a saved brief when the signature still matches the
current settings. A mismatch falls through to "Gathering…" + a fresh fetch.
Also closes the same latent leak in the merge's `?? it` fallback. Briefs saved
before this change lack a sig → won't instant-paint until re-saved (fails safe).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:52:01 -04:00
thejayman77 854f06401f Home: paint the saved brief instantly, refresh behind it (Codex)
Codex's point: edge-caching /api/brief helps anonymous visitors, but a logged-in
reader with prefs/dismissals makes it a personalized (private, origin-bound)
request — so it won't fix Jay's own "Gathering the good news…" delay. The real
fix isn't more CDN tuning, it's not blocking the first paint on the network.

The brief is already saved locally (BRIEF_VIEW_KEY). Now on the Today view we
render that saved brief immediately and refresh /api/brief in the background;
"Gathering…" only shows on a true first visit with nothing cached. A failed
background refresh stays invisible (loadToday returns if a brief is already
painted; onMount won't blank painted content) so a slow/offline origin never
wipes a good view.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:48:01 -04:00
thejayman77 ecf879fd1b Perf: parallelize admin loads + edge-cache /api/brief
Two concrete latency wins found by measuring (server compute is 2-17ms; the time
is in the path, not the box):
- Admin panel fired its 6 API calls SEQUENTIALLY (await chain) — so it paid the
  uncached origin round-trip six times back-to-back. Now one Promise.all batch.
  This is the admin lag.
- /api/brief (the home "Gathering the good news…" content) wasn't edge-cached, so
  a distant anonymous visitor triggered a Cloudflare→residential-origin pull.
  Same global/shareable boundary as /api/feed: public s-maxage=45 when no
  prefs/exclude, else private,no-store. (Needs /api/brief added to the CF cache
  rule path list to take effect at the edge.)

Tests: test_brief_cache_boundary. 228 pytest + 11 vitest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:40:57 -04:00
thejayman77 18c4530721 Remove the service worker (protect first loads)
Per Codex + Jay: the SW was added for nice-to-have PWA/offline caching, but it
sat in the boot path and put first loads at risk (post-deploy tiny-chunk stalls
of 4-10s — fast HTML, then delayed chunks). For a young site where a handful of
visitors a day IS the audience, a broken first impression is a huge share of
traffic. The site's value doesn't need offline caching; browser HTTP cache +
the Cloudflare edge are enough.

Removed cleanly (not just deleted — that strands the old worker on existing
clients):
- Delete src/service-worker.js → SvelteKit stops auto-registering.
- static/service-worker.js is now a one-shot KILL SWITCH: takes over, wipes all
  caches, unregisters itself, no fetch handler (requests go straight to network/
  browser cache). Served no-cache so existing clients pick it up.
- app.html boot script unregisters any worker + clears caches on load, as a
  backstop so no returning visitor stays stuck on the old boot path.

The boot seatbelt (timeout card, preloadError reload-once, telemetry) stays —
that, not the SW, was the real blank-screen protection. Build clean, 11 vitest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 08:30:35 -04:00
thejayman77 f84d934da5 SW: non-disruptive update — drop skipWaiting/claim (post-deploy boot stall)
Telemetry isolated the last boot-slow source (id9): warm edge, fast shell
(60ms), but chunks took 2.6-4.7s on the LAN-fast dev box, 26min post-deploy —
i.e. NOT network/origin. Cause: on the first load of a new build the new SW
ran skipWaiting()+clients.claim(), activating mid-boot — deleting the old
cache and seizing the loading page, yanking the cache from under the ~16
in-flight chunk requests (the sz0 + clustered-start + staggered-finish
signature).

Fix (Codex-approved): remove skipWaiting() and clients.claim() so a new worker
installs quietly and takes control on the NEXT navigation, never mid-boot. The
post-deploy first load then completes under the stable old worker (cache
intact) against the warmed edge. Cache cleanup stays in activate (now runs only
at the deferred, safe activation); old immutable chunks live 14 days at the
origin regardless, so a slightly-behind worker still loads safely. Trade-off —
SW/shell update applies one navigation later — is fine: the shell is
edge-cached and the SW's only job is offline/slow-network fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 07:52:34 -04:00
thejayman77 a34a47fe22 API: edge-cacheable headers for global startup endpoints ("Gathering" speedup)
"Gathering the good news…" waits on the home's startup API calls, which were all
DYNAMIC → a round-trip to the residential origin every load (the occasional 2-3s
linger). These responses depend only on the URL, never the session, so they're
safe to share at the edge:
- /api/moods, /api/categories (static config) → public, s-maxage=900
- /api/lanes, /api/families (global, data-derived counts) → public, s-maxage=120
- /api/feed → public, s-maxage=45 ONLY when shareable (no following / prefs /
  exclude); the following feed (reads the session) and personal filters stay
  private, no-store.

Hard personalization boundary, explicit per-endpoint (no blanket /api/* rule).
Pairs with a Cloudflare cache rule (added separately) making these paths
eligible. Tests assert the global endpoints are public+s-maxage and the feed
boundary (default/topic public; following/prefs/exclude private). 227 pytest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 04:34:11 -04:00
thejayman77 8435041b14 Deploy: warm immutable chunks BEFORE publishing the shell
Post-deploy slow-load fix (telemetry-confirmed): boot-slow beacons showed the
shell arriving fast (33-79ms) but freshly-deployed chunks taking 3-5s, every
event within ~6-8min of a deploy, the same chunks fast HITs later. Cause: the
new shell went live pointing at chunk hashes not yet warm at the edge, so the
first visitor fetched them cold from the residential origin (modulepreload
fires them together → one unlucky "chunk warmer").

Reorder sync-static.sh: warm the immutable chunks at the edge BEFORE swapping in
the new shell, so a published shell never references cold chunks. Shell + routes
still warmed after publish. Pure deploy-script change — no runtime/SW changes.
Warms the origin's nearest POP (covers local users + our own post-deploy
testing); a distant POP still cold-fills once (inherent to a residential origin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 22:12:15 -04:00
thejayman77 c4ea329f9b Candidate rename hardening (Codex): pending-only + length cap
Two small server-side tweaks so the endpoint matches the UI policy:
- Rename is refused (409) for promoted/rejected candidates — they're settled
  history; the UI already hides Rename for them, now the server enforces it too.
- Name is capped at 160 chars before save, so an accidental pasted paragraph
  can't wreck the queue layout.

Tests extended: 300-char name truncates to 160; renaming a promoted candidate
→ 409. 225 pytest + 11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:55:38 -04:00
thejayman77 070b40584e Candidates: inline rename (fix a name typo without reject + re-add)
A staged candidate could only be renamed by rejecting and re-adding it, which
churns the queue and discards the preview just to fix a typo. Add an inline
Rename on each candidate: a "Rename" pill swaps the name for an input
(Enter saves · Esc cancels), POST /api/admin/candidates/{id}/rename →
sources.rename_candidate(). Empty clears the name (promote then derives one
from the feed host). Preview is preserved; the fixed name carries into promotion.

Tests: test_candidate_rename (rename in place keeps preview, promotes with the
new name, gated + 404). 225 pytest + 11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:39:13 -04:00
thejayman77 3afc1ed37e Sources hardening (Codex audit): promote-time dedup, postJSON timeout, host-only feed_key
Three follow-ups from Codex's audit of the deep-preview/search/dedup work:
- Promote-time duplicate guard: promote_candidate() now re-checks
  find_existing_feed() and raises DuplicateFeedError → 409, so an
  old/CLI/direct-DB candidate or a race can't bypass the add-time check and
  silently overwrite a live source's settings via upsert. (sources scanned
  first, so a real source collision wins over the candidate matching itself.)
- postJSON/putJSON/delJSON gain opt-in {timeout} (AbortController, default
  none so other calls are unchanged); deep preview uses 120s and surfaces a
  calm "timed out" message instead of pinning the button on "Deep-checking…"
  if the LAN model stalls.
- feed_key() now lowercases the host only, not the whole URL — paths/queries
  can be case-significant; scheme/www/trailing-slash/host-case still collapse.

Tests: test_candidate_deep_preview_and_dedup extended — promote succeeds once,
then a re-promote of the same candidate is refused 409. 224 pytest + 11 vitest.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 21:31:39 -04:00