Compare commits

..

161 Commits

Author SHA1 Message Date
thejayman77 cf65243e07 zen: rebuild UB locomotion — real 3D steering + motionRoot/visualRig split (Codex)
Not a tuning pass — a locomotion rebuild per Codex's review. Replaces the X-axis shuttle +
scripted 180° U-turn with a proper 3D steering controller (behavior.js): UB seeks wandering
waypoints through the whole tank (XYZ incl. near/far depth passes), eases speed via limited
accel, steers with a rate-limited quaternion toward its velocity, banks into curves, and
softly veers away from walls BEFORE reaching them — no scripted turns.

Architecture: motionRoot (Group) owns world position + heading + bank; the visual rig plays
in-place body clips inside it, so navigation never fights the skeleton and the controller is
the sole heading authority (clips carry no root motion — verified). Fuller clip set exported
(idle/cruise/fast/turn{L,R}{in,loop,out}/eat*, build-clips.mjs). Authored clips play at their
own timing; only cruise cadence scales with speed. Debug panel: cruise speed + roam W/H/DEPTH
+ liveliness, a live readout (mode/clip/speed/turn/pos), and the raw-clip preview.

Next (Codex step 3): needs-driven personality — forage/zoomies/surface/inspect — on this base.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:53:35 -04:00
thejayman77 45bd44834e zen: fluid rework — slow banked U-turns synced to the clip + calmer cruise + clip preview
Phase-B first pass was stiff/awkward — the turn whipped ~180° in ~0.7s while the turn clip
ran 2.5s (fighting), and a flat fish spun that fast went edge-on. Rework:
- U-turn now runs over the clip's duration (~2.4s), smootherstep ease-in-out, banks in/out
  (roll), glides the arc instead of stopping — heading + animation move together.
- Calmer: slower cruise (0.26), fewer modes (cruise/rest, dropped jerky darts), longer
  timers, gentle continuous roll/pitch body sway so cruising isn't rigid.
- New "preview clip" tuner control: freeze locomotion + loop any raw clip broadside — proves
  the animation itself is fluid (isolates engine vs asset).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 21:33:29 -04:00
thejayman77 d13811319d zen: Phase B — UB swims with a behavior engine (wander, cruise/rest/burst, U-turns)
UB is no longer a static in-place loop. New behavior.js owns locomotion: UB wanders a
bounded tank, cruises at a chosen speed, drifts to new depths (nose tilts into it),
occasionally rests or darts, and banks through smooth U-turns at the edges — the tail
beats faster/slower with speed. All clips are in-place, so the engine drives world
position + heading and crossfades between the named clips (idle/cruise/burst/turnL/turnR).

Multi-clip GLB built via tools/glb-split/build-clips.mjs (5 clips, 8.7MB — orphaned Take
accessors explicitly disposed). aquarium.js reworked: clip crossfade + per-frame behavior
apply. Tuner (/zen?debug=1) now exposes scale + Behavior (cruise speed / roam width /
roam height / liveliness) + the fins section. Reduced-motion calms speed + liveliness.
Still admin-gated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 20:38:43 -04:00
thejayman77 9365f69b4a zen: calmer UB — live speed control (default 0.7×) + a touch bigger (scale 1.2)
Swim1_norm read a bit restless; add a tunable playback speed (default 0.7× for a calm
glide, ×0.7 again under reduced-motion) wired into the render tuner, and bump the default
scale to 1.2 for more presence. Both live-adjustable at /zen?debug=1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:37:20 -04:00
thejayman77 a47897e7b1 zen: UB base loop → Swim1_norm (gentle in-place swim, not the static Idle)
Idle is a resting pose (only eye/mouth/fin micro-motion). Swapped the base loop to
Swim1_norm — a ~2.5s swim cycle with ZERO root drift (verified via trim-clip.mjs), so
UB undulates continuously without traveling off-screen. Generalized the trimmer
(tools/glb-split/trim-clip.mjs: extract any [start,end] range, rebased to 0, + drift report).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:21:55 -04:00
thejayman77 ce69b8cd18 zen: UB is now the Queen angelfish (real model) + fix admin lockout
- Admin lockout: /zen checked blockedForViewer() before auth loaded, so a hard-refresh/
  direct-link bounced admins to /play. Now revalidate auth (await refresh if !ready)
  BEFORE the gate check.
- UB swap: retired the two-tail koi (ub.glb/ub-split.glb) for the vetted Queen angelfish.
  Trimmed the 75.67s baked Take down to just the Idle loop (tools/glb-split/trim-idle.mjs
  → 16MB → 6.9MB) → static/models/ub-angelfish.glb. aquarium.js reworked for the pack's
  ONE-mesh/TWO-material layout (…_body opaque single-sided; …_fins opaque alpha-tested,
  tunable); animation is the trimmed Idle. Debug tuner (/zen?debug=1) updated: yaw/pitch/
  scale + one fins&tail section. Still devgate IN_DEV={'zen'} — admin-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:10:38 -04:00
thejayman77 e64c5ced3c art: verify pointerId in dragMove + clear activePointer on every exit (Codex final)
dragMove now ignores events from any pointer other than the one that started the drag
(if !dragging || e.pointerId !== activePointer), dragStart ignores a second pointer
mid-drag, and activePointer is reset in enterZoom/fit/Escape (the close effect already
did). Prevents a second/hybrid-device pointer from hijacking an active drag.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 17:00:12 -04:00
thejayman77 c42f29537b art: harden zoom pointer lifecycle (Codex sign-off)
- Reset dragging on every exit path (enterZoom, fit, Escape, lightbox-close effect) so a
  drag interrupted by Escape/Fit can't carry the grabbing state into the next session.
- Drag ends on pointerup/pointercancel/lostpointercapture (dropped pointerleave, which
  fought the capture) so a drag genuinely continues outside the image.
- dragStart guards e.button===0; track the captured pointerId and release only when
  hasPointerCapture() — no double-release throws.
- a11y: slider aria-valuetext ("150 percent").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:45:03 -04:00
thejayman77 494e9dfcdd art: drag-to-pan the zoomed artwork (persistent position; zoom holds the spot)
Per request: the inspector now pans only while the mouse button is held (grab/grabbing),
using a persistent translate rather than cursor-follow — so you place a detail where you
want it and it stays put. Zooming (slider/±/arrows) scales the translate by the same
ratio, keeping the viewport-centred spot fixed so you can keep magnifying that exact area
without it recentering. Pan is clamped to the image bounds (pointer-capture drag); 1× recenters.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:20:12 -04:00
thejayman77 6b2458f674 art: variable gallery-inspector zoom (1×–4× toolbar) instead of a binary jump (Codex)
Reworked the lightbox desktop zoom from a fixed 2.5× toggle into a proper inspector:
enter at 1.5×, a quiet floating toolbar (− / slider / + / % / Fit) drives a continuous
1×–4× scale in 0.1 steps, cursor movement keeps panning (transform-origin). Fit returns
to the framed gallery view; Escape steps out then closes; the slider takes native arrow
keys. Removed click-to-exit on the artwork (too easy to trigger while inspecting) — exit
is the visible Fit control or Escape. Toolbar is a translucent dark pill, hidden on touch
(native pinch). Zoom resets when the lightbox closes. Uses the cached full-res asset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 16:13:00 -04:00
thejayman77 f3005e626d art: desktop zoom in the lightbox (pan-by-cursor), mobile keeps pinch
The full-screen lightbox showed the framed piece capped at ~66vh, so on desktop it was
barely larger than the page view and there was no way to inspect detail (mobile can pinch).
Add a "Zoom in" affordance: it swaps to a magnified inspection view (full-res image scaled
2.5×) where moving the cursor pans via transform-origin; click or Escape steps back to the
framed view, Escape/✕/backdrop close. Restructured the lightbox from a single <button> to a
dialog (backdrop button + close button + stage) so the controls are valid/accessible. Zoom
button hidden on touch (hover:none) — native pinch covers mobile. Uses the already-cached
full-res copy (/api/art/image/<id>?size=full); fade-in, frame/thickness, rotate-on-portrait
all preserved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:52:10 -04:00
thejayman77 d79c52dab4 docs: hero referrer fix done — referrerpolicy now consistent across all remote images
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:38:53 -04:00
thejayman77 4d2f5e3703 home: hero image as <img referrerpolicy=no-referrer> (privacy consistency, Codex)
The homepage hero was a CSS background-image, the one image on the site that couldn't
carry referrerpolicy — so a remote hero leaked the referrer to the publisher CDN while
article cards + share pages suppressed it. Replace with a real <img referrerpolicy=
"no-referrer">; the retry probe now sets probe.referrerPolicy='no-referrer' too. object-fit
cover/contain replaces background-size (contain keeps the matted framed-plate look via
padding), fixed 5/4 footprint, fade-in and typographic fallback preserved; img onerror
falls back to the typo cover post-reveal. (Suppresses the referrer, not the IP — zero
third-party requests still requires policy 'none' or local caching.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:38:05 -04:00
thejayman77 35aa8ff544 docs: record cache-revoke purge + engagement warm-up + optional hero-referrer item
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:30:43 -04:00
thejayman77 2dc4419024 images/analytics: purge on policy revoke + engagement warm-up note (Codex close-out)
- newsimg.purge_source(): when a source leaves 'cache' (permission revoked / re-classified),
  the admin image-policy endpoint now deletes that source's re-hosted copies immediately,
  rather than leaving them inaccessible-but-on-disk. Endpoint returns {purged}.
- Admin "Engaged readers" carries a warm-up note: tracking began 2026-06-30, so low
  rolling windows are partly warm-up, not all bots (compare d7 after a week, the window
  after its full span). Guards against misreading "6 engaged vs 135 visits" as 129 bots.
Tests: purge_source removes only the target source's copies; endpoint reports purged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:29:55 -04:00
thejayman77 9d46e03ab8 docs: durable policy of record for images + visitor metrics (Codex close-out)
Encodes the source-level image-rights policy (cache/remote/none; default remote,
opt-in cache only for cleared sources) and the Recorded-visits vs Engaged-readers
metric, so the decisions live in the repo for future audits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:09:24 -04:00
thejayman77 f416e13700 analytics: honest engagement metric — Engaged readers vs Recorded visits (Codex)
Admin now shows two numbers:
- Recorded visits: the existing raw count (one daily 'visit' beacon; still includes
  UA-spoofing bots that slip past the UA filter).
- Engaged readers: distinct visitor-day with DELIBERATE activity — either the new
  gesture-gated 'engaged' beacon (fires once/day only after ~8s visible AND a real
  scroll/pointer/key/touch) or a deliberate action (source_click, full_story, share,
  replace_used, paywall_replace, not_today/less_like_this/hide_topic, game start/
  complete/share). Explicitly EXCLUDES auto-fired visit/summary_viewed/open, replace_none,
  and game *_arrival (a share-loop landing, not engagement).

armEngaged() in analytics.js (wired in the global layout) + a mirrored vanilla-JS beacon
on the server-rendered /a/<id> share pages. 'engaged' added to the event allowlist and
fired with article_id=0 so the uniqueness constraint dedups it per day. queries.admin_stats
gains engaged_today/d7/d30. Bots are doubly excluded (UA filter at the beacon + the
gesture gate). Tests cover the metric (engaged + deliberate counted; visit/summary/arrival
not). 447 backend + 36 frontend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:07:24 -04:00
thejayman77 8a7606e20d images: fix two fetcher bugs + add source-level image-rights policy (Codex)
Fetcher (the two remaining bugs Codex found):
- Real redirects are now followed. _NoRedirect makes urllib RAISE HTTPError on 3xx, so
  the old status-branch was dead code (mocked tests masked it). Handle 301/302/303/307/308
  HTTPError as redirects (re-validate the destination); classify 4xx≠429 as PERMANENT
  (negative-cached), 429/5xx/network as transient. Real-opener redirect + 404/5xx tests.
- The megapixel ceiling is now enforced: explicit `w*h > _MAX_PIXELS` check BEFORE load()
  (Pillow only warns at MAX_IMAGE_PIXELS). Test with a lowered ceiling.

Image-rights policy (per Codex + owner decision — only cache what's cleared):
- sources.image_policy: 'cache' (re-host a downscaled copy — license/permission/PD only),
  'remote' (hotlink the publisher's image — the conservative DEFAULT), 'none' (no image).
- newsimg.display_url resolves the display URL per policy; applied in Article.from_row so
  feed/brief/history return the right URL, and in share.py (og/twitter still reference the
  publisher's own image, never re-hosted). warm() + /api/img both gated on 'cache'.
- Frontend uses the server-resolved image_url (reverted the hardcoded /api/img); the
  graceful retry covers remote hotlinks too. Admin: per-source image-policy selector +
  POST /api/admin/sources/{id}/image-policy. Default 'remote' → nothing re-hosted until
  a source is explicitly cleared.

445 backend + 36 frontend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 14:01:11 -04:00
thejayman77 a55ba185a8 images: harden the cache per Codex audit (SSRF-safe, cache-only endpoint, WebP-only)
Blocker fixes for the image cache:
- /api/img/{id} now serves cache HITS ONLY and is restricted to ACCEPTED, CANONICAL
  articles. It never fetches — the cycle (newsimg.warm) owns all fetching — so the
  public endpoint has no SSRF/worker-exhaustion surface. Dropped 1-year immutable
  caching (image_url can change) → public, max-age=86400.
- newsimg._safe_fetch: SSRF-safe (reuses enrich._host_is_public + _NoRedirect, http(s)
  only, every redirect hop re-validated, body capped). _FetchError distinguishes
  permanent refusals (negative-cached via a .fail marker) from transient errors (retry).
- _encode re-encodes only decoded RASTER images to WebP and REJECTS everything else
  (SVG, undecodable, decompression bombs via MAX_IMAGE_PIXELS, pathological dimensions);
  originals are never retained. prune() also sweeps stale .fail markers.
- Concurrency: fetching only runs inside the cycle lock; writes stay atomic.

Smaller fixes:
- share.py visible image has onerror→this.remove() (degrade to the text unfurl, no
  broken icon when an image isn't cached yet).
- share-page Back follows history only on a SAME-ORIGIN referrer (never bounce to an
  external site); menu now honors Escape + resets crossing back to desktop (HubBar parity).

Tests: private host, redirect-to-private, hostile SVG/non-image, transient-vs-permanent
failure, LRU prune, warm (accepted+canonical only, idempotent), cache-only endpoint
(404 on not-cached/unaccepted/duplicate, never fetches), share chrome parity. 441 pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:19:57 -04:00
thejayman77 c350a2713b git: stop tracking the runtime image cache (data/img_cache)
The cache-feature commit swept warmed WebPs into Git via 'git add -A' — 257 tracked
files (~12 MB) + churn making prod checkouts dirty. Add data/img_cache/ to .gitignore
(beside art_cache) and untrack with 'git rm -r --cached' (live files retained; no
history rewrite). [Codex audit blocker]

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 12:14:32 -04:00
thejayman77 ee43bb0df6 analytics: filter known-bot User-Agents at /api/events (honest visitor counts)
Many modern crawlers (AI scrapers, headless Chrome, link-preview fetchers) run JS and
fire the visit/summary_viewed beacon, inflating "visitors" even though there's no
human discovery channel. Apply queries.is_bot_ua() at /api/events — the same filter
the load-error beacon uses — so honest bot UAs (GPTBot, AhrefsBot, headless Chrome,
python/curl, …) are dropped before recording. Response is identical so a bot can't
detect it. Counts read lower but truer going forward (past rows unchanged). Won't catch
UA-spoofing bots; that needs a heavier heuristic. Tests: bot UAs dropped, real browser
counted; existing event tests send a real UA (default client UA contains "python").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 11:19:51 -04:00
thejayman77 27022108b4 caddy: block vuln-scanner probe paths (no-PHP/WP stack) → 403, not the SPA shell
Path-only @junk matcher on upbeatbytes.com (*.php, /wp-*, /.env, /.git, /phpmyadmin,
/vendor, etc.) returns 403 instead of falling through try_files to a 200 SPA shell.
Never matches by User-Agent, so real users + Googlebot/Bing are untouched. Applied to
the live Caddyfile (validated + reloaded) and mirrored into the repo snapshot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 10:11:57 -04:00
thejayman77 86d9897113 ui: reserve the scrollbar gutter so the top bar stops shifting between pages
Pages tall enough to scroll showed a ~15px scrollbar; short pages didn't — so the
centered top bar jumped left/right as you navigated. scrollbar-gutter: stable on html
(SPA app.css + the server-rendered share pages) keeps the layout width constant. No-op
on overlay-scrollbar platforms (mobile), which never shifted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 04:52:59 -04:00
thejayman77 3740e09d02 share pages: carry the real HubBar toolbar (consistency with the SPA)
The server-rendered /a/<id> and digest pages predated "HubBar everywhere" and showed
a stripped bar (logo + a bespoke Back pill). They can't run the Svelte component, so
add a hand-kept static replica of HubBar (logo + News/Play/Art nav + account glyph +
mobile burger/drop-panel) plus HubShell's borderless ← Back. A signed-in reader's
avatar paints from the same localStorage cache HubBar uses. /a/<id> now looks like any
detail page (/art, /word). Reusable _top_bar_html/_TOP_BAR_CSS/_TOP_BAR_JS/_back_link
helpers; applied to both share pages. Kept in sync with HubBar.svelte by hand (noted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 04:26:01 -04:00
thejayman77 c33dad9832 images: add Pillow to the web extra so the API container downscales too
The on-demand /api/img path runs in the container (only fastapi+uvicorn), so without
Pillow it fell back to caching the original full-size bytes instead of a downscaled
WebP. Add Pillow>=10 to the web extra. The host cycle already had it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:30:05 -04:00
thejayman77 8a3c00db3b images: cache + serve article images from our own origin (bounded, LRU-evicted)
Stop hotlinking news images from third-party CDNs (the source of the "blank until
you refresh a few times" graphic). New goodnews/newsimg.py caches a downscaled WebP
display copy (≤800px) beside the DB, like art_cache:
- GET/HEAD /api/img/{article_id} — resolves id→image_url (allowlisted to our corpus,
  not an open proxy), fetch+cache on first miss, serve local after, immutable headers.
- cycle warms display copies for recent accepted-with-image articles (so the FIRST
  view is already local) and prunes to a hard size cap (default 1 GB) by LRU eviction.
Frontend now points at /api/img/<id>: the hub lead, every ArticleCard (feed hero +
cards), and the /a/<id> share page's visible image. og:image/twitter:image stay the
source URL so social crawlers fetch the canonical image directly.

Storage is bounded by construction — over the cap, least-recently-used files are
evicted, so it can't grow without limit regardless of ingest rate. Tests cover
fetch/downscale, cache-hit (no refetch), bad-scheme/non-image rejection, fetch
failure, LRU prune, warm, and the endpoint allowlist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 20:28:33 -04:00
thejayman77 cb06d550bd home: reveal the news photo only once it actually loads (retry + graceful fallback)
The hub painted the lead news image as a CSS background straight from the source's
hotlinked URL — one transient failure (slow/rate-limited third-party CDN) left a
blank plate until you refreshed and the browser served it from cache. Now the probe
that already runs for cover-vs-figure detection gates the photo: load with up to two
retries (0.5s/1s backoff), reveal the plate only once it's truly loaded (and cached),
and otherwise keep the typographic topic cover. Soft fade-in on arrival; reduced-motion
honored. No more blank-until-refresh.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 19:58:26 -04:00
thejayman77 d98cec9ded admin: read/unread triage for load errors (unread by default, mark read/all)
The load-error log had no way to clear reviewed entries. Add a read_at column to
client_errors and a read/unread model mirroring the feedback inbox:
- GET /api/admin/client-errors?show=unread|read|all (default unread; returns id+read)
- POST /api/admin/client-errors/read-all  (mark all unread read)
- POST /api/admin/client-errors/{id}/read {read: bool}  (per-row toggle)
Headline stat is now "Unread load errors" (admin_stats.client_errors.unread), so the
red badge clears as you triage. Admin UI: Unread/Read/All tabs, a "Mark all read"
button, and a per-row ✓/↩ toggle; reading an entry drops it from the default view.
14-day auto-prune still bounds the table. Tests cover filter, toggle, mark-all,
404, and gating.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 10:38:22 -04:00
thejayman77 bddb8d22b0 HubBar: revalidate auth on mount so the avatar shows on cold hub entry
auth.user paints from its localStorage cache, but if the hub is the entry point
nothing had refreshed the session. Revalidate once (guarded on !auth.ready) so the
profile picture + signed-in state are correct wherever the shared bar renders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 05:56:26 -04:00
thejayman77 b8ac82e897 HubBar: show the signed-in profile picture in the account button
The cutover put HubBar on / and /news, but HubBar's account icon was a hardcoded
person SVG — so signed-in users lost the avatar the old `/` Header showed. Render
<Avatar> when auth.user.avatar_url exists (fills the circle, no tint peek); fall
back to the person glyph when signed out / no picture.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 05:54:11 -04:00
thejayman77 0ae789752e fix: QOTD/WOTD freshness — pick within the freshest cohort, not the rotated pool
Both selectors ordered candidates least-recently-shown, then daily.seeded_order()
ROTATED the whole list and took [0] — an arbitrary date-hashed item, undoing the
ordering. Result: repeats (quote id 2 on 6/28+6/29; word "harmony" on 6/25+6/28),
no guarantee a pool item is shown before it recurs.

Fix: daily.freshest(rows) returns the freshest cohort only — every NEVER-shown
item while any remain, else the oldest-shown group. quote/wotd _candidates use it;
seeded_order now picks deterministically WITHIN that cohort. So every pool item is
featured once before any repeat, then cycles oldest-first. Dropped the unused
_NO_REPEAT_POOL window. Tests: no-repeat-until-exhausted (quote + wotd) + a
freshest() unit test. 428 backend tests green.

(Separate follow-up: expand the QOTD pool from 16 → 90+ vetted public-domain
quotes for a longer no-repeat window.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 05:39:06 -04:00
thejayman77 414a4c4b8b deploy: drop the cache-warmer from sync-static.sh (no-op without CF proxy)
Cloudflare is DNS-only (grey-cloud) for upbeatbytes.com — no proxy/CDN/edge — so
the warm() step (curl every chunk + key routes through the public domain) wasn't
priming any edge; it just GET every asset from the already-fast static origin,
generating thousands of internal-origin requests per deploy (the "traffic spike"
in the logs). Removed it. Kept the valuable part: chunks-before-shell ordering,
14-day chunk grace, service-worker last. No change for visitors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 05:28:49 -04:00
thejayman77 03aed9c37d fix: mobile-game footer selector + hub teaser honors Boundaries (Codex)
- app.css: the playing-game footer-hide targeted the old footer.site; the shared
  footer is footer.ub-foot now → during a mobile game the footer lingered. Retarget.
- Homepage hub teaser fetched /api/brief without the reader's prefs, so an excluded
  topic could still be featured on /. initPrefs() + append P.param(prefs.data),
  matching the News Brief — boundaries now respected on the hub.

(Nonblocking, noted for later: legacy /?view=… redirects are client-side and drop
unrelated params like UTM.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:05:36 -04:00
thejayman77 667b1a82c3 brand: standardize "Upbeat Bytes" → "upbeatBytes" everywhere
Per the logo + brand: the name is upbeatBytes (camelCase). Swept all user-facing
strings — titles/og:site_name/og:title, logo alt text, share pages (share.py),
emails (email_send), classifier prompt (llm), digest/unsubscribe (api), PWA
manifest, game share text, sign-in, the SPA shell + patch-static-heads (play
title) — plus README/publish.sh and the email test fixture. (SMTP From env was
already upbeatBytes.) Domains (upbeatbytes.com) unchanged. 425 BE + 36 FE green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 20:01:20 -04:00
thejayman77 f8628b3b14 homepage: title → the hub tagline (was news-only)
<title> + og:title + twitter:title: 'Upbeat Bytes — calm, constructive news' →
'upbeatBytes — a calmer, brighter corner of the internet', matching the hub's own
svelte:head so crawlers and JS users see the same thing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:55:13 -04:00
thejayman77 1bd86e30e5 caddy: fix /home2,/home3 redirect (redir destination, not a path matcher)
`redir / permanent` mis-parsed — Caddy read the leading `/` as a path matcher and
`permanent` as the destination, so it only matched `/` and emitted a broken 302 to
"permanent". Use an explicit destination URL (matching the www→apex idiom):
`redir https://upbeatbytes.com/ permanent`. Live Caddy reloaded; snapshot mirrored.
Verified: /home2,/home3,/home3.html → 301 → /.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:21:44 -04:00
thejayman77 2cfffdfd6a NEWS RELAUNCH CUTOVER: promote the hub to /, feed to /news, go public
The big flip. /home3 (hub) becomes /; the feed lives at /news; both indexable.
- PROMOTE: routes/+page.svelte is now the hub (was the interim NewsFeed wrapper);
  noindex removed; "Read more good news" → /news. routes/home3 + home2 deleted.
- routes/+page.js: redirects legacy root-query links (/?view=latest, /?tag, /?source,
  /?q, /?view=today→highlights) to /news before the hub renders (no flash).
- /news: noindex dropped (route meta + Caddy @newsHidden removed); now public.
- LINKS: HubBar brand/Home → /, News default → /news; HubShell/art/play back → /;
  account Following + share.py Explore/Browse/source → /news.
- FOOTER: one shared Footer.svelte (motto + Send feedback + slot) across Hub/News/
  Play/Art/HubShell/Account/Zen; global layout footer removed (FeedbackModal stays).
- SITEMAP: + /news /art /play /word /quote /onthisday; cap 5k→50k; gated on
  has-summary; paywalled excluded; HEAD now 200 (api_route GET+HEAD).
- Head-patcher: /news entry. PWA + shell description broadened to the hub.
- Caddy: @newsHidden dropped; @hidden now admin-only (word/quote/onthisday public);
  /home2,/home3 → / 301. Mirrored to deploy/caddy snapshot.

425 backend + 36 frontend tests green; build clean; Caddy valid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 19:16:43 -04:00
thejayman77 1c1ecefde8 news: harden paywall exclusion at the candidate query + add the missing regressions
Codex's two non-blocking hardening items, folded in before cutover:
- _candidate_articles() now excludes paywalled sources IN-QUERY (before LIMIT 50),
  so flagged stories can't consume candidate slots and leave a full brief thin.
  Dropped the now-redundant post-fetch filter in build_daily_brief.
- Regressions: history retains a viewed paywalled article; sitemap omits a
  paywalled source AND restores it under override="free".
- Aligned test_brief_paywall to the source-level model (paywalled sources carry a
  paywalled homepage, as in production) — it had relied on article-URL detection.

425 backend tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:54:53 -04:00
thejayman77 c600145ba5 news: close the remaining no-paywall bypass paths (Codex audit)
queries.feed was the main chokepoint, but several discovery paths have their own
SQL. Apply the shared source exclusion to all of them so "no paywalls" is truly
site-wide:
- briefs.build_daily_brief: EXCLUDE paywalled candidates (was: demote) — never
  stored in a new brief.
- queries.brief: stored-brief retrieval (covers /today + /api/brief) filters the
  paywalled source.
- digest.digest_items + followed_digest_items: the morning email + "from what you
  follow" omit paywalled sources.
- sitemap(): paywalled article pages excluded from the sitemap.
All reuse queries.paywalled_source_ids (admin override still wins).

Regression tests (test_paywall_exclusion.py): never stored in a new brief; /today
+ digest omit it; followed-source email omits it; Saved retains it; 'free'
override restores eligibility. 423 backend tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:22:52 -04:00
thejayman77 0d21231597 news: hard-exclude paywalled sources from the feed + brief (no unreadable news)
Per Jay: don't surface stories people can't read without paying — it's off-brand
("no paywalls") and pointless. Paywalled is source-level (domain rule, admin-
overridable): just 3 sources today (Nature, New Scientist, MIT Tech Review),
~5.4% of accepted articles.

- queries.paywalled_source_ids(conn): live source set (admin override wins).
- queries.feed gains include_paywalled=False (default) → adds `a.source_id NOT IN
  (…)`. One chokepoint covers Latest/tags/sources/moods/topics/search/since AND
  the brief top-up. Source-level + SQL → paging stays exact, no frontend change.
- brief(): filter the cached/home pool by the same rule; replacement already
  avoids paywalled and now rides the feed exclusion too.
- Dropped the now-moot "paywalled below readable" demotion sort.
- Saved/history keep showing items you saved (their own queries, not excluded).
- test_source_paywall_override updated: paywalled source → excluded from the feed
  (was: shown with a badge); 'free' override → returns, no badge. 418 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 17:10:00 -04:00
thejayman77 54761f5083 news behavior split: /news leads with Latest, Highlights via ?view=highlights
Base-aware so the frozen `/` is untouched (it's still the live, indexed site
until cutover); the new behavior applies only to /news. At cutover `/` becomes
the hub and only /news's behavior remains.

- defaultView(base): /news bare → Latest (the live firehose); `/` bare → Highlights.
- Brief is canonically /news?view=highlights, with ?view=today kept as an alias.
- Latest is pure chronological on /news — stop passing `home` into it (geo scope
  belongs to Highlights). The Closer-to-Home card/dial is hidden on /news Latest;
  Highlights keeps the scope dial. `/`'s Latest keeps geo (frozen).
- Back fixed: on /news it shows only for genuine drill-ins (tag/source/search),
  not the top-level lanes (Latest/Highlights/Following); `/` keeps its old rule.
- goBack's app-safe fallback lands on the base's default view.

feednav.js gains defaultView + def-aware parse/build; 36 frontend tests (9 new),
build clean. /news stays noindex.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:21:42 -04:00
thejayman77 39b38f0cf1 /news utilities: label the pills + wrap the action row on phones (Codex)
- Saved/Boundaries are now labeled pills (icon + text), not bare circles — a
  shield alone doesn't read as "Boundaries" on touch. .vh-util is auto-width with
  padding; labels show on desktop and mobile.
- Fix the narrow-screen overflow: on a signed-in hub-chrome drill-in the row
  (Search+Saved+Boundaries+Follow+Back) exceeded ~320–375px. The view-head now
  wraps the action row below the heading — scoped to `.container.hub` so the
  frozen `/` feed (fewer controls; Saved/Boundaries in its Header) is untouched.

32 tests green; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:01:05 -04:00
thejayman77 036e7ed7e8 /news: surface Saved + Boundaries in the view-head (don't bury them in Account)
Per Codex: HubBar stays purely site-level, so the feed's own utilities live with
the feed. Beside the existing Search toggle (hub chrome only, so `/`'s Header
keeps its own — no duplication): a Saved button (opens the existing flyout) and a
Boundaries/Tune control with a visible active indicator (links to its account
section for now). Same pill styling as Search.

Also flagged the Back-condition trap in-code: once bare /news becomes Latest,
Back must be suppressed for 'latest' too (only genuine drill-ins show it) — to be
fixed at the behavior split, not now (would alter the frozen `/`).

32 tests green; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:42:40 -04:00
thejayman77 e974fc4942 /news: wear the shared HubBar (consistent chrome), keep BottomNav + global footer
Per the agreed direction (Codex calls): /news joins the new family without CD.
- NewsFeed gets an explicit chrome="legacy|hub" prop (never inferred from path):
  `/` passes legacy (its own Header, unchanged) and /news passes hub (the shared
  editorial HubBar). Exactly one bar renders — never HubBar + Header.
- HubBar gains a configurable `newsHref`; the /news instance links News → /news
  (active), not the live `/`. Other hub pages keep the default (News → /).
- BottomNav kept (Highlights/Latest/Play/You stay visible); no top-level Back on
  bare /news (HubBar Home returns to the hub); contextual Back on drill-in views
  is unchanged. No new footer — the global footer stays until the shared Footer step.

Known prominence shift (refinable later): Saved/Boundaries move off the top bar on
/news (reachable via account); Feedback stays via the global footer. /news still
noindex. 32 tests green; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:32:08 -04:00
thejayman77 2fd28fa719 news: track @newsHidden in Caddy snapshot + extract testable feed routing helpers
Housekeeping per Codex:
- Mirror the live @newsHidden rule into deploy/caddy/Caddyfile.snapshot so the
  /news noindex protection is reproducibly recorded.
- Extract the feed's routing helpers (feedBase/parseView/viewUrl) into pure
  $lib/feednav.js and unit-test them (the base-aware URL generation wasn't
  exercised by the prior suite). NewsFeed imports them; behavior unchanged.

(Note: the step-1 commit also swept in data/wotd_audio/renewal.mp3 — a legit
cached pronunciation, not extraction-related; left as-is per Codex.)

32 frontend tests green; build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:19:36 -04:00
thejayman77 f4a7a7bcc7 news relaunch step 1: extract the feed into NewsFeed.svelte, mount at / and /news
Pure refactor, no visible/behavioral change. The ~1065-line feed moves verbatim
from routes/+page.svelte into lib/components/NewsFeed.svelte; both routes/+page
and the new routes/news render it. Link generation is base-aware (feedBase()):
on `/` it builds `/?…` exactly as today (bug-for-bug parity); on `/news` it
builds `/news?…` so /news is self-coherent. At cutover, `/` becomes the hub and
the feed lives only at /news.

/news is kept hidden during the transition (noindex, follow) so we never publish
a duplicate indexable feed: route <meta robots> + a Caddy @newsHidden X-Robots-Tag
(follow, so link equity flows). Removed at cutover, when /news enters the sitemap.

27/27 frontend tests green; build clean; /news.html prerenders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:11:06 -04:00
thejayman77 099bf55711 docs: news relaunch migration plan (link/redirect map + interim routing)
Settled plan (user + Codex) for standing up /news as the feed's home and cutting
/home3 → / without breaking the feed, deep links, or SEO. Drives the upcoming
implementation; next build is the feed extraction (pure refactor). Includes the
four Codex amendments: /news noindex during transition, explicit prototype 301s,
explicit legacy-view mapping (shim before render + /news?view=today alias), and
the footer coverage inventory (FeedbackModal stays in the global layout).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:00:08 -04:00
thejayman77 6c10ad99a9 On This Day: serve sharp images (originalimage, not the 330px thumbnail)
The Wikimedia feed's thumbnail is 330px, which upscales blurry in our hero. Use
originalimage.source instead — it's reliably sharp. (Can't just request a bigger
thumbnail width: for very large source images Wikimedia only serves pre-generated
bucket sizes and 400s on arbitrary widths — e.g. 500px ok, 800/1024px fail.)

- onthisday._best_image() prefers originalimage, falls back to the thumbnail.
- scripts/otd_image_upsize_backfill.py re-fetches each stored MM-DD and upgrades
  image_url in onthisday_pool + daily_onthisday in place (ran on host: pool + 6
  daily rows now sharp; today's hero verified 200). Only the /onthisday hero
  loads this image (home card is text-only), so larger files are a single-page,
  one-time load.
- test_best_image locks the prefer-original/fallback behavior.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 17:07:37 -04:00
thejayman77 e3e6f24753 home3 news: typographic category cover for pictureless articles
~half the brief has no image, leaving a blank well above the headline. When an
article has no image_url, fill the well with the topic word (e.g. "science") in
lowercase Newsreader on a soft topic-tinted field, color-coded per topic
(science/tech/environment/health/community/culture/world/space + neutral
default). Same 5:4 footprint as the photo, so card height stays consistent.
Threads `topic` through the news object.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 22:05:41 -04:00
thejayman77 022908392b /onthisday: IN HISTORY +1px (final landing)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:38:13 -04:00
thejayman77 998e758614 /onthisday: drop "IN HISTORY" 2px to land it (final)
top: calc(0.2 * var(--ys)) → + 2px.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:36:16 -04:00
thejayman77 14c2648f8f /onthisday: tie "IN HISTORY" offset to the year size (stop the oscillation)
6px read high, 12px read low — instead of guessing another px, anchor the label
to the visible cap top via top: calc(0.2 * var(--ys)). ~9px at the 46px desktop
year, scaling down on mobile. Still absolute, so the baseline is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:30:17 -04:00
thejayman77 fe0c2988c2 /onthisday: lower "IN HISTORY" to the visible top of "2013" (cap-offset fix)
Per Codex: top clamp(4px,0.7vw,6px) → clamp(9px,1.5vw,12px). Label is absolutely
positioned, so this only moves it down ~6px to meet the numerals' visible cap —
the year/date baseline and the rest of the row are untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:25:22 -04:00
thejayman77 883c37b428 Joy cards finalize (Codex pass): robust year align, image guard, a11y, honesty
/onthisday:
- Year alignment restructured per Codex: "2013" is the sole in-flow baseline
  anchor; "IN HISTORY" is absolutely positioned in reserved start-padding, so it
  can't drag the shared baseline (no more viewport-math/top-offset compensation).
  Rule baseline-aligns too. Narrow phones deliberately wrap the year lockup onto
  its own row (rule+date below) instead of accidental flex wrapping.
- Render the hero only when image_url exists (pool has imageless items → was a
  blank dark hero).
- "The story" → "A little context": the Wikipedia summary is context about an
  associated subject, not a narrative of the event. Honest without backend work.
- Figure detection also forces contain on filename hints (seal/flag/logo/map/
  diagram/crest/emblem/coat-of-arms) so JPEG logos/maps aren't cropped.

A11y contrast (AA): clay #b06a45→#9a5a38 (eyebrow, button, + homepage card
accent so they still match), small green IN HISTORY #3a7d5b→#367653, rose
attribution/share + Copy button → #8b596d.

/quote: native Share now carries the attributed text (author included, matching
Copy); narrow-phone guard so "QUOTE OF THE DAY" + eyelash don't crowd at 320px.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:10:11 -04:00
thejayman77 36b3df5d40 /onthisday: nudge year alignment + clay Read-more button
- Raise "2013" 2px toward the line; lower "IN HISTORY" ~3px (offset constant
  8→11) so its top sits flush with the numerals instead of riding high.
- Read-more button green → clay (#b06a45) to match the "On This Day" title, per
  Jay giving each card its own character.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:36:25 -04:00
thejayman77 719a2c5052 /onthisday: baseline-align "2013" with the date so it sits on the line
The chaos was align-items:flex-end + a tight 0.86 line-height letting "2013"
hang below the date's line. Switch the dateline to align-items:baseline so the
year and "Friday, June 26" share one baseline; raise "IN HISTORY" with a
post-layout relative offset (responsive via a --ys var) so it cap-aligns to the
top of the numerals without dragging the baseline. Rule sits at the baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:30:39 -04:00
thejayman77 2e43766d71 /onthisday: cap-align "IN HISTORY" to the top of "2013" + tighten the gap
margin-top 4→9px (text top now level with the numerals' cap) and gap 11→7px
(pulled a touch closer to 2013).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:24:49 -04:00
thejayman77 aa8ee674d5 /onthisday: year back to green, "IN HISTORY" top-aligned beside big "2013"
Per Jay: only the eyebrow was meant to go clay — the year stays GREEN to match
the date (both greens on the line tie in the green Read-more button). Layout is
now "IN HISTORY" top-aligned to the left of a large Playfair "2013", not stacked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:19:53 -04:00
thejayman77 d969810c10 /onthisday: figure-vs-photo hero, plain stacked year, clay title, more space
- Hero now adapts: photos stay full-bleed (cover); seals/logos/diagrams (PNG/SVG
  or near-square / extreme aspect) show WHOLE on a light matte (contain) instead
  of cropping to nonsense — e.g. today's SCOTUS seal.
- Year: dropped the mint chip/bubble. Now plain stacked type — "In history" over
  a larger "2013" — in the homepage card's brown family (kills the green/mint
  clash; makes the page echo the card).
- Eyebrow recolored to the homepage "On this day" card accent (#b06a45 clay).
- Added breathing room between the title and the content.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:16:14 -04:00
thejayman77 e2e59bfdc4 /onthisday: move the year off the image into a green "In history" chip
Overlaying the year on an uncontrolled Wikimedia image was a legibility coin
flip (fine on dark photos, cluttered/unreadable on bright seals/logos like the
SCOTUS seal). Per Jay's call, lift "In history · {year}" out of the photo into a
green chip on the dateline row (year in Playfair) and drop the scrim so the hero
reads clean. Dateline wraps on narrow screens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:51:03 -04:00
thejayman77 783b853aee joy cards: restore eyelash title to mockup size + darken the micro-labels
- Eyelash title (On This Day / Quote of the Day) was rendering ~16px; the mockup
  is 22px (CD sized it up for prominence). Bumped to clamp(17px,2.4vw,22px).
- "The story" / "What it means" micro-labels were the mockup's light tan
  (#a89880) — low-contrast on the cream card. Darkened to #74633f for readability.
Applied to both /onthisday and /quote so the "letter" family stays consistent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:42:52 -04:00
thejayman77 cf018dc36d /onthisday redesign: CD's green-key "letter" with hero + year overlay
Rebuilt /onthisday to CD's On This Day design — the QOTD "letter" language in a
green key: deckle frame, "On This Day" eyelash title, a flush dateline rule, a
hero image with a left scrim and the year overlaid (Playfair), the event in
Playfair italic, a "The story" note, and a "Read more on Wikipedia" pill. Wired
to live /api/onthisday/today; hero sits on a deep-green backdrop so transparent
seal/logo images never read as broken. HubShell (bar + Back + footer + icon).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:58:48 -04:00
thejayman77 ba0838dd94 home3: rename the small-joys OTD card "A good thing today" → "On this day"
Names the ritual (this date in history) rather than describing it; matches the
/onthisday page + engine. Hero tag becomes "{year} in history" (the old
"ON THIS DAY" there was now redundant with the eyebrow).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:54:41 -04:00
thejayman77 50488a1885 /quote redesign: CD's "letter" QOTD (deckle frame, wax-seal date, watermark)
Rebuilt /quote to CD's QOTD v3 — a warm letter card with a dashed deckle frame,
an overlapping wax-seal date stamp, a giant faded quote-mark watermark, the quote
in Playfair Display italic, attribution, a "What it means" note, and Copy/Share.
Wired to live /api/quote/today (date → seal, text, author/work, meaning).

Uses our HubShell (HubBar + Back + footer + account icon) per the brief. Self-
hosted Playfair Display (roman+italic, OFL) for the signature quote/watermark/
seal; Newsreader for the body serif + Hanken for sans labels (kept to our stack
to avoid font sprawl). Rose accent matches CD (#a4607a family).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 11:46:28 -04:00
thejayman77 ecfc49eda0 Codex audit fixes: home3 read-time, /art OG/canonical, Bloom medallion centering
- home3 dropped source_read_minutes when mapping the brief item, so the badge
  only ever showed "1 min brief" — include it so "· ~N min full story" appears.
- /art is public but shipped the homepage title/canonical/OG (SSR-off shell, so
  svelte:head can't fix scrapers) — add /art to patch-static-heads.mjs.
- Bloom: center the circular motif in the tile's visual field (~43%) instead of
  the top illustration zone, so it reads as a medallion (per Jay + Codex).
- Polish: prefers-reduced-motion disables the tile hover-lift; fix stale
  patch-play-head.mjs comment reference.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:54:57 -04:00
thejayman77 123602dc52 Revert "/play Bloom: enlarge the flower so it fills the tile (it was already centered)"
This reverts commit 3dc72b1d31.
2026-06-25 20:47:09 -04:00
thejayman77 3dc72b1d31 /play Bloom: enlarge the flower so it fills the tile (it was already centered)
Last change recalculated to the same position (so it looked unchanged) — the
flower WAS centered, just small/floaty vs the denser word/match grids. Enlarge
the petals + ring (bigger radius) so it carries the same visual weight, still
centered above the title.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:45:31 -04:00
thejayman77 a74a363728 /play Bloom motif: center the flower vertically above the title (match other tiles)
Bloom used a hardcoded % anchor so it read top-heavy with a gap below. Wrap the
petals in a fixed-size .bloom-ring and flex-center it in the space above the foot
(padding-bottom clears the title) — robust, no guessed percentage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:41:10 -04:00
thejayman77 60a1b50376 /play polish: center motifs, fill Daily Word 2nd row, tighten bubble aim
- Motifs (Word/Word Search/Memory Match) now vertically center in the space above
  the title bar (was top-anchored with dead space); Bloom flower nudged to match.
- Daily Word: second row filled (BYTE / CALM) so it's as lively as the others.
- Bubble Blaster: aim line now starts at the shooter (no gap) and the cluster has
  a single blue target bubble matching the shooter (others recolored) so the
  matching-shot read is clear.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:29:34 -04:00
thejayman77 5a7c90e7be /play: bubble shooter to the right aiming a blue center ball; space under top bar
- Bubble Blaster: shooter moves right (clear of the title), a diagonal dashed aim
  line runs up-left to a now-blue bubble at the cluster center (shooter + target
  share the blue — reads like lining up a matching shot).
- Add breathing room between HubBar and the Play header (arcade-head top margin).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:16:48 -04:00
thejayman77 f6ae44e126 /play motifs: shrink word-search + memory-match, smaller/raised bubbles w/ angled aim
- Word Search grid: smaller fixed cells, centered, sits clear of the title.
- Memory Match: smaller tiles (capped, centered) so the motif isn't oversized.
- Bubble Blaster: smaller bubbles raised into a tighter cluster (clears the
  title), shooter a touch smaller, and the aim line is now a diagonal dashed
  line tilted up — reads like the user is aiming.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 20:05:21 -04:00
thejayman77 aa15cf119c /play arcade tweaks: Back in header (right), real word-search grid, packed bubbles
- Back moves into the header row, right-justified and level with "Play" (the
  in-game step-back row stays for select/play views).
- Word Search motif is now a real 6×5 letter grid with BYTES "found" down the
  diagonal — reads as a search, not a single highlighted word.
- Bubble Blaster motif is a packed bubble cluster + dashed aim line + loaded
  shooter (like the actual game), not an evenly-spaced grid of balls.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 19:50:24 -04:00
thejayman77 6348835099 /play: arcade-tile hub redesign (presentation only; routing untouched)
Per CD's "Play - Arcade" + Codex's discipline note — replace just the hub
presentation; game selection/routing/URL-state/back all preserved.

- Colored game tiles, each with a motif of itself (Daily Word letter grid, Word
  Search letter rows, Bloom flower wheel, Memory Match dots), name + a calm daily
  clue on a gradient foot. Whole card tappable + a slight spring hover-lift (no
  hover-only CTA — works touch-first). Eyelash + Newsreader "Play" header.
- Bubble Blaster shown as a "Coming soon" ribbon tile (gentle bubble-pop puzzle,
  to build later); a "More coming soon" tile signals the area keeps growing.
- REMOVED from /play: the "Today's calm set" panel and the Zen Den tile. No
  dashboard/XP/streak/"played today" — Play stays pure games. Progress/achievements
  move to the opt-in /zen later (zen-reframe), done surgically.
- Fonts now match the rest of the hub (Newsreader / Hanken / Space Mono). Back
  button already present. Responsive: 3 cols → 2 (≤880) → 1 (≤560), no h-scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:32:19 -04:00
thejayman77 485c4a7805 WOTD: commit cached pronunciation clip for 'beauty' (matches tracked audio)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 13:23:02 -04:00
thejayman77 89987b8316 /art: thickness affects rotated fullscreen; collapsible writeup on mobile
- Fullscreen (rotated landscape view) ignored the thickness slider because I'd
  hard-coded the frame rail/mat to fit after rotation. Let them scale with
  thickness again and pull the image's short-edge cap in (60vw) so the whole
  frame still fits even at max thickness.
- Mobile: the writeup is now a collapsible "About this piece" accordion between
  the artwork and the controls (open by default). Collapse it and the frame /
  thickness / palette controls rise up beside the artwork, so you can see frame
  changes live. Desktop unchanged (writeup always shown in the left column).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:09:28 -04:00
thejayman77 420150a532 /art mobile: fix horizontal overflow (controls width:100% + margins)
The mobile controls card kept its base width:100% and also got side margins,
so 100%+margins overflowed the viewport → sideways scroll. Set width:auto
(flex stretch fills minus margins) + box-sizing:border-box, and add
overflow-x:clip on .room as a seatbelt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:54:10 -04:00
thejayman77 9c41da5fcc /art desktop: fix CTA overlap, widen card for a bigger piece, darken labels
- .body wasn't a flex container, so the "View at The Met" CTA's align-self was
  ignored and the inline link collapsed over the metadata. Make .head/.body flex
  columns → CTA sits correctly below the collection/rights.
- Widen the /art desktop card (max-width 1052→1280, art column 46→50%, tighter
  padding) so the framed piece is properly sized and the frame reads in better
  proportion — /art intentionally breaks the hub's 1180 column since the artwork
  is the point and mobile has its own layout.
- Darken the Space Mono micro-labels (Frame / Thickness / Colors / Collection /
  Rights) so they stand out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:31:20 -04:00
thejayman77 5157c221ed /art redesign: "The Story" editorial page (writeup + palette + frame + share)
Rebuilt /art to CD's "The Story" hybrid: a warm-tan editorial card with the
Daily-Art purple accent. Left = guide write-up (kicker · title · attribution ·
the museum-guide blurb · Collection/Rights · View at The Met). Right = the framed
piece (our existing frame + thickness customizer, on one line each) on a deeper-
tan ground, then a divider, the "Colors in this piece" palette, and Share /
Download (Download gated on public-domain license; image is same-origin so it
just works). Self-hosted Space Mono for the curatorial micro-labels; Newsreader
display + Hanken UI like the rest of the hub.

Mobile: the left/right wrappers collapse (display:contents) so the blocks reflow
to head → artwork → writeup → controls — the art sits high, seen before it's read.
Kept HubBar, the Back button, and the fullscreen lightbox (incl. landscape rotate).
Save-to-account + framed-composite export deferred to Phase 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:22:06 -04:00
thejayman77 ed814c97b9 Daily Art engine: museum-guide blurb (grounded LLM) + extracted palette
- daily_art gains blurb + palette columns (idempotent migration).
- art._palette: Pillow median-cut to ~5 hex colors from the cached image (best-
  effort → [] on any failure). art._blurb: a warm 2-3 sentence "what you're
  looking at" note grounded in the Met catalogue (title/artist/bio/date/medium/
  classification/culture/tags). Prompt leans on context/significance and the
  title+tags for subject — explicitly NOT asserting literal composition (figure
  counts/poses) it can't see, since the model can't view the image. Markdown
  stripped from the output.
- pick_daily generates both (client optional → blurb skipped when absent); cycle
  + art CLI pass an LLM client. /api/art/today exposes blurb + palette.
- Backfilled the last 3 days on host (Veteran / Magnolia Vase / Bierstadt).
- scripts/art_blurb_palette_backfill.py for in-place backfill (no re-pick).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:12:54 -04:00
thejayman77 79ecb800af home3 News card: desktop gist fills to equal height (basis:0 fade), right never stretches
Desktop: the gist flex-fills the card with flex-basis:0 so it never inflates the
row height — the right column always sets it and is never stretched — and fades
softly into a comfortable margin above the read-time. Mobile keeps the clean
3-line clamp (natural height, no fade).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:37:40 -04:00
thejayman77 44610703c2 home3 News card: hug content (no stretch gap) + tighten mobile brief gap
- Desktop: .news align-self:start so it sizes to its content instead of stretching
  to the right column's height — the "1 min brief" footer sits just under the gist
  rather than floating at the bottom of a tall card. Right column sets row height;
  bento stays tight.
- Mobile: tighten the gap under the gist before "1 min brief" (was ~a full line).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:29:40 -04:00
thejayman77 376f6263fd home3 News card: clamp the gist (3 lines mobile / 4 desktop), pin Read-more bottom
It's already a summary, so show only a taste and let the reader click through.
This also stops the News card being the tallest element, which had stretched the
right column and spread its cards — they pack naturally again. Footer (brief ·
read-more) pinned to the card bottom via margin-top:auto.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 16:07:05 -04:00
thejayman77 8dc22afbdc home3: fix full-width regression — don't let the containment rule override .bento max-width
The min-width:0/max-width:100% containment rule included .bento, and being a later
rule of equal specificity it overrode .bento's own max-width:1180px → grid went
edge-to-edge. Scope the rule to the grid items + flex chain (.news/.rightcol/
.pair-wrap/.joys-shelf/.joys) instead; .bento keeps its 1180px cap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 15:57:51 -04:00
thejayman77 5657494988 Fix joys regressions + self-host Work Sans for cards (Codex audit)
- Restore desktop/tablet two-up Small Joys (arrows rotate which two show); the
  swipe rail is now phone-only (≤520). One isNarrow flag drives both the DOM
  (2 vs 3 cards) and the layout, so they can't disagree.
- Contain the overflow that let the phone rail widen the whole page: min-width:0
  + max-width:100% down the chain (bento/rightcol/pair-wrap/joys-shelf/joys),
  mobile bento column to minmax(0,1fr), and overflow-x:clip on .page as a seatbelt.
- Read-time badge: overflow-wrap:anywhere + line-height so a long
  "1 min brief · ~N min full story" never causes width pressure.
- Self-host Work Sans (latin variable woff2, OFL) and apply it to the cards —
  the bolder/darker look from CD's mockup; headings stay Newsreader.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:57:55 -04:00
thejayman77 1c6c907f7f home3 + play: CD-guided card polish, scrollable Small Joys, consistent Back
- /play: hub view now shows a "Back" row under HubBar too (leaves Play to where
  you came from), so every hub page has the same bar+back rhythm — no shift.
- Eyelash on every card: each .label now opens with a short accent dash (via
  currentColor, so each label's colour drives it) + tighter tracking (.18em, 600),
  on desktop and mobile alike.
- Card headings to Newsreader 500 (calmer), body to 15px/1.5 #5a5346 (per CD).
- Small Joys is now a single swipeable row (all 3 cards in a scroll-snap rail, the
  next peeking; arrows scroll, counter tracks the snapped card) instead of a
  2-up rotation — more elegant, and the 1/3 ‹ › affordance is honest.
- Daily Art card: more breathing room before "View today".

Note: used our self-hosted Hanken Grotesk for the card sans (CD's mockup used
Google Work Sans) — same metrics applied; can self-host Work Sans if preferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:41:04 -04:00
thejayman77 f0e02b40e5 One consistent top bar across the whole hub (HubBar everywhere)
There were three different top bars (home3/detail vs /play vs /art). Unify on the
shared HubBar:
- HubBar is now fully self-contained (own @font-face + hardcoded hub colors) so it
  renders identically regardless of the host page's tokens/fonts.
- /art: dropped its bespoke gallery bar for <HubBar active="art" />.
- /play: dropped its bar for <HubBar active="games" />; the contextual in-game
  step-back (Game Selection / Play Hub) moves into the page body as a secondary
  ".gameback" control, shown only in game views (the global nav is in HubBar).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:11:18 -04:00
thejayman77 8a79b14c80 /art: add a Back button (matches the hub's single-history behavior)
Daily Art had no Back. Add a top-left "‹ Back" in its own gallery bar styling:
history.back() when arrived via in-app nav, else goto('/home3', {replaceState})
on a cold deep-link — same rule as the hub detail pages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:38:54 -04:00
thejayman77 eaeb8d3139 WOTD Listen button: always blue, no sticky hover or tap-highlight box on mobile
- Filled blue by default (a little colour on a calm page) instead of outline-fills-
  on-hover, which on touch "stuck" lit until you tapped elsewhere.
- Hover gated to (hover: hover) so it only darkens on real pointers (no sticky
  mobile hover); :active gives a press shade everywhere.
- -webkit-tap-highlight-color: transparent + user-select: none kill the selection/
  highlight box mobile drew on tap.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:29:08 -04:00
thejayman77 1150ae916b /art rotate: keep the whole frame on-screen (short-edge cap + modest fixed frame)
The rotated fullscreen clipped the top rail: the image's short edge plus rail/mat
exceeded the phone's narrow width. Cap the short edge (max-height pre-rotation),
use a modest fixed rail/mat in this view so the moulding always fits regardless
of the thickness slider, and hide the (now-sideways) caption — the placard is on
the page anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:22:17 -04:00
thejayman77 06dd293da0 /art: landscape paintings rotate to fill the screen in fullscreen on portrait phones
Conditional 90° rotate of the fullscreen stage (frame + caption together): only
when the artwork is landscape AND the screen is a narrow portrait phone — so a
wide painting fills the long axis (turn phone to view level, tap to close).
Portrait/square art and desktop stay upright. Landscape detected from the loaded
image's natural dimensions; CSS decides WHEN via (orientation: portrait). Image
caps use swapped vh/vw units post-rotation so the whole framed piece always fits.
No screen-orientation lock API (unreliable on mobile web) — pure CSS transform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:15:56 -04:00
thejayman77 4720ded29c Mobile combing: WOTD watermark, Entertainment card breathing, Art lightbox fit
- WOTD /word: watermark was nudged too far down last round; bring it back up to
  sit JUST below the date (clears it by a few px instead of dropping markedly).
- home3 Entertainment card: stacked on mobile it lost the height it borrowed from
  Play on desktop and felt crowded — add vertical breathing (padding + gaps), not
  as tall as desktop.
- /art lightbox: on a narrow portrait phone the framed piece (image + mat + rail)
  overflowed the viewport (rail clipped → looked distorted). Cap the image so the
  WHOLE frame fits, leaving room for the caption.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 09:06:36 -04:00
thejayman77 cf66d99e21 home3 News card: "1 min brief · ~N min full story" badge
Per the convo with Codex: keep the value proposition on the card rather than
hiding everything when full-story time is unknown.
- summary + full-story time → "1 min brief · ~10 min full story"
- summary only → "1 min brief"
- no brief/summary → hide the row
"brief" over "summary" (more editorial); full-story time still never faked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 08:44:40 -04:00
thejayman77 dc23277b38 Read-time: full-article "Full story · ~N min" badge (Option B)
Replaces the gist-based read-time with the SOURCE article's full read time — the
contrast that sells the gist ("calm 1-min version here; ~10 min for the deep dive").

- goodnews/readtime.py: word_count_from_html (strips script/style/nav/header/
  footer/form/button/aside furniture before counting) + source_read_minutes
  (~225 wpm, 200-word floor, None when extraction looks failed/too thin).
- articles.source_words + read_checked_at columns (count only, never the body;
  fits the privacy posture). Idempotent migration.
- enrich.fetch_source_words + enrich_read_times: a bounded, retry-guarded cycle
  step (mirrors the image enrichers) that counts words for recent accepted
  articles. Only ever writes a real count; never overwrites good with zero. Wired
  into the cycle after recent-image enrichment.
- queries: source_words flows through _ARTICLE_COLUMNS; api exposes
  source_read_minutes on Article (null when unknown).
- home3: News card shows "Full story · ~N min", hidden entirely when null (no
  misleading "1 min").
- Tests: furniture stripping, threshold/rounding, enrich idempotency + no
  zero-overwrite, API null handling. 412 backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 08:09:00 -04:00
thejayman77 bdf3b1f47b WOTD _polish: enforce the "examples must use the word" contract
Per Codex audit: only accept a polish when there's a gloss AND at least one
example sentence that actually contains the word (case-insensitive). Examples
that don't use the word are dropped; if none remain, fall back to the raw
dictionary def/examples instead of shipping a gloss with empty/irrelevant usage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 07:56:22 -04:00
thejayman77 26b23a8f09 #6: computed News read-time + per-page OG/canonical for hub detail pages
- News card "3 min read" → computed from our own gist (~200 wpm, floor 1). We
  summarize, so it's honestly ~"1 min read" — the good news in about a minute.
- Generalized the build-time head patch (patch-play-head → patch-static-heads):
  now also rewrites build/word.html, quote.html, onthisday.html so each ships
  its own <title>/description/canonical/OG/Twitter tags instead of the homepage
  head + canonical="/". Non-JS scrapers and canonical dedup are correct before
  these pages are ever un-noindexed. Same fail-loud guard as before.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 06:51:40 -04:00
thejayman77 ccdc764b2b WOTD /word polish: larger IPA + part-of-speech label, watermark clears the date
- IPA pronunciation bumped to 23px (it reads small at body size).
- Vertical part-of-speech label 11px -> 13px and darkened (#7fa0bd -> #5f86a6).
- Watermark nudged down so the glyph's top curve no longer overlaps the date.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 06:40:00 -04:00
thejayman77 c809594b43 WOTD: one-off polish backfill script (migrate + gloss/usage for existing pool)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 06:11:07 -04:00
thejayman77 cebbed58ab WOTD #4/#5 content quality + Editorial Asymmetric /word page (CD)
Content quality ("LLM polishes, dictionary anchors"):
- New wotd._polish: rewrites the real dictionary gloss into ONE warm plain
  sentence + two clear everyday example sentences, grounded in the real
  definition (no invented meanings). Stored in new wotd_pool/daily_wotd columns
  gloss + usage, alongside the raw definition/examples which stay the anchor.
- harvest() polishes each new word; pick_daily() lazily polishes + caches back
  any older pooled word that lacks a gloss (client threaded through run_daily).
- Admin word-add polishes on insert; re-pick passes an LLM client so quote
  meaning / word gloss fill on a forced fresh pick.
- /api/word/today now prefers gloss + usage, falling back to the raw dictionary
  def/examples when polish is absent (so it's always safe).
- db._migrate adds gloss/usage to wotd_pool + daily_wotd (idempotent ALTER).

Frontend — /word redesigned to CD's "Editorial Asymmetric": faded oversized
initial bleeding off the right, vertical part-of-speech rail, big Newsreader
word, airy definition, left-ruled italic example sentences, outline Listen
button + date. (Uses our self-hosted Newsreader/Hanken stack rather than the
mockup's Google fonts; the made-up syllable respelling is omitted since we only
have real IPA.)

Tests: _polish parse/trim/cap, harvest stores gloss/usage, pick lazy-polishes
older words, admin gloss flows through to /api/word/today. 403 backend + 27 fe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 06:08:14 -04:00
thejayman77 e5f3d942e2 hub: back-button replaceState trapdoor + hamburger resize-safety
Codex audit polish:
- Cold deep-link Back now goto('/home3', { replaceState: true }) instead of
  pushing a new entry, so the browser Back from the hub can't bounce the reader
  straight back into the detail page. In-app arrivals still history.back().
- HubBar closes the hamburger when crossing to desktop width (matchMedia change),
  so `open` can't go stale and reappear if the viewport shrinks back to mobile.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 05:56:46 -04:00
thejayman77 d3c7282850 hub detail pages: shared Back button with proper single-history
#3 Back buttons: HubShell now renders a top-left "‹ Back" on each detail page
(/word /quote /onthisday). It mirrors the News reader's rule — if you arrived by
an in-app navigation, history.back() returns you to the hub with state/scroll
intact (and the browser Back button traverses the same single history); a direct
deep-link has no app history, so it falls back to goto('/home3'). Opt-out via
back={false}; relabel via backLabel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 22:08:39 -04:00
thejayman77 b174d8d2a7 home3/hub: shared HubBar with mobile hamburger + Art-card mobile crop fix
#2 Mobile top bar → hamburger: extracted the editorial bar (brand + nav + new
collapsible hamburger drop-panel) into a shared lib/components/HubBar.svelte,
used by both /home3 and HubShell (the /word /quote /onthisday detail pages), so
there's one nav to maintain/audit. Full horizontal nav ≥721px; hamburger + drop
panel ≤720px. Escape + link-click close it; panel is hidden on desktop as a
safety. Removed the duplicated bar markup/CSS from home3 + HubShell.

#1 Mobile layout / Art card: on phones the Art card now stacks image-first with
the painting in a proper 3:2 frame (aspect-ratio) instead of a stubby fixed
130px band that cropped the work to a sliver. Also drop the News gist's bottom
fade once the bento is single-column (natural height = no truncation, so the
fade was just dimming the final line), and let the joys header wrap on phones.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:55:36 -04:00
thejayman77 2e9d81bd86 WOTD: commit cached pronunciation clip for 'repose' (matches tracked audio)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 21:00:01 -04:00
thejayman77 84b1fb514f Small joys: Codex audit #2 fixes (route resolution, noindex, sense/tone, exclude-current re-pick)
- Admin joy item route moved to /api/admin/joys/{kind}/items/{item_id} so the
  /add and /repick verbs resolve to their own routes instead of 422-ing as a
  non-int item id (the launch blocker). Frontend mutate URL updated to match.
- Re-pick now excludes the currently-shown item: the endpoint reads today's
  daily pool_id and passes it as `avoid`, so "Re-pick today" yields a different
  item. Added `avoid` to pick_daily/_candidates across wotd/quote/onthisday.
- WOTD sense selection: the LLM now proposes word + intended part of speech, and
  _lookup prefers that sense (fixes "serene" returning the archaic noun).
- On This Day tone prompt tightened to favor genuinely uplifting events and
  exclude merely procedural/political-administrative ones.
- Caddy @hidden now also noindexes /word /quote /onthisday /admin (+ .html).
- Regression tests: add/repick resolve (401 not 422), add/feature/block/delete,
  re-pick excludes current; WOTD pos-preference + proposal parsing units.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 20:19:02 -04:00
thejayman77 3bde6534e9 Small joys: wire homepage rail to live data + rich pages (/word /quote /onthisday) + admin
- /home3: small-joys rail now reads live /api/word|quote|onthisday/today (placeholders only
  as fallback); each cell links to its detail page.
- HubShell component (shared bar/footer/fonts/tokens) for the hub + detail pages.
- /word: big word, IPA, Listen (cached clip + browser-TTS fallback), definition, sentences.
- /quote: the quote, attribution, and the AI "what it means".
- /onthisday: the date, year + fact, image, summary, source.
- Admin "Small Joys" tab: per-pool list with feature/block/delete/add + re-pick, for all
  three kinds. New admin API: GET/POST /api/admin/joys/{kind}[/{id}|/add|/repick].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 18:52:38 -04:00
thejayman77 67d4bc32cb Small joys: Quote of the Day + Word of the Day engines
- quote.py: curated public-domain quote pool (16 seeded, admin-grows), deterministic daily
  pick, lazy AI "what it means" explainer of the real quote (cached). No LLM-invented quotes.
- wotd.py: LLM proposes positive words → validated/enriched against dictionaryapi.dev (real
  definition, IPA, examples, audio) → audio clip cached to our origin (TTS fallback) →
  deterministic daily pick. Tops the pool up toward 30/day.
- db.py: quote_pool/daily_quote + wotd_pool/daily_wotd tables.
- api.py: /api/quote/today, /api/word/today, /api/word/audio/{word} (GET+HEAD).
- cli.py: cycle steps for both (under --no-joys), shared LLM client.
- tests: test_quote.py (6) + test_wotd.py (5). 393 backend tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:28:55 -04:00
thejayman77 a7da8362ab Small joys backend: shared daily framework + On This Day engine
- goodnews/daily.py: shared helpers for the daily "small joys" (http_json, date-seeded
  deterministic pick, dedup key) so each joy is a small self-contained module.
- goodnews/onthisday.py: harvest today's MM-DD from Wikimedia's On-this-day feed →
  tone-filter to good/neutral (keyword floor + optional LLM refine) → pool → deterministic
  daily pick (idempotent, respects blocked/featured) → cached row. Network/LLM before any
  DB write. Multi-source ready (source column).
- db.py: onthisday_pool + daily_onthisday tables.
- api.py: GET /api/onthisday/today (edge-cacheable).
- cli.py: cycle step (run after Daily Art; --no-joys to skip), LLM client for tone refine.
- tests/test_onthisday.py: 7 tests (filter+dedup, pick idempotent, blocked/featured,
  never-empty, empty-pool, LLM-narrow). 382 backend tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:51:29 -04:00
thejayman77 4739d87f4b caddy: X-Robots-Tag noindex,nofollow on /home2 + /home3 (HTTP-level, for non-JS crawlers)
Per Codex audit — the JS <meta robots> isn't seen by bots that don't run JS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:59:30 -04:00
thejayman77 ea85e9cdfb home3 news footer: 3-min-read back to the right; 'Read more good news' is blue now
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:50:41 -04:00
thejayman77 48a0cc1111 home3 news: remove redundant 'Read article' (block is clickable), keep read-time; darken footer divider
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 15:36:48 -04:00
thejayman77 147701e7dc home3 news: summary clickable (->article); drop Read-article underline, add divider before Read-more-good-news
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:48:04 -04:00
thejayman77 de53873b4f home3: Entertainment nav is a non-clickable soon item (drop dead href=#)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:39:46 -04:00
thejayman77 440e8391d6 home3: clickable news photo + headline (-> article); top bar = Home(active)/News/Games/Art/Entertainment
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:37:47 -04:00
thejayman77 d3017598a2 home3 Entertainment: icon tiles (palette/smiley/sparkle) instead of teaser boxes, centered motif
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 14:11:24 -04:00
thejayman77 18e950bbf7 home3 news headline: drop JS char-slice, use CSS line-clamp:2 (word-boundary, fills 2 lines)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 13:20:32 -04:00
thejayman77 319c5f6b7e home3 news figure matte: fade silvery->white downward so it melts into the card
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:37:56 -04:00
thejayman77 6925a6951b home3 news image: photos display full-bleed (cover); only wide/tall figures get the framed plate (aspect-detected on load)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:30:12 -04:00
thejayman77 390bf5f43d home3: news image as tinted-matte framed plate (CD); Entertainment uses blurred teaser boxes (no play button) + non-video copy
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:22:14 -04:00
thejayman77 1ffc9c6e92 home3: news image contain-on-matte (figures sit whole); Entertainment centered play-glyph empty-state
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:10:41 -04:00
thejayman77 8037e72f17 home3 joys: clamp all cards to WORD card's height (min-height 170, no squish); darken WOTD definition
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:57:26 -04:00
thejayman77 772cb47d69 home3 joys: tighten WORD card (word line-height:1, phonetics up+darker), lower shared min-height so all rotations match
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:43:15 -04:00
thejayman77 e38081733d home3: small-joys equal height + trimmed spacing + fresh palette (sky/rose/clay); news summary fills the room
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:11:09 -04:00
thejayman77 89352e7123 home3: small-joys rail -> rotating two-up shelf (option A: label + dots + arrows)
Two jewel cards at a time, reader-advanced (no auto-motion); 3 cells total, wraps. Keeps
each card at generous size instead of cramming three.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:58:55 -04:00
thejayman77 8cf061d214 home3: small-joys rail polished to 'little jewels' (CD spec — focal points, watermarks, accent tags, gradients)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:36:12 -04:00
thejayman77 3c519a5f50 home3: small-joys rail (WOTD/QOTD crafted + 'good thing today' SOON), whole-word headline clamp, 16px gaps
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:18:00 -04:00
thejayman77 2173802c70 home3: rename Daily Moment -> Entertainment; add skinny Word/Quote-of-the-day strip (placeholder)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:39:02 -04:00
thejayman77 de94d179bd home3: Art pinned top, Play/Moment pair vertically centered in the space below (FIX1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:14:37 -04:00
thejayman77 65cb770514 home3: Art pinned top, pair pinned bottom, space distributed between (FIX1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:11:02 -04:00
thejayman77 f4b20bdf79 home3: center the right column in the space (match FIX1), not top-aligned
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:08:08 -04:00
thejayman77 2e3cc5f382 home3: stop card stretch (independent right column, float not stretch); News gets two links (Read article + Read more good news)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 08:59:10 -04:00
thejayman77 5306c65c7b home3: consistent half-column bleed + equal-size side games on Play; FIX1 layout (fade-out gist, read-time meta, trimmed Art headline)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 06:33:57 -04:00
thejayman77 703ea22e0b home3 Play card: center word search, balance gaps, full wordle visible
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 06:06:44 -04:00
thejayman77 c95ca8775a home3 Play card: fit word-search panel + bolder BYTES, EAT/YTE tiles, richer amber
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 05:47:10 -04:00
thejayman77 a8762a1dd8 home3 Play card: fix Wordle tile colours (TYPE→BYTE, accurate green/amber)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 05:27:35 -04:00
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
128 changed files with 11766 additions and 1315 deletions
+4
View File
@@ -6,5 +6,9 @@ node_modules/
data/*.sqlite3
data/*.sqlite3-*
data/*.db
data/geo_audit*.json
logs/
data/art_cache/
data/img_cache/
tools/glb-split/incoming/
+2 -2
View File
@@ -1,8 +1,8 @@
# Upbeat Bytes
# upbeatBytes
Calm, constructive news — local-first ingestion, scoring, and a daily brief.
(The Python package and CLI are named `goodnews` for historical reasons; the
product is **Upbeat Bytes**, at upbeatbytes.com.)
product is **upbeatBytes**, at upbeatbytes.com.)
The first milestone is intentionally small: collect public RSS/Atom metadata, dedupe it, store short source-provided snippets, and attach early reason-coded heuristic scores. It does not store full article bodies.
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+29
View File
@@ -46,6 +46,22 @@ upbeatbytes.com {
encode gzip zstd
# Drop vuln-scanner probes for stacks we don't run. We're a SvelteKit SPA + FastAPI:
# zero PHP, no WordPress, no exposed dotfiles — so these paths can NEVER be a real
# user or a wanted search crawler (matching is path-only, never by User-Agent, so
# Googlebot/Bing are untouched). Without this they fall through try_files to the SPA
# shell and get a 200; now they get a clean 403 (still logged, so probes stay visible).
@junk path *.php /wp-admin* /wp-login* /wp-includes* /wp-content* /wp-json* /xmlrpc.php /.env /.env.* /.git /.git/* /phpmyadmin* /pma* /myadmin* /dbadmin* /vendor/* /.aws/* /.ssh/* /cgi-bin/* /administrator/*
handle @junk {
respond 403
}
# Retired prototype routes (promoted/removed at the news relaunch) → the hub.
@oldhome path /home2 /home2.html /home3 /home3.html
handle @oldhome {
redir https://upbeatbytes.com/ permanent
}
# Dynamic API + server-rendered pages (share, digest, sitemap) → FastAPI.
@api path /api/* /healthz /docs /docs/* /openapi.json /a/* /today /sitemap.xml
handle @api {
reverse_proxy upbeatbytes-api:8000
@@ -58,10 +74,21 @@ upbeatbytes.com {
root * /srv/sites/upbeatbytes
# Hidden in-progress prototypes — keep crawlers out at the HTTP level (the JS
# <meta robots> isn't seen by non-JS bots since the static shell is generic).
# Only admin stays out of the index now — news, art, play, and the joy pages are public.
@hidden path /admin /admin.html
header @hidden X-Robots-Tag "noindex, nofollow"
# Content-hashed assets never change for a given URL — cache them forever.
@immutable path /_app/immutable/*
header @immutable Cache-Control "public, max-age=31536000, immutable"
# Static texture + font assets — large/unchanging. Cache them forever like
# immutable assets; rename the file if one ever changes.
@assets path /textures/* /fonts/*
header @assets Cache-Control "public, max-age=31536000, immutable"
# The SPA shell: "/" and extensionless client routes (try_files → index.html).
# Briefly cacheable at the CDN edge (s-maxage) so a first paint never depends
# on this origin's uplink; browsers still revalidate every visit (max-age=0).
# A deploy propagates within ≤2min and old immutable chunks are kept for a
@@ -80,6 +107,8 @@ upbeatbytes.com {
@revalidate {
not path /_app/immutable/*
not path /textures/*
not path /fonts/*
path *.*
}
header @revalidate Cache-Control "no-cache"
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash
# Publish Upbeat Bytes: build the frontend, sync it to the live Caddy site,
# Publish upbeatBytes: build the frontend, sync it to the live Caddy site,
# rebuild/restart the API container, and reload Caddy. One command to redeploy.
set -euo pipefail
@@ -26,4 +26,4 @@ curl -fsS -o /dev/null -w ' logo-email.png → %{http_code} %{content_type}\n'
https://upbeatbytes.com/logo-email.png \
|| echo " ⚠ logo-email.png is not being served — the digest masthead would break!"
echo "✓ Published Upbeat Bytes → https://upbeatbytes.com"
echo "✓ Published upbeatBytes → https://upbeatbytes.com"
+14 -35
View File
@@ -1,51 +1,30 @@
#!/usr/bin/env bash
# Sync the built static site to the live root in an order that avoids deploy-race
# blank screens AND post-deploy slow loads. rsync isn't atomic, so a naïve
# `rsync --delete` can briefly serve a NEW index.html that points at chunks not
# synced yet (→ failed load), or delete old chunks an in-flight client still
# needs. And even once synced, a new shell can point at chunks that aren't warm
# at the CDN edge yet, so the unlucky first visitor fetches them cold from the
# (residential) origin — the post-deploy slow-load window. So the order is:
# 1. new hashed chunks first, and DON'T prune old ones (grace window)
# 2. WARM those chunks at the edge BEFORE the shell goes live, so a new shell
# never references cold chunks
# 3. other static assets (version.json, icons…), pruning removed files
# 4. the shell HTML — only once its chunks exist AND are warm
# 5. the service worker last — a returning client adopts it only after the rest
# 6. warm the shell + key routes after publish
# Old immutable chunks are pruned after a grace window to bound disk growth.
# NOTE: warming runs from this host, so it primes the Cloudflare POP nearest the
# origin (great for local/nearby users + our own post-deploy testing); a distant
# POP still cold-fills once on its first hit — inherent to a residential origin.
# blank screens. rsync isn't atomic, so a naïve `rsync --delete` can briefly serve
# a NEW index.html that points at chunks not synced yet (→ failed load), or delete
# old chunks an in-flight client still needs. So the order is:
# 1. new hashed chunks first, and DON'T prune old ones (14-day grace window)
# 2. other static assets (version.json, icons…), pruning removed files
# 3. the shell HTML — only once its chunks exist
# 4. the service worker last — a returning client adopts it only after the rest
# Old immutable chunks are pruned after the grace window to bound disk growth.
#
# NOTE: there is intentionally NO edge cache-warming. Cloudflare is DNS-only
# (grey-cloud) for upbeatbytes.com — no proxy/CDN/POP — so warming would just GET
# every asset from the residential origin (already µs-fast static): a no-op that
# generated lots of internal request volume. Removed 2026-06-29.
set -euo pipefail
src="$1"; site="$2"
base="https://upbeatbytes.com"
# Warm a newline-separated list of paths (on stdin) through the public domain.
# Best-effort: a warm miss must never fail the deploy.
warm() { xargs -P 8 -I{} curl -fsS -o /dev/null --max-time 20 "$base{}" 2>/dev/null || true; }
# 1. New hashed chunks first (old ones kept — 14-day grace for in-flight clients).
rsync -a "$src/_app/immutable/" "$site/_app/immutable/"
# 2. Warm the chunks BEFORE publishing the shell, so the new shell never points
# at chunks still cold at the edge (the post-deploy slow-load cause).
echo " warming new chunks (before publish)…"
find "$site/_app/immutable" -type f \( -name '*.js' -o -name '*.css' \) -printf '/_app/immutable/%P\n' | warm
# 3. Other static assets (prune removed files), then 4. the shell, then 5. the SW.
# 2. Other static assets (prune removed files), then 3. the shell, then 4. the SW.
rsync -a --delete \
--exclude='_app/immutable/***' --exclude='index.html' --exclude='service-worker.js' \
"$src/" "$site/"
rsync -a "$src/index.html" "$site/index.html"
rsync -a "$src/service-worker.js" "$site/service-worker.js"
# 6. Warm the shell + key routes + remaining static now that they're published
# (primes CF↔origin even where no-cache; caches the cacheable shell/routes).
echo " warming shell + routes (after publish)…"
printf '%s\n' / /play /account /admin /service-worker.js /_app/version.json \
/manifest.webmanifest /words-5.json /words-6.json /logo.svg /favicon.svg \
/icon-192.png /icon-512.png /fonts/inter-latin-wght-normal.woff2 | warm
# Bound disk growth: prune immutable chunks older than the grace window.
find "$site/_app/immutable" -type f -mtime +14 -delete 2>/dev/null || true
+77
View File
@@ -0,0 +1,77 @@
# Images & visitor metrics — policy of record
Encodes the decisions from the 2026-06-30 Codex audit. Update this doc when the policy changes.
## Article images — source-level rights policy
We do **not** blanket-cache publisher images. Caching (re-hosting a copy on our origin) is
**opt-in per source**; the safe default is to display the publisher's own image without copying it.
`sources.image_policy` (one of):
| policy | meaning | when to use |
|---|---|---|
| `cache` | Re-host a downscaled WebP (≤800px) at `/api/img/<id>` | ONLY sources we've cleared: open license (CC etc.), explicit permission, public domain, or our own/gov public-domain material |
| `remote` (**default**) | Hotlink the publisher's image URL (with the frontend's graceful retry) | Anything not explicitly cleared — display, never copy |
| `none` | No image (typographic topic cover) | Sources whose terms don't support display, or known-bad imagery |
**Default for new/unknown sources is `remote`.** Nothing is re-hosted until an admin sets a
source to `cache` (admin → Sources → expand a source → Images selector, or
`POST /api/admin/sources/{id}/image-policy {"policy": "cache"}`).
Why conservative: fair use is case-specific and balances four factors; "reduced + attributed +
linked" is good practice but **not permission**, and the search-engine-thumbnail precedent
(Perfect 10 v. Amazon) turned on a specifically transformative image-search function, not a
universal thumbnail exemption. So re-hosting waits on a per-source rights basis.
### How resolution works
- `newsimg.display_url(article_id, image_policy, raw_url)` returns the display URL: `/api/img/<id>`
for `cache`, the publisher URL for `remote`, `None` for `none`/no-image.
- Applied server-side in `Article.from_row` (feed/brief/history) and `share.py` (the `/a/<id>`
page). `og:image`/`twitter:image` always reference the **publisher's own** image URL (a link,
not a copy) — never our cached path — so social crawlers don't hit our endpoint.
- The frontend uses the resolved `image_url` as-is; the hub probe-retry + ArticleCard `onerror`
cover slow/failed loads for both cached and hotlinked URLs.
### Cache mechanics (`goodnews/newsimg.py`)
- **The cycle owns all fetching** (`warm()`), gated on `image_policy='cache'`, under the cycle lock.
`GET /api/img/<id>` serves **cache hits only** (never fetches), restricted to accepted + canonical
articles whose source is `cache`. No SSRF/worker-exhaustion surface on the public endpoint.
- `_safe_fetch`: http(s) only, `_host_is_public` on every redirect hop (HTTPError-based redirects
followed), body capped. 4xx≠429 = permanent (negative-cached via `<sha1>.fail`); 429/5xx/network = transient.
- `_encode`: decoded raster → WebP only; rejects SVG/undecodable; pixel ceiling enforced
(`w*h > _MAX_PIXELS`) **before** decode. Originals are never retained.
- Bounded: hard size cap (default 1 GB, `GOODNEWS_IMG_CACHE_CAP`) with LRU eviction; `.fail`
markers swept after `_FAIL_TTL_S`. `data/img_cache/` is gitignored (runtime data).
- **Revocation:** when a source leaves `cache` (set to `remote`/`none` in admin), the endpoint
calls `newsimg.purge_source()` to delete that source's re-hosted copies **immediately** — they
don't linger on disk. (Setting *to* `cache` just flips the flag; the cycle warms it.)
## Visitor metrics — Recorded visits vs Engaged readers
A JS-capable bot can trip the visit beacon, so the admin shows two numbers:
- **Recorded visits** — raw count: one daily `visit` beacon per device. Known-bot User-Agents are
filtered at `/api/events` (`queries.is_bot_ua`), but UA-spoofing bots still land here. Noisy.
- **Engaged readers** — distinct visitor-day with **deliberate** activity (the honest number):
- the gesture-gated `engaged` beacon (`analytics.armEngaged`, mirrored on the share page) — fires
once/day only after ~8s visible **and** a real scroll/pointer/key/touch; or
- a deliberate action: `source_click`, `full_story`, `share_ub`/`copy_source`/`native_share`,
`replace_used`, `paywall_replace`, `paywalled_source_open`, `not_today`/`less_like_this`/`hide_topic`,
or a game `started`/`completed`/`shared`.
- **Never** counts auto-fired `visit`/`summary_viewed`/`open`, `replace_none`, or game `*_arrival`.
- Defined by `queries.ENGAGED_EVENT_KINDS`; surfaced as `visitors.engaged_today/d7/d30`.
**Warm-up caveat:** the `engaged` beacon began **2026-06-30**, so rolling windows fill over time —
a low `engaged_d7`/`engaged_d30` is partly warm-up, NOT proof the gap to recorded visits was all
bots. Compare `d7` after a full week, `d30` after thirty days. (Admin shows this note inline.)
Privacy unchanged: only a salted `visitor_hash` is stored (no IP, no raw token, no fingerprint).
### Referrer suppression on remote images
Every on-site image request for a `remote` source sets `referrerpolicy="no-referrer"` so the
publisher CDN doesn't get the referring URL: article cards, the share page, AND the homepage hero
(converted from a CSS `background-image` to a real `<img>` — the retry probe sets
`probe.referrerPolicy='no-referrer'` too). This hides the *referrer*, **not** the visitor's IP —
any remote image necessarily exposes the IP to the CDN. For zero third-party image requests, the
source must be `none` or explicitly cleared for local caching (`cache`).
+103
View File
@@ -0,0 +1,103 @@
# News relaunch — link/redirect map + interim-routing plan
Scope: stand up `/news` (the feed's new home) and cut `/home3``/` (hub) without breaking
the feed, deep links, or SEO. Verified against the codebase 2026-06-28. Settled with the
user + Codex; amendments folded in. **Next build = the extraction (§A, pure refactor).**
## A. Interim routing — no broken window, no duplicated impl
The feed currently IS `routes/+page.svelte` (~1,065 lines: views, BottomNav, MoodNav,
LanePicker, SavedFlyout, search). Don't clone it. Extract once, mount twice.
1. **Extract** the feed UI from `routes/+page.svelte``lib/components/NewsFeed.svelte`
(verbatim move; URL base becomes `/news`). `parseView`/`urlForView` switch base `/``/news`.
2. **Mount at both** during transition:
- `routes/news/+page.svelte``<NewsFeed/>`
- `routes/+page.svelte``<NewsFeed/>` (interim; identical component)
One implementation, both routes live, zero interim breakage.
- **`/news` stays hidden during transition**: `X-Robots-Tag: noindex, follow` (so we don't
publish a duplicate indexable feed). Removed at cutover, when `/news` is added to the sitemap.
3. **Parity test** at `/news` (every view, deep link, Back/Forward, account action) — see §F.
4. **Restyle** `NewsFeed` once (CD editorial + reusable card — replaces `ArticleCard`).
5. **Behavior fix** (separate, deliberate, post-parity): Latest stays chronological; Highlights
gets prefs + geo scope.
6. **Cutover**: `routes/+page.svelte` → hub content (move from `home3`); add the legacy-query
redirect shim (§C); drop `/news` noindex + add to sitemap; `/news` stays the feed.
## B. Link/redirect map
Rule of thumb: **brand/logo/"home"/"back to UB" → `/` (hub)**; **anything "more news /
browse / a feed view" → `/news`**; **feed-internal view URLs → `/news?…` base**;
**`/home3` fallbacks → `/`**.
### B1. Brand / home / back → `/` (KEEP)
- `Header.svelte:9` brand `/`; `account:103` brand `/`, `:108` back `/`; `admin:678` brand `/`,
`:28` non-admin bounce `/`; `auth/verify:24` post-signin `/` (DECIDED: always hub; future
optional start-screen setting), `:38` back `/`; `zen:59` brand `/`;
`share.py:197,327` brand logo `/`, `:354` "Back to Upbeat Bytes" `/`; `api.py:781` unsubscribe back `/`.
### B2. News CTAs → `/news` (CHANGE)
- `home3:198` "Read more good news" `/`**`/news`** (the loop bug)
- `share.py:210` "Explore Upbeat Bytes →" → **`/news`**
- `share.py:332` "Browse more on Upbeat Bytes →" → **`/news`**
### B3. Feed-internal view URLs → `/news?…` base (CHANGE, via the extraction)
- `routes/+page.svelte` (→ NewsFeed): `urlForView` builds `/?source=`,`/?tag=`,`/?view=`
(`:39-41`), search `goto('/?q=')` (`:50`), clear `goto('/')` (`:689`), `urlForView('today')`
returns `/` (`:344,503`) → all rebase to `/news`.
- `account:196` Following `/?view=following`**`/news?view=following`**
- `share.py:30` source link `/?source={id}`**`/news?source={id}`**
### B4. `/home3` fallbacks → `/` (CHANGE)
- `HubBar:18` home nav `/home3`**`/`**; `:28` brand `/home3`**`/`**
- `HubShell:17` back fallback `goto('/home3')`**`/`**
- `art:41`, `play:213` back fallback `goto('/home3')`**`/`**
## C. Redirects
- **Legacy root-query shim** on the new hub `/` — runs **before the hub fetches/renders** (no
hub flash), `replaceState`:
- `/?view=today``/news?view=highlights`
- `/?view=latest``/news` (Latest is the default)
- `?tag=`, `?source=`, `?q=`, other `?view=` → carry across to `/news?…`
- `/news?view=today` remains an accepted **alias** (old links never break)
- **`/home2`, `/home2.html`, `/home3`, `/home3.html``/` permanent (301)** (Caddy). A bare
route delete would just serve the SPA fallback — redirect explicitly.
## D. Infra
- **Caddy `@hidden`** (currently `/home2 /home3 /word /word.html /quote /quote.html
/onthisday /onthisday.html /admin /admin.html`):
- REMOVE `/word* /quote* /onthisday*` (indexable at launch)
- `/home3*`,`/home2*` → 301 redirects (out of @hidden)
- **`/news` carries `X-Robots-Tag: noindex, follow` until cutover**, then removed
- KEEP `/admin*`; `/a/*` still routed to FastAPI
- **Sitemap** (`api.py sitemap()`): raise `LIMIT 5000` → ~50000; gate on having a real summary
(skip ~31 incomplete; ~+1,000 URLs); add static `/news` (at cutover) + `/art /play /word
/quote /onthisday` (keep `/`,`/today`). Fix **`HEAD /sitemap.xml`** (currently 404).
- **Head patcher** (`patch-static-heads.mjs`): add `/news` (title/desc/canonical/OG).
- **PWA description** (`manifest.webmanifest` + `app.html` description/og/twitter): currently
news-only — broaden to the hub (news + daily art + games + small resets).
- **Footer**: ONE shared `Footer.svelte` — consistent core (**motto + Send feedback**) + a
default **slot for per-section extras**. **`FeedbackModal` stays in the global layout**;
only the layout's `<footer.site>` *markup* is removed (and HubShell's `<footer.foot>`).
**Coverage inventory** — the shared footer must be explicitly added to every public surface:
**Hub, News, Play, Art, HubShell details, Account, Zen** (if dev-visible). Admin/auth get a
deliberate **minimal** treatment (explicit, not by omission).
## E. Behavior (deliberate, post-parity)
- **Latest** = newest accepted after safety/boundary exclusions; **stop passing `home`** (or add
an explicit "Local first" lane) so Latest ≠ local-first.
- **Highlights** = ranked around interests + geo scope dial (kept at launch).
- `trackVisit()` stays global; `markBriefSeen()` only inside Highlights.
## F. Cutover checklist (rehearsal first, hidden)
1. **GSC review** (coverage / manual-actions / crawl) BEFORE rehearsal.
2. **Parity** at `/news`: each view (today/latest/following/tag/source/search/mood/topic),
deep links, Back/Forward single-history, account actions (save/follow/hide/replace), PWA.
3. **Legacy redirects**: `/?view=today`→`/news?view=highlights`, `/?view=latest`→`/news`,
`/?tag=…`,`/?source=…`,`/?q=…`→`/news?…`; `/home2*`,`/home3*`→`/`.
4. **SEO**: `/` 200 + indexable + canonical; `/a/*` 200 + self-canonical (unchanged); `/today`
200 indexable; `/news` noindex dropped + in sitemap; joy/art/play noindex removed + in
sitemap; sitemap GET + HEAD 200.
5. **Caches**: anon Latest/Brief edge-cacheable (45s) intact; personalized private/no-store.
SW is a kill-switch (no bump) — just verify.
6. **Promote** → live 200/301/canonical/cache checks; resubmit sitemap in GSC.
+1 -1
View File
@@ -6,7 +6,7 @@
"scripts": {
"dev": "vite dev --host",
"build": "vite build",
"postbuild": "node scripts/patch-play-head.mjs",
"postbuild": "node scripts/patch-static-heads.mjs",
"preview": "vite preview",
"test": "vitest run"
},
-41
View File
@@ -1,41 +0,0 @@
// Post-build: give build/play.html its own social/canonical metadata.
//
// The app is a static SPA (ssr=false), so every prerendered shell ships app.html's
// HOMEPAGE <head> — meaning a shared /play link previews as the news homepage. Client
// svelte:head can't fix that for non-JS social scrapers (Twitter/Slack/iMessage/etc.).
// So we rewrite the static head of play.html here, at build time. Deep-linked variants
// (/play?game=…) are served the same file, so they inherit this games-hub preview.
import { readFile, writeFile } from 'node:fs/promises';
const FILE = new URL('../build/play.html', import.meta.url);
const URL_PLAY = 'https://upbeatbytes.com/play';
const TITLE = 'Play · Upbeat Bytes — calm daily games';
const DESC =
'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
'A friendly little break from the doomscroll.';
const subs = [
[/<title>[\s\S]*?<\/title>/, `<title>${TITLE}</title>`],
[/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${DESC}" />`],
[/<link rel="canonical" href="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<link rel="canonical" href="${URL_PLAY}" />`],
[/<meta property="og:title" content="[^"]*"\s*\/>/, `<meta property="og:title" content="${TITLE}" />`],
[/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${DESC}" />`],
[/<meta property="og:url" content="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<meta property="og:url" content="${URL_PLAY}" />`],
[/<meta name="twitter:title" content="[^"]*"\s*\/>/, `<meta name="twitter:title" content="${TITLE}" />`],
[/<meta name="twitter:description" content="[^"]*"\s*\/>/, `<meta name="twitter:description" content="${DESC}" />`],
];
let html = await readFile(FILE, 'utf8');
const missed = [];
for (const [re, repl] of subs) {
if (!re.test(html)) { missed.push(re.source.slice(0, 40)); continue; }
html = html.replace(re, repl);
}
// Fail loudly if the homepage head drifted — better a broken build than silently
// shipping the wrong /play preview again.
if (missed.length) {
console.error('patch-play-head: these head tags were not found (app.html changed?):\n ' + missed.join('\n '));
process.exit(1);
}
await writeFile(FILE, html);
console.log('patch-play-head: rewrote build/play.html head → /play metadata');
+84
View File
@@ -0,0 +1,84 @@
// Post-build: give specific prerendered shells their OWN social/canonical metadata.
//
// The app is a static SPA (ssr=false), so every prerendered shell ships app.html's
// HOMEPAGE <head> — meaning a shared /play or /word link previews as the news homepage,
// and its canonical points at "/". Client svelte:head can't fix that for non-JS scrapers
// (Twitter/Slack/iMessage) or for canonical dedup. So we rewrite each page's static head
// here, at build time. Deep-linked variants (e.g. /play?game=…) inherit the same file.
import { readFile, writeFile } from 'node:fs/promises';
const BASE = 'https://upbeatbytes.com';
// Per-page <head> overrides. Keep titles/descriptions in sync with each page's intent.
const PAGES = [
{
file: 'news.html', path: '/news',
title: 'News · upbeatBytes — calm, constructive news',
desc: 'Calm, constructive news, newest first — and a daily Highlights brief. ' +
'No ads, no paywalls, no doomscrolling.',
},
{
file: 'play.html', path: '/play',
title: 'Play · upbeatBytes — calm daily games',
desc: 'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
'A friendly little break from the doomscroll.',
},
{
file: 'word.html', path: '/word',
title: 'Word of the Day · upbeatBytes',
desc: 'A new uplifting word every day — its meaning in plain language, how to say it, and how to use it.',
},
{
file: 'quote.html', path: '/quote',
title: 'Quote of the Day · upbeatBytes',
desc: 'A hopeful, hand-picked quote each day, with a short note on what it means.',
},
{
file: 'onthisday.html', path: '/onthisday',
title: 'On This Day · upbeatBytes',
desc: 'One genuinely good thing that happened on this day in history.',
},
{
file: 'art.html', path: '/art',
title: 'Daily Art · upbeatBytes',
desc: "A masterwork a day from the world's open museum collections — beautifully framed, " +
'with a short note on what youre looking at.',
},
];
function subsFor(url, title, desc) {
return [
[/<title>[\s\S]*?<\/title>/, `<title>${title}</title>`],
[/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${desc}" />`],
[/<link rel="canonical" href="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<link rel="canonical" href="${url}" />`],
[/<meta property="og:title" content="[^"]*"\s*\/>/, `<meta property="og:title" content="${title}" />`],
[/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${desc}" />`],
[/<meta property="og:url" content="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<meta property="og:url" content="${url}" />`],
[/<meta name="twitter:title" content="[^"]*"\s*\/>/, `<meta name="twitter:title" content="${title}" />`],
[/<meta name="twitter:description" content="[^"]*"\s*\/>/, `<meta name="twitter:description" content="${desc}" />`],
];
}
for (const { file, path, title, desc } of PAGES) {
const fileUrl = new URL(`../build/${file}`, import.meta.url);
let html;
try {
html = await readFile(fileUrl, 'utf8');
} catch {
console.error(`patch-static-heads: build/${file} is missing (route renamed/removed?)`);
process.exit(1);
}
const missed = [];
for (const [re, repl] of subsFor(BASE + path, title, desc)) {
if (!re.test(html)) { missed.push(re.source.slice(0, 40)); continue; }
html = html.replace(re, repl);
}
// Fail loudly if the homepage head drifted — better a broken build than silently
// shipping the wrong preview/canonical again.
if (missed.length) {
console.error(`patch-static-heads: ${file} — these head tags were not found (app.html changed?):\n ` + missed.join('\n '));
process.exit(1);
}
await writeFile(fileUrl, html);
console.log(`patch-static-heads: rewrote build/${file} head → ${path}`);
}
+6 -3
View File
@@ -8,7 +8,7 @@
font-display: swap;
}
/* Upbeat Bytes calm design system.
/* upbeatBytes calm design system.
Sand, sea, and sun: warm paper surfaces, a vivid-azure accent, gold highlight,
a serif voice for headlines, strong readable contrast, generous space.
No urgency colors (no red). Built around the logo's #0083ad azure. */
@@ -34,7 +34,10 @@
}
* { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
/* Always reserve the scrollbar gutter so the centered top bar / content doesn't jump
left-right between pages that scroll and pages that don't. (No-op on overlay-scrollbar
platforms like mobile, which never shifted anyway.) */
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; scrollbar-gutter: stable; }
body {
margin: 0;
background: var(--bg);
@@ -72,5 +75,5 @@ button { font-family: inherit; cursor: pointer; }
/play and always removed on navigation via effect cleanup. Mobile only. */
@media (max-width: 720px) {
html.playing-game, html.playing-game body { overflow: hidden; }
html.playing-game footer.site { display: none; }
html.playing-game footer.ub-foot { display: none; }
}
+9 -9
View File
@@ -7,17 +7,17 @@
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0083ad" />
<meta name="description" content="Calm, constructive news worth your attention — and nothing that isn't." />
<title>Upbeat Bytes — calm, constructive news</title>
<meta name="description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
<title>upbeatBytes — a calmer, brighter corner of the internet</title>
<link rel="canonical" href="https://upbeatbytes.com/" />
<meta property="og:site_name" content="Upbeat Bytes" />
<meta property="og:site_name" content="upbeatBytes" />
<meta property="og:type" content="website" />
<meta property="og:title" content="Upbeat Bytes — calm, constructive news" />
<meta property="og:description" content="Calm, constructive news worth your attention — and nothing that isn't. Summarized, so you get the gist and go deeper only if you want." />
<meta property="og:title" content="upbeatBytes — a calmer, brighter corner of the internet" />
<meta property="og:description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets. No ads, no paywalls, no doomscrolling." />
<meta property="og:url" content="https://upbeatbytes.com/" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Upbeat Bytes — calm, constructive news" />
<meta name="twitter:description" content="Calm, constructive news, summarized — get the gist, go deeper only if you want." />
<meta name="twitter:title" content="upbeatBytes — a calmer, brighter corner of the internet" />
<meta name="twitter:description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
%sveltekit.head%
<style>
#boot-fallback {
@@ -128,9 +128,9 @@
<div style="display: contents">%sveltekit.body%</div>
<div id="boot-fallback" role="alert" aria-live="polite">
<div class="bf">
<img src="%sveltekit.assets%/logo.svg" alt="Upbeat Bytes" />
<img src="%sveltekit.assets%/logo.svg" alt="upbeatBytes" />
<p>We had a little trouble loading. A quick refresh usually sorts it out.</p>
<button type="button" onclick="location.reload()">Refresh Upbeat Bytes</button>
<button type="button" onclick="location.reload()">Refresh upbeatBytes</button>
</div>
</div>
</body>
+36
View File
@@ -3,6 +3,7 @@
// admin dashboard tell "new vs returning" in aggregate. Server hashes it.
const VISITOR_KEY = 'goodnews:visitor';
const VISITDAY_KEY = 'goodnews:visitday';
const ENGAGEDDAY_KEY = 'goodnews:engagedday';
export function visitorId() {
try {
@@ -63,3 +64,38 @@ export function trackVisit() {
/* ignore */
}
}
// "Engaged reader" signal — what separates real readers from a JS-capable bot that
// trips a raw visit. Fire 'engaged' at most once/day, and ONLY once the page has been
// VISIBLE for ~8s AND we've seen a genuine gesture (scroll/pointer/key/touch). Captures
// nothing beyond the day-deduped visitor token. Returns a cleanup fn. (A deliberate
// action — source-click, share, game start — also counts as engagement, server-side.)
export function armEngaged() {
let fired = false, gesture = false, visibleSecs = 0;
function fire() {
if (fired || !gesture || visibleSecs < 8) return;
fired = true;
try {
const today = new Date().toISOString().slice(0, 10);
if (localStorage.getItem(ENGAGEDDAY_KEY) !== today) {
localStorage.setItem(ENGAGEDDAY_KEY, today);
track('engaged');
}
} catch { /* ignore */ }
cleanup();
}
const onGesture = () => { gesture = true; fire(); };
const tick = setInterval(() => {
if (typeof document === 'undefined' || document.visibilityState === 'visible') {
visibleSecs += 1;
fire();
}
}, 1000);
const evs = ['scroll', 'pointerdown', 'keydown', 'touchstart'];
function cleanup() {
clearInterval(tick);
if (typeof window !== 'undefined') evs.forEach((e) => window.removeEventListener(e, onGesture));
}
if (typeof window !== 'undefined') evs.forEach((e) => window.addEventListener(e, onGesture, { passive: true }));
return cleanup;
}
@@ -59,7 +59,7 @@
if (value) onaction?.(kind, value);
}
// Sharing: share the branded Upbeat Bytes card page (default), with copy-source.
// Sharing: share the branded upbeatBytes card page (default), with copy-source.
let shareOpen = $state(false);
let copied = $state('');
const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;
@@ -100,12 +100,13 @@
>
{#if showImage}
<a class="media" href={summaryHref} onclick={opened}>
<!-- image_url is resolved server-side per source image policy (cached copy or publisher URL) -->
<img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
onerror={() => { failed = true; onimageerror?.(); }} />
</a>
{:else if usePlaceholder}
<a class="media placeholder" href={summaryHref} onclick={opened} style="--c:{accentColor}" tabindex="-1" aria-hidden="true">
<span class="ph-word">{humanize(article.topic) || 'upbeat bytes'}</span>
<span class="ph-word">{humanize(article.topic) || 'upbeatBytes'}</span>
</a>
{/if}
+1 -1
View File
@@ -228,7 +228,7 @@
const breakdown = Object.keys(byLen).sort((a, b) => b - a).map((l) => `${l}×${byLen[l]}`).join(' ');
const pang = found.some(isPangram) ? ' · pangram ✓' : '';
const bloomV = mode === 'daily' ? 'daily' : (format === 'wild' ? 'free-wild' : 'free-center');
const text = `Upbeat Bytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\n${gameShareUrl('bloom', bloomV)}`;
const text = `upbeatBytes · Bloom ${date}\n${fullBloom ? 'Full Bloom 🌸' : tier.name} · ${found.length} words${pang}\n${breakdown}\n${gameShareUrl('bloom', bloomV)}`;
if (navigator.share) navigator.share({ text }).then(() => trackGame('bloom', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { trackGame('bloom', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
}
+35
View File
@@ -0,0 +1,35 @@
<script>
// The one footer across the hub family. Consistent core — the motto + a Send
// feedback link (a low-friction "talk to a human") — plus an optional per-section
// slot. `padBottomNav` reserves space on pages with the fixed mobile BottomNav
// (the /news feed) so the footer never hides behind it. FeedbackModal lives in the
// global layout; this only triggers it.
import { openFeedback } from '$lib/feedback.svelte.js';
let { children, padBottomNav = false } = $props();
</script>
<footer class="ub-foot" class:padnav={padBottomNav}>
<p class="motto">upbeatBytes — no ads, no paywalls, no doomscrolling.</p>
<p class="acts">
<button class="fb" onclick={openFeedback}>Send feedback</button>
{#if children}<span class="sep">·</span>{@render children()}{/if}
</p>
</footer>
<style>
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
.ub-foot {
text-align: center; padding: 30px 18px 36px; margin-top: 10px;
border-top: 1px solid rgba(120, 90, 40, 0.14);
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
}
.motto { margin: 0; font-size: 13px; color: #a4977f; }
.acts { margin: 7px 0 0; font-size: 13px; color: #a4977f; }
.fb { background: none; border: none; color: #7c6444; font: inherit; font-size: 13px; cursor: pointer; text-decoration: underline; padding: 0; }
.fb:hover { color: #5f4d2e; }
.sep { margin: 0 6px; }
.ub-foot :global(a) { color: #7c6444; }
@media (max-width: 720px) {
.ub-foot.padnav { padding-bottom: calc(36px + 64px + env(safe-area-inset-bottom)); }
}
</style>
+2 -2
View File
@@ -6,8 +6,8 @@
<header class="appbar">
<div class="container bar">
<a class="brand" href="/" aria-label="Upbeat Bytes — home">
<img class="logo" src="/logo.svg" alt="Upbeat Bytes" width="586" height="196" />
<a class="brand" href="/" aria-label="upbeatBytes — home">
<img class="logo" src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
</a>
<nav class="utils" aria-label="Your controls">
+140
View File
@@ -0,0 +1,140 @@
<script>
// Shared editorial top bar for the hub (`/`) + its detail pages (/news, /word, /quote,
// /onthisday, /art, /play). Full horizontal nav on wide screens; a hamburger + drop panel
// on phones so the bar stays clean. `active` highlights the current section.
// News now lives at /news (the hub is `/`). `newsHref` stays overridable for safety.
import { onMount } from 'svelte';
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
import Avatar from './Avatar.svelte';
let { active = '', newsHref = '/news' } = $props();
let open = $state(false);
// auth.user paints immediately from its localStorage cache; revalidate once if no
// page has yet (e.g. the hub is the entry point) so the avatar + signed-in state are
// correct everywhere the bar shows, not just on the feed/account pages.
onMount(() => { if (!auth.ready) refreshAuth(); });
// Close the menu when we cross into desktop width, so it can't linger open and reappear
// if the viewport shrinks back to mobile (the CSS hide alone left `open` stale).
$effect(() => {
const mq = window.matchMedia('(min-width: 721px)');
const sync = (e) => { if (e.matches) open = false; };
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
});
let LINKS = $derived([
{ key: 'home', href: '/', label: 'Home' },
{ key: 'news', href: newsHref, label: 'News' },
{ key: 'games', href: '/play', label: 'Games' },
{ key: 'art', href: '/art', label: 'Art' },
]);
</script>
<svelte:window on:keydown={(e) => { if (e.key === 'Escape') open = false; }} />
<header class="bar">
<a class="brand" href="/" aria-label="upbeatBytes home">
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
</a>
<div class="bar-end">
<nav class="nav">
{#each LINKS as l}
<a class:on={active === l.key} href={l.href}>{l.label}</a>
{/each}
<span class="nav-soon">Entertainment</span>
</nav>
<a class="acct" class:hasimg={auth.user?.avatar_url} href="/account" aria-label="Your account">
{#if auth.user?.avatar_url}
<Avatar user={auth.user} size={32} />
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#C98A2E" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="8" r="4" /><path d="M4 21c0-4 4-6 8-6s8 2 8 6" />
</svg>
{/if}
</a>
<button class="burger" class:open aria-label="Menu" aria-expanded={open} aria-controls="hub-menu"
onclick={() => (open = !open)}>
<span></span><span></span><span></span>
</button>
</div>
</header>
{#if open}
<div class="menu-wrap">
<nav id="hub-menu" class="menu">
{#each LINKS as l}
<a class:on={active === l.key} href={l.href} onclick={() => (open = false)}>{l.label}</a>
{/each}
<span class="menu-soon">Entertainment <em>soon</em></span>
</nav>
</div>
{/if}
<style>
/* Self-contained so the bar looks identical on EVERY page, regardless of the host
page's own fonts/tokens — one cohesive top bar across the whole site. */
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
.bar {
display: flex; align-items: center; justify-content: space-between;
max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box;
padding: 26px clamp(18px, 5vw, 44px) 0;
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
}
.brand { display: block; line-height: 0; }
.brand img { height: 48px; width: auto; display: block; }
.bar-end { display: flex; align-items: center; gap: clamp(16px, 2.4vw, 32px); }
.nav { display: flex; align-items: center; gap: clamp(16px, 2.4vw, 32px); font-size: 16.5px; font-weight: 500; }
.nav a { color: #6b6256; text-decoration: none; }
.nav a.on { color: #23201b; }
.nav a:hover { color: #0083ad; }
.nav-soon { color: #b3a890; }
.acct {
width: 32px; height: 32px; border-radius: 50%; border: 1.5px solid #e6c9a0; background: #FCEFD7;
display: flex; align-items: center; justify-content: center; flex: none;
}
/* when showing the profile picture, let it fill the circle cleanly (no tint ring/peek) */
.acct.hasimg { background: none; overflow: hidden; padding: 0; }
.acct:hover { background: #fbe6c4; }
/* hamburger — phones only */
.burger {
display: none; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
width: 40px; height: 40px; border-radius: 11px; border: 1.5px solid #e6c9a0; background: #FCEFD7;
cursor: pointer; padding: 0; flex: none;
}
.burger:hover { background: #fbe6c4; }
.burger span { width: 18px; height: 2px; border-radius: 2px; background: #7a6a52; transition: transform 0.2s ease, opacity 0.15s ease; }
.burger.open span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.burger.open span:nth-child(2) { opacity: 0; }
.burger.open span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
/* drop panel */
.menu-wrap { max-width: 1180px; width: 100%; margin: 10px auto 0; box-sizing: border-box; padding: 0 clamp(18px, 5vw, 44px); font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif; }
.menu {
display: flex; flex-direction: column; background: #fff; border: 1px solid #f2e7d3;
border-radius: 14px; overflow: hidden; box-shadow: 0 14px 34px -20px rgba(60, 50, 30, 0.4);
}
.menu a, .menu .menu-soon {
padding: 14px 18px; font-size: 16px; font-weight: 500; text-decoration: none;
color: #6b6256; border-top: 1px solid #f3ece0;
}
.menu a:first-child { border-top: none; }
.menu a.on { color: #23201b; }
.menu a:hover { background: #FFF9EF; color: #0083ad; }
.menu-soon { display: flex; align-items: center; justify-content: space-between; color: #b3a890; }
.menu-soon em { font-style: normal; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #c3b69c; }
@media (max-width: 720px) {
.nav { display: none; }
.burger { display: flex; }
}
@media (min-width: 721px) {
.menu-wrap { display: none; } /* safety: never show the panel once back on desktop */
}
</style>
@@ -0,0 +1,69 @@
<script>
// Shared shell for the hub + its detail pages (/word, /quote, /onthisday): the editorial
// top bar, footer, fonts, and design tokens. Content goes in the default slot.
import { afterNavigate, goto } from '$app/navigation';
import HubBar from './HubBar.svelte';
import Footer from './Footer.svelte';
let { active = '', back = true, backLabel = 'Back', children } = $props();
// Same single-history rule as the News reader's in-page Back: if we arrived here by an
// in-app navigation, history.back() returns to wherever (the hub) with state intact; a
// direct deep-link has no app history, so fall back to the hub.
let cameFromApp = $state(false);
afterNavigate(({ from }) => { if (from) cameFromApp = true; });
function goBack() {
if (cameFromApp && typeof history !== 'undefined') history.back();
// Cold deep-link: no in-app origin. REPLACE this entry rather than pushing one, so the
// browser Back from the hub doesn't bounce the reader straight back into the detail page.
else goto('/', { replaceState: true });
}
</script>
<div class="page">
<HubBar {active} />
<main class="shell-main">
{#if back}
<button class="back" onclick={goBack} aria-label="Go back">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 18l-6-6 6-6" />
</svg>
{backLabel}
</button>
{/if}
{@render children?.()}
</main>
<Footer />
</div>
<style>
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-italic-var.woff2') format('woff2'); font-weight: 400 500; font-style: italic; font-display: swap; }
.page {
--ink: #1c1916; --body: #6b6256; --muted: #a89e8c; --teal: #0083ad;
--canvas: #FFF9EF; --news-border: #f2e7d3;
min-height: 100vh; background: var(--canvas); color: #23201b;
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
display: flex; flex-direction: column;
}
.page :global(*) { box-sizing: border-box; }
.shell-main {
flex: 1; width: 100%; max-width: 1180px; margin: 0 auto; box-sizing: border-box;
padding: clamp(20px, 4vw, 40px) clamp(18px, 5vw, 44px) clamp(40px, 6vw, 72px);
}
/* top-left Back — returns to the hub (or wherever you came from), preserving history */
.back {
display: inline-flex; align-items: center; gap: 6px; margin: 0 0 clamp(14px, 3vw, 24px);
background: none; border: none; cursor: pointer; padding: 6px 10px 6px 0;
font-family: inherit; font-size: 14px; font-weight: 600; color: var(--body);
transition: color 0.15s ease;
}
.back:hover { color: var(--teal); }
.back svg { transition: transform 0.15s ease; }
.back:hover svg { transform: translateX(-2px); }
</style>
+1 -1
View File
@@ -133,7 +133,7 @@
function share() {
const label = `${TIER_LABEL[tier] || tier} · ${format === 'colors' ? 'colors' : 'icons'}`;
const when = isFree ? 'Free play' : date;
const text = `Upbeat Bytes · Memory Match (${label}) ${when}\nCleared in ${moves} moves\n${gameShareUrl('match', `${mode}-${format}-${tier}`)}`;
const text = `upbeatBytes · Memory Match (${label}) ${when}\nCleared in ${moves} moves\n${gameShareUrl('match', `${mode}-${format}-${tier}`)}`;
if (navigator.share) navigator.share({ text }).then(() => trackGame('match', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { trackGame('match', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
<script>
// A single hub "room" card. Presentational — the page passes live preview data in.
// Every card is tinted with a darker top strip; layout varies by preview type:
// news = inset photo on top, art = inset photo on the right, static = centered header.
let { room, artImg = null, newsImg = null, newsHeadline = '' } = $props();
</script>
<a class="card card--{room.size} card--{room.preview}" href={room.href}
style={room.tint ? `--tint:${room.tint}` : ''}>
{#if room.preview === 'news'}
{#if newsImg}<div class="photo photo--top" style="background-image:url({newsImg})"></div>{/if}
<div class="content">
<div class="head"><span class="ic">{room.icon}</span><span class="title">{room.title}</span></div>
{#if newsHeadline}<p class="headline">{newsHeadline}</p>{:else}<p class="blurb">{room.blurb}</p>{/if}
<span class="cta">{room.cta} <span class="arr"></span></span>
</div>
{:else if room.preview === 'art'}
<div class="content">
<div class="head"><span class="ic">{room.icon}</span><span class="title">{room.title}</span></div>
<p class="blurb">{room.blurb}</p>
<span class="cta">{room.cta} <span class="arr"></span></span>
</div>
{#if artImg}<div class="photo photo--right" style="background-image:url({artImg})"></div>{/if}
{:else}
<div class="content">
<div class="head"><span class="ic">{room.icon}</span><span class="title">{room.title}</span></div>
<p class="blurb">{room.blurb}</p>
<span class="cta">{room.cta} <span class="arr"></span></span>
</div>
{/if}
</a>
<style>
.card {
--pad: clamp(13px, 1.7vw, 18px);
position: relative; display: flex; overflow: hidden; text-decoration: none; color: var(--ink);
background: var(--tint, var(--surface)); border: 1px solid rgba(20, 30, 45, 0.07);
border-radius: 18px; box-shadow: 0 10px 30px rgba(20, 30, 45, 0.06);
transition: transform 0.18s ease, box-shadow 0.18s ease;
padding: var(--pad);
}
.card:hover { transform: translateY(-4px); box-shadow: 0 20px 46px rgba(20, 30, 45, 0.12); }
/* size variants — grid spans set by the page; these set the heights */
.card--large { min-height: 340px; }
.card--wide { min-height: 200px; }
.card--small { min-height: 168px; }
.content { display: flex; flex-direction: column; flex: 1; }
/* shared side-by-side header (icon + title), clean modern sans title */
.head { display: flex; align-items: center; gap: 10px; }
.ic { font-size: clamp(1.7rem, 2.4vw, 2.1rem); line-height: 1; }
.title {
font-family: var(--sans); font-weight: 700; letter-spacing: -0.015em;
font-size: clamp(1.25rem, 2vw, 1.5rem);
}
.blurb { margin: 11px 0 14px; color: var(--muted); font-size: 1rem; line-height: 1.45; max-width: 42ch; }
.cta { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; color: var(--accent); font-size: 0.98rem; }
.arr { transition: transform 0.18s ease; }
.card:hover .arr { transform: translateX(4px); }
/* photos sit inset (rounded) so the card colour shows all around them */
.photo { background-size: cover; background-position: center; border-radius: 11px; }
/* News — inset photo on top, headline below; larger hero header */
.card--news { flex-direction: column; }
.card--news .photo--top { width: 100%; flex: 1 1 auto; min-height: 150px; }
.card--news .content { flex: 0 0 auto; padding-top: 14px; }
.card--news .ic { font-size: clamp(2rem, 2.8vw, 2.5rem); }
.card--news .title { font-size: clamp(1.5rem, 2.6vw, 2rem); }
.card--news .headline {
margin: 12px 0 4px; font-family: Georgia, serif; font-style: italic;
font-size: clamp(1.1rem, 1.9vw, 1.4rem); line-height: 1.34; max-width: 40ch;
}
/* Art — inset rectangular photo on the RIGHT (cover-cropped to hide ragged edges) */
.card--art { flex-direction: row; align-items: stretch; gap: var(--pad); }
.card--art .content { justify-content: center; }
.card--art .photo--right { flex: 0 0 40%; min-width: 120px; align-self: stretch; }
/* Static — centered header, body left, link pinned to the bottom (cards line up) */
.card--static .head { justify-content: center; }
.card--static .blurb { margin: 12px 0 14px; }
.card--static .cta { margin-top: auto; }
</style>
+1 -1
View File
@@ -41,7 +41,7 @@
</p>
<button class="primary" onclick={onclose}>Done</button>
{:else}
<h2>Sign in to Upbeat Bytes</h2>
<h2>Sign in to upbeatBytes</h2>
<p class="sub">
Save articles and keep your history across devices.
</p>
+1 -1
View File
@@ -167,7 +167,7 @@
const label = variant === '6' ? 'Long Word' : 'Daily Word';
const score = status === 'won' ? guesses.length : 'X';
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\n${gameShareUrl('word', variant)}`;
const text = `upbeatBytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\n${gameShareUrl('word', variant)}`;
// Count a share only once it actually happens (sheet completed / clipboard wrote),
// never on a cancelled share sheet or denied clipboard.
if (navigator.share) navigator.share({ text }).then(() => trackGame('word', 'shared')).catch(() => {});
@@ -223,7 +223,7 @@
function share() {
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\n${gameShareUrl('wordsearch', size)}`;
const text = `upbeatBytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\n${gameShareUrl('wordsearch', size)}`;
if (navigator.share) navigator.share({ text }).then(() => trackGame('wordsearch', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { trackGame('wordsearch', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
}
+40
View File
@@ -0,0 +1,40 @@
// Pure routing helpers for the news feed, shared by its two mounts: `/` (interim,
// becomes the hub at cutover) and `/news` (its permanent home). Kept framework-free
// so they're unit-testable without mounting the Svelte component.
// Which base path the feed is rendering at. `/news` is permanent; `/` is the interim
// mount. Anything not under /news is treated as `/`.
export function feedBase(pathname) {
return (pathname || '').startsWith('/news') ? '/news' : '/';
}
// The default (bare-path) view per base: `/news` leads with Latest — the live, "as it
// comes in" firehose — while the interim `/` keeps leading with Highlights (frozen). At
// cutover `/` becomes the hub, so only the /news default remains.
export function defaultView(base) {
return base === '/news' ? 'latest' : 'today';
}
// The current view key, derived from query params only (path-agnostic, so a deep link
// works at either base): search > source > tag > explicit view > the base's default.
// 'highlights' is the canonical param for the Brief; 'today' stays accepted as an alias.
export function parseView(url, def = 'today') {
const p = url.searchParams;
if ((p.get('q') || '').trim()) return 'search';
if (p.get('source')) return 'source:' + p.get('source');
if (p.get('tag')) return 'tag:' + p.get('tag');
const v = p.get('view');
if (!v) return def;
if (v === 'highlights' || v === 'today') return 'today'; // Brief (alias-tolerant)
return v;
}
// A link to a view at the given base path. The base's default view is the bare path;
// the Brief is canonically `?view=highlights`; everything else carries its own param.
export function viewUrl(base, key, def = 'today') {
if (key === def) return base;
if (key === 'today') return base + '?view=highlights';
if (key.startsWith('source:')) return base + '?source=' + encodeURIComponent(key.slice(7));
if (key.startsWith('tag:')) return base + '?tag=' + encodeURIComponent(key.slice(4));
return base + '?view=' + encodeURIComponent(key);
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, it, expect } from 'vitest';
import { feedBase, defaultView, parseView, viewUrl } from './feednav.js';
const view = (path, def) => parseView(new URL('https://x' + path), def);
describe('feedBase', () => {
it('is /news only under the /news path; everything else is /', () => {
expect(feedBase('/news')).toBe('/news');
expect(feedBase('/news?view=highlights')).toBe('/news');
expect(feedBase('/')).toBe('/');
expect(feedBase('/?view=latest')).toBe('/');
expect(feedBase('')).toBe('/');
expect(feedBase(undefined)).toBe('/');
});
});
describe('defaultView', () => {
it('/news leads with Latest; / (frozen) leads with Highlights', () => {
expect(defaultView('/news')).toBe('latest');
expect(defaultView('/')).toBe('today');
});
});
describe('parseView', () => {
it('bare path resolves to the base default', () => {
expect(view('/')).toBe('today'); // / default (frozen)
expect(view('/news', 'latest')).toBe('latest'); // /news default = Latest
});
it('Brief is reachable via ?view=highlights, with ?view=today as an alias', () => {
expect(view('/news?view=highlights', 'latest')).toBe('today');
expect(view('/news?view=today', 'latest')).toBe('today'); // alias kept
expect(view('/?view=today')).toBe('today');
});
it('reads the standard views regardless of base', () => {
expect(view('/news?view=latest', 'latest')).toBe('latest');
expect(view('/?view=following')).toBe('following');
expect(view('/?tag=clean-energy')).toBe('tag:clean-energy');
expect(view('/?source=7')).toBe('source:7');
expect(view('/?q=solar')).toBe('search');
});
it('prioritizes search > source > tag > view, ignores whitespace q', () => {
expect(view('/?q=a&source=7&tag=b&view=latest')).toBe('search');
expect(view('/?source=7&tag=b&view=latest')).toBe('source:7');
expect(view('/?tag=b&view=latest')).toBe('tag:b');
expect(view('/?q=%20')).toBe('today');
});
});
describe('viewUrl', () => {
it('the base default is the bare path; the Brief is ?view=highlights', () => {
// /news: Latest is the default (bare), Highlights is explicit
expect(viewUrl('/news', 'latest', 'latest')).toBe('/news');
expect(viewUrl('/news', 'today', 'latest')).toBe('/news?view=highlights');
expect(viewUrl('/news', 'following', 'latest')).toBe('/news?view=following');
// / (frozen): Highlights is the default (bare), Latest is explicit
expect(viewUrl('/', 'today', 'today')).toBe('/');
expect(viewUrl('/', 'latest', 'today')).toBe('/?view=latest');
});
it('round-trips: a generated link parses back to the same view', () => {
expect(view(viewUrl('/news', 'today', 'latest'), 'latest')).toBe('today');
expect(view(viewUrl('/news', 'latest', 'latest'), 'latest')).toBe('latest');
expect(view(viewUrl('/', 'latest', 'today').replace('/', '/'), 'today')).toBe('latest');
});
it('encodes tag/source/view values', () => {
expect(viewUrl('/news', 'tag:good news', 'latest')).toBe('/news?tag=good%20news');
expect(viewUrl('/', 'source:42', 'today')).toBe('/?source=42');
});
});
+50
View File
@@ -0,0 +1,50 @@
// The homepage hub as data. Each "room" is one entry — add, reorder, or resize by
// editing this list. `size` drives the grid footprint (large | wide | tall | small);
// `preview` picks how the card shows itself (news/art = live snippet, static = blurb).
// Copy here is placeholder — owner-written final wording lands later.
export const ROOMS = [
{
id: 'news',
title: 'Good News',
blurb: "The world's quiet good, gathered each morning.",
href: '/', // becomes /news once the Brief is split out
cta: 'Read the Brief',
size: 'large',
preview: 'news',
icon: '📰',
tint: '#fbecdb', // soft amber
},
{
id: 'art',
title: 'Daily Art',
blurb: 'A masterwork a day, beautifully framed.',
href: '/art',
cta: 'View today',
size: 'wide',
preview: 'art',
icon: '🖼️',
tint: '#f4ecf3', // soft lilac
},
{
id: 'play',
title: 'Play',
blurb: 'Daily Word, Word Search, Bloom, Memory Match.',
href: '/play',
cta: 'Enter',
size: 'small',
preview: 'static',
icon: '🎲',
tint: '#e9f1f9', // soft sky
},
{
id: 'moment',
title: 'Daily Moment',
blurb: 'A small calm to carry with you.',
href: '#',
cta: 'Soon',
size: 'small',
preview: 'static',
icon: '🌿',
tint: '#eaf4ea', // soft sage
},
];
+84 -54
View File
@@ -15,25 +15,28 @@
// /zen?debug=1 — tuning a blind WebGL fish through redeploys was the slow path.
import * as THREE from 'three';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { createSwimmer } from './behavior.js';
const MODEL_URL = '/models/ub-split.glb';
const MODEL_URL = '/models/ub-angelfish.glb';
// Converged Phase-A starting point (Claude + Codex): a hair off strict profile so
// the mouth/chin reads naturally; tail OPAQUE alpha-tested (one coherent tail, no
// blend bleed); fins translucent + single-sided (kills the double-sided ghosting
// that read as an off-center dorsal). All live-tunable at /zen?debug=1.
// UB is the Queen angelfish: ONE mesh, TWO materials (…_body opaque single-sided; …_fins
// opaque alpha-tested), and a multi-clip GLB (idle / cruise / burst / turnL / turnR). A
// behavior engine (behavior.js) owns locomotion — UB wanders, cruises, drifts, rests,
// darts, and banks through U-turns; the loop applies its position/heading and crossfades
// to the clip it asks for. Render + behavior are live-tunable at /zen?debug=1.
export const DEFAULTS = {
yaw: Math.PI / 2 + 0.10, // ub.rotation.y — slight 3/4 view
pitch: 0, // ub.rotation.x — nose up/down
tailTranslucent: false, // false = opaque alpha-tested (coherent); true = blended
tailSide: 'front', // front | back | double
tailAlphaTest: 0.035,
tailOpacity: 1.0, // only when translucent
finSide: 'front', // front | back | double
finOpacity: 0.75,
finAlphaTest: 0.02,
scale: 1.0, // multiplier on the auto-fit size
cruiseSpeed: 0.5, // roaming speed (tail cadence follows it)
boundsX: 1.5, // tank half-extents UB roams within (soft-avoided)
boundsY: 0.7,
boundsZ: 0.6, // depth → near/far passes
liveliness: 1.0, // 0 = placid cruiser · higher = more rests + darts
finTranslucent: false, // false = opaque alpha-tested (coherent); true = blended
finSide: 'double', // front | back | double (fins are thin → double reads fuller)
finOpacity: 0.9, // only when translucent
finAlphaTest: 0.5, // clip the fin-edge alpha
preview: '', // '' = live behavior; a clip name = freeze + loop it in place (diagnostic)
paused: false,
frame: 0, // 0..1 scrub position when paused
};
const SIDE = { front: THREE.FrontSide, back: THREE.BackSide, double: THREE.DoubleSide };
@@ -76,58 +79,70 @@ export async function createAquarium(canvas, initial = {}) {
const center = box.getCenter(new THREE.Vector3());
ub.position.sub(center);
const maxDim = Math.max(size.x, size.y, size.z) || 1;
ub.scale.setScalar(2.6 / maxDim);
const baseScale = 2.6 / maxDim; // auto-fit; params.scale fine-tunes it live
// Collect the three named materials/meshes so applyMaterials can retarget them
// live without re-traversing semantics.
// Classify the two meshes by material-name substring (…_body / …_fins) so
// applyMaterials can retarget them live regardless of the species prefix.
const part = {};
ub.traverse((o) => { if (o.isMesh && o.material?.name) part[o.material.name] = o; });
ub.traverse((o) => {
if (!o.isMesh || !o.material?.name) return;
const n = o.material.name.toLowerCase();
if (n.includes('body')) part.body = o; else if (n.includes('fin')) part.fins = o;
});
function applyMaterials() {
const body = part.UB_Body, fins = part.UB_Fins, tail = part.UB_Tail;
if (body) {
const body = part.body, fins = part.fins;
if (body) { // body: always OPAQUE, single-sided
const m = body.material;
m.transparent = false; m.opacity = 1; m.alphaTest = 0;
m.depthWrite = true; m.depthTest = true; m.side = THREE.FrontSide;
body.renderOrder = 1; m.needsUpdate = true;
}
if (fins) {
if (fins) { // fins (+ tail): opaque alpha-tested by default
const m = fins.material;
m.transparent = true; m.opacity = params.finOpacity; m.alphaTest = params.finAlphaTest;
m.alphaToCoverage = true; m.depthWrite = false; m.depthTest = true;
m.side = SIDE[params.finSide] ?? THREE.FrontSide;
fins.renderOrder = 3; m.needsUpdate = true;
}
if (tail) {
const m = tail.material;
m.alphaTest = params.tailAlphaTest; m.alphaToCoverage = true; m.depthTest = true;
m.side = SIDE[params.tailSide] ?? THREE.FrontSide;
if (params.tailTranslucent) {
m.transparent = true; m.opacity = params.tailOpacity; m.depthWrite = false;
m.alphaTest = params.finAlphaTest; m.alphaToCoverage = true; m.depthTest = true;
m.side = SIDE[params.finSide] ?? THREE.DoubleSide;
if (params.finTranslucent) {
m.transparent = true; m.opacity = params.finOpacity; m.depthWrite = false;
} else {
m.transparent = false; m.opacity = 1; m.depthWrite = true; // opaque, coherent
m.transparent = false; m.opacity = 1; m.depthWrite = true; // clean, coherent
}
tail.renderOrder = 2; m.needsUpdate = true;
fins.renderOrder = 2; m.needsUpdate = true;
}
}
function applyTransform() {
ub.rotation.set(params.pitch, params.yaw, 0);
}
applyMaterials();
applyTransform();
scene.add(ub);
ub.scale.setScalar(baseScale * (params.scale ?? 1));
// Animation — keep every action by name so Phase B can crossfade (Idle_swim →
// Eat_Up / Roll). Idle_swim is the base loop.
// motionRoot flies UB around the tank (position + heading + bank); the visual rig (the
// model) just plays in-place body clips inside it — so navigation never fights the
// skeleton (Codex). The controller is the sole authority on world movement + heading.
const motionRoot = new THREE.Group();
motionRoot.add(ub);
scene.add(motionRoot);
// Clips by name (idle / cruise / fast / turn{L,R}{in,loop,out} / eat*). The controller
// asks for one each frame; crossfade() blends to it. Authored clips play at their own
// timing; only cruise's cadence scales with swim speed.
const mixer = new THREE.AnimationMixer(ub);
const actions = {};
for (const clip of gltf.animations) actions[clip.name] = mixer.clipAction(clip);
const baseClip = (actions.Idle_swim ? actions.Idle_swim : mixer.clipAction(gltf.animations[0]));
baseClip.play();
const baseDuration = baseClip.getClip().duration || 1;
mixer.timeScale = reduced ? 0.6 : 1; // calmer when reduced-motion
let curClip = actions.cruise ? 'cruise' : Object.keys(actions)[0];
actions[curClip]?.play();
function crossfade(name) {
if (name === curClip || !actions[name]) return;
actions[name].reset().play();
actions[curClip]?.crossFadeTo(actions[name], 0.35, false);
curClip = name;
}
// The swim controller — owns UB's position + heading + which clip to show.
const swimmer = createSwimmer({
reduced,
cruiseSpeed: params.cruiseSpeed * (reduced ? 0.7 : 1),
boundsX: params.boundsX, boundsY: params.boundsY, boundsZ: params.boundsZ,
liveliness: params.liveliness,
});
resize();
// The canvas lives in a responsive container; a ResizeObserver catches layout
@@ -140,22 +155,37 @@ export async function createAquarium(canvas, initial = {}) {
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const dt = clock.getDelta();
if (params.paused) {
mixer.setTime(params.frame * baseDuration); // scrub to a frozen frame
if (params.paused) { renderer.render(scene, camera); return; }
if (params.preview && actions[params.preview]) {
// diagnostic: freeze locomotion, loop one raw clip broadside at centre
motionRoot.position.set(0, 0, 0);
motionRoot.rotation.set(0, Math.PI / 2, 0);
crossfade(params.preview);
if (actions[params.preview]) actions[params.preview].timeScale = 1;
} else {
mixer.update(dt);
const s = swimmer.update(dt);
motionRoot.position.copy(s.pos);
motionRoot.quaternion.copy(s.quat); // heading + bank, sole authority
crossfade(s.clip);
if (actions.cruise) actions.cruise.timeScale = THREE.MathUtils.clamp(s.speed / (params.cruiseSpeed || 0.5), 0.6, 1.5);
}
mixer.update(dt);
renderer.render(scene, camera);
});
return {
// exposed for Phase B tuning + the /zen?debug=1 panel
ub, actions, mixer, scene, camera, params, baseDuration,
// live setter: merge new values, re-apply materials + transform
// exposed for tuning + the /zen?debug=1 panel
ub, motionRoot, actions, mixer, swimmer, scene, camera, params,
// live setter: merge new values, re-apply materials + scale + controller params
setParams(next = {}) {
Object.assign(params, next);
applyMaterials();
applyTransform();
ub.scale.setScalar(baseScale * (params.scale ?? 1));
swimmer.setParams({
cruiseSpeed: params.cruiseSpeed * (reduced ? 0.7 : 1),
boundsX: params.boundsX, boundsY: params.boundsY, boundsZ: params.boundsZ,
liveliness: params.liveliness,
});
return { ...params };
},
getParams() { return { ...params }; },
+106
View File
@@ -0,0 +1,106 @@
// UB's swim controller — real 3D steering (Codex rebuild). Owns world position, velocity
// and heading; the visual rig just plays in-place body clips. UB seeks wandering waypoints
// through the whole tank (XYZ, incl. near/far depth passes), eases speed up and down, banks
// into curves, and softly veers away from walls BEFORE reaching them — no scripted U-turns.
// The controller is the SOLE authority on heading (clips carry no root motion), so the
// animation never fights the steering. Frame-rate-independent.
import * as THREE from 'three';
const rand = (a, b) => a + Math.random() * (b - a);
const damp = (cur, tgt, rate, dt) => cur + (tgt - cur) * (1 - Math.exp(-rate * dt));
const FORWARD = new THREE.Vector3(0, 0, 1); // model nose = local +Z
const UP = new THREE.Vector3(0, 1, 0);
export function createSwimmer(opts = {}) {
const reduced = opts.reduced ?? false;
const P = {
boundsX: 1.5, boundsY: 0.7, boundsZ: 0.6,
cruiseSpeed: 0.5, maxSpeed: 1.15, accel: 1.1, maxTurn: 1.7, // maxTurn rad/s
liveliness: 1, ...opts,
};
const pos = new THREE.Vector3(0, 0, 0);
const vel = new THREE.Vector3(P.cruiseSpeed, 0, 0);
const heading = new THREE.Quaternion();
const wp = new THREE.Vector3();
let targetSpeed = P.cruiseSpeed, mode = 'cruise', timer = 3, roll = 0, yawRate = 0;
const prevF = new THREE.Vector3(0, 0, 1);
const S = { pos, quat: new THREE.Quaternion(), speed: P.cruiseSpeed, mode: 'cruise', clip: 'cruise', turnRate: 0 };
// scratch
const _des = new THREE.Vector3(), _steer = new THREE.Vector3(), _to = new THREE.Vector3();
const _f = new THREE.Vector3(), _r = new THREE.Vector3(), _u = new THREE.Vector3();
const _m = new THREE.Matrix4(), _tq = new THREE.Quaternion(), _curF = new THREE.Vector3(), _roll = new THREE.Quaternion();
const bnd = () => new THREE.Vector3(P.boundsX, P.boundsY, P.boundsZ);
function newWaypoint() {
const b = bnd();
wp.set(rand(-b.x, b.x) * 0.9, rand(-b.y, b.y) * 0.85, rand(-b.z, b.z) * 0.9);
}
function pickMode() {
const live = P.liveliness * (reduced ? 0.5 : 1);
const r = Math.random();
if (r < 0.12 * live) { mode = 'rest'; targetSpeed = P.cruiseSpeed * 0.06; timer = rand(3, 6); }
else if (r < 0.12 * live + 0.09 * live) { mode = 'dart'; targetSpeed = P.maxSpeed * rand(0.8, 1); timer = rand(1.2, 2.4); newWaypoint(); }
else { mode = 'cruise'; targetSpeed = P.cruiseSpeed * rand(0.85, 1.15); timer = rand(4, 8); }
}
newWaypoint(); pickMode();
return {
state: S,
params: P,
setParams(n = {}) { Object.assign(P, n); },
update(dt) {
dt = Math.min(dt, 0.05);
timer -= dt; if (timer <= 0) pickMode();
_to.copy(wp).sub(pos);
if (_to.lengthSq() < 0.12) newWaypoint();
_des.copy(_to).normalize().multiplyScalar(targetSpeed);
// soft wall/surface/floor avoidance — veer inward as a boundary nears
const b = bnd(), margin = 0.55;
for (const ax of ['x', 'y', 'z']) {
const lim = ax === 'x' ? b.x : ax === 'y' ? b.y : b.z;
if (pos[ax] > lim - margin) _des[ax] -= ((pos[ax] - (lim - margin)) / margin) * targetSpeed * 1.6;
if (pos[ax] < -lim + margin) _des[ax] += (((-lim + margin) - pos[ax]) / margin) * targetSpeed * 1.6;
}
// limited-accel steering, capped speed
_steer.copy(_des).sub(vel).clampLength(0, P.accel);
vel.addScaledVector(_steer, dt).clampLength(0, P.maxSpeed);
pos.addScaledVector(vel, dt);
pos.set(THREE.MathUtils.clamp(pos.x, -b.x, b.x), THREE.MathUtils.clamp(pos.y, -b.y, b.y), THREE.MathUtils.clamp(pos.z, -b.z, b.z));
const speed = vel.length();
S.speed = speed;
// heading toward velocity (belly-down basis), rate-limited so it can't snap
if (speed > 1e-4) {
_f.copy(vel).normalize();
_u.copy(UP).addScaledVector(_f, -UP.dot(_f)).normalize();
_r.crossVectors(_u, _f).normalize();
_m.makeBasis(_r, _u, _f);
_tq.setFromRotationMatrix(_m);
heading.rotateTowards(_tq, P.maxTurn * dt);
}
// banking: signed yaw rate → lean into the curve
_curF.copy(FORWARD).applyQuaternion(heading);
const cross = prevF.x * _curF.z - prevF.z * _curF.x; // horizontal component of prevF × curF
yawRate = damp(yawRate, (Math.asin(THREE.MathUtils.clamp(cross, -1, 1)) / dt) || 0, 6, dt);
prevF.copy(_curF);
roll = damp(roll, THREE.MathUtils.clamp(-yawRate * 0.22, -0.5, 0.5), 3, dt);
_roll.setFromAxisAngle(FORWARD, roll);
S.quat.copy(heading).multiply(_roll);
S.turnRate = yawRate;
// clip: rest→idle, dart→fast, hard turn→turn-loop (by sign), else cruise
S.mode = mode;
if (mode === 'rest') S.clip = 'idle';
else if (mode === 'dart') S.clip = 'fast';
else if (Math.abs(yawRate) > 0.6) S.clip = yawRate > 0 ? 'turnLloop' : 'turnRloop';
else S.clip = 'cruise';
return S;
},
};
}
+6 -26
View File
@@ -2,8 +2,8 @@
import '../app.css';
import { onMount } from 'svelte';
import FeedbackModal from '$lib/components/FeedbackModal.svelte';
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js';
import { trackVisit } from '$lib/analytics.js';
import { fb, closeFeedback } from '$lib/feedback.svelte.js';
import { trackVisit, armEngaged } from '$lib/analytics.js';
let { children } = $props();
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the
// recovery card + timeout as soon as the shell hydrates.
@@ -12,6 +12,8 @@
// Count the daily visit at the LAYOUT level so every landing page counts —
// direct /play and /a/ arrivals included, not just the news homepage.
trackVisit();
// Arm the once-daily "engaged reader" signal (≈8s visible + a real gesture).
return armEngaged(); // cleanup on layout teardown
});
</script>
@@ -19,27 +21,5 @@
{#if fb.open}<FeedbackModal onclose={closeFeedback} />{/if}
<footer class="site">
<div class="container">
<button class="fb" onclick={openFeedback}>Send feedback</button>
<span class="dot">·</span>
Upbeat Bytes · metadata &amp; links only, no stored articles · <a href="/docs">API</a>
</div>
</footer>
<style>
footer.site {
text-align: center;
color: var(--muted);
font-size: 0.82rem;
padding: 26px 0 34px;
border-top: 1px solid var(--line);
}
footer.site a { color: var(--accent-deep); }
footer.site .fb { background: none; border: none; color: var(--accent-deep); font: inherit; font-size: 0.82rem; cursor: pointer; text-decoration: underline; padding: 0; }
footer.site .dot { margin: 0 4px; }
/* room for the mobile bottom tab bar */
@media (max-width: 720px) {
footer.site { padding-bottom: calc(34px + 64px + env(safe-area-inset-bottom)); }
}
</style>
<!-- Footer is per-surface now (shared $lib/components/Footer.svelte) so each section
can add its own extras; FeedbackModal stays global, triggered from any footer. -->
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from '@sveltejs/kit';
// Keep legacy root-query links alive after the cutover: the old feed lived at `/`
// (e.g. /?view=latest, /?tag=…, /?source=…, /?q=…); the feed is now /news. Redirect
// here in load() — before the hub renders — so bookmarks/old shares never flash the
// hub. Bare `/` (no query) falls through to the hub. parseView's alias keeps
// /news?view=today working too.
export function load({ url }) {
const p = url.searchParams;
if (p.get('q')) throw redirect(307, '/news?q=' + encodeURIComponent(p.get('q')));
if (p.get('source')) throw redirect(307, '/news?source=' + encodeURIComponent(p.get('source')));
if (p.get('tag')) throw redirect(307, '/news?tag=' + encodeURIComponent(p.get('tag')));
const v = p.get('view');
if (v === 'today') throw redirect(307, '/news?view=highlights');
if (v === 'latest') throw redirect(307, '/news');
if (v) throw redirect(307, '/news?view=' + encodeURIComponent(v));
}
File diff suppressed because it is too large Load Diff
+5 -2
View File
@@ -8,6 +8,7 @@
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
import { track } from '$lib/analytics.js';
import { openFeedback } from '$lib/feedback.svelte.js';
import Footer from '$lib/components/Footer.svelte';
import AccountPanel from '$lib/components/AccountPanel.svelte';
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
import LanePicker from '$lib/components/LanePicker.svelte';
@@ -100,7 +101,7 @@
<header class="bar">
<div class="container inner">
<a class="brand" href="/" aria-label="Upbeat Bytes — home"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
<a class="brand" href="/" aria-label="upbeatBytes — home"><img class="logo" src="/logo.svg" alt="upbeatBytes" /></a>
<div class="baractions">
<button class="fb" onclick={openFeedback} aria-label="Share feedback" title="Share feedback">
<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><path d="M4 5h16v11H8l-4 3z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round" /></svg>
@@ -193,7 +194,7 @@
<p class="gate">Sign in, then follow sources and topics to build your own calm lane.</p>
{:else}
<h2>Following</h2>
<p class="dnote">Sources and topics you follow feed your <a href="/?view=following">Following</a> lane. Open any source or grouping and tap <strong>Follow</strong> to add it.</p>
<p class="dnote">Sources and topics you follow feed your <a href="/news?view=following">Following</a> lane. Open any source or grouping and tap <strong>Follow</strong> to add it.</p>
{#if follows.length}
<ul class="follows">
{#each follows as f (f.kind + ':' + f.value)}
@@ -228,6 +229,8 @@
</div>
</main>
<Footer />
<style>
.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
+185 -11
View File
@@ -22,6 +22,22 @@
try { await loadStats(); } catch { error = "Couldn't load stats."; }
}
// Load errors: read/unread triage. Default view = unread; reading/marking clears them
// from the list (filter to see read/all). Re-load stats so the headline count tracks.
let ceFilter = $state('unread');
async function loadClientErrors() {
try { clientErrors = await getJSON('/api/admin/client-errors?show=' + ceFilter); } catch { /* keep */ }
}
async function setCeFilter(f) { if (f === ceFilter) return; ceFilter = f; await loadClientErrors(); }
async function markCeRead(id, read) {
try { await postJSON(`/api/admin/client-errors/${id}/read`, { read }); await loadClientErrors(); await loadStats(); }
catch { /* ignore */ }
}
async function markAllCeRead() {
try { await postJSON('/api/admin/client-errors/read-all', {}); await loadClientErrors(); await loadStats(); }
catch { /* ignore */ }
}
onMount(async () => {
if (!auth.ready) await refresh();
if (!auth.user || !auth.user.is_admin) {
@@ -218,6 +234,30 @@
try { await delJSON('/api/admin/bloom/overrides/' + encodeURIComponent(w)); await loadBloomQueue(); } catch { /* ignore */ }
}
// --- Small Joys: On This Day / Quote / Word pools ---
let joyKind = $state('onthisday');
let joyItems = $state([]);
let joyLoading = $state(false);
let joyAdd = $state({});
let joyAddErr = $state('');
async function loadJoys(k) {
joyLoading = true;
try { joyItems = await getJSON('/api/admin/joys/' + k); } catch { joyItems = []; }
joyLoading = false;
}
async function joyMutate(id, action, fields) {
try { await postJSON(`/api/admin/joys/${joyKind}/items/${id}`, { action, fields }); await loadJoys(joyKind); } catch { /* ignore */ }
}
async function joyAddSubmit() {
joyAddErr = '';
try { await postJSON(`/api/admin/joys/${joyKind}/add`, joyAdd); joyAdd = {}; await loadJoys(joyKind); }
catch (e) { joyAddErr = e?.message || 'Could not add'; }
}
async function joyRepick() {
try { await postJSON(`/api/admin/joys/${joyKind}/repick`, {}); } catch { /* ignore */ }
}
$effect(() => { const k = joyKind; if (section === 'joys') loadJoys(k); });
// --- Games: Daily Word pool ---
let wpWord = $state('');
let wpResult = $state(null); // lookup result for the current input
@@ -335,6 +375,7 @@
{ key: 'audience', label: 'Audience' },
{ key: 'feedback', label: 'Feedback' },
{ key: 'games', label: 'Games' },
{ key: 'joys', label: 'Small Joys' },
{ key: 'publish', label: 'Publishing' },
];
const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
@@ -470,6 +511,16 @@
} catch (e) { s._artErr = e?.message || 'Could not set the override.'; }
}
// Per-source image rights policy: 'cache' (re-host a downscaled copy — only for sources
// cleared by license/permission/PD), 'remote' (hotlink the publisher image — default),
// 'none' (no image). Conservative by default; caching is opt-in.
async function setImagePolicy(s, policy) {
const prev = s.image_policy;
s.image_policy = policy; // optimistic
try { await postJSON(`/api/admin/sources/${s.id}/image-policy`, { policy }); }
catch (e) { s.image_policy = prev; s._artErr = e?.message || 'Could not set the image policy.'; }
}
// --- Source candidates: supervised "add a source" pipeline ---
let candidates = $state([]);
let newFeedUrl = $state('');
@@ -650,7 +701,7 @@
<header class="bar">
<div class="container inner">
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="upbeatBytes" /></a>
<a class="back" href="/account"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Account</a>
</div>
</header>
@@ -693,34 +744,49 @@
<h2>Pulse</h2>
<div class="cards">
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Visitors today</span></div>
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Visitors (7d)</span></div>
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Visitors ({range}d)</span></div>
<div class="stat"><span class="n">{stats.visitors.engaged_today}</span><span class="l">Engaged today</span></div>
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Visits today (raw)</span></div>
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Visits (7d)</span></div>
<div class="stat"><span class="n">{stats.visitors.d30}</span><span class="l">Visits ({range}d)</span></div>
<div class="stat"><span class="n">{stats.content.served}</span><span class="l">Articles live</span></div>
<div class="stat"><span class="n">{stats.content.accepted_7d}</span><span class="l">Fresh (7d)</span></div>
<div class="stat"><span class="n">{stats.content.latest_brief_size}</span><span class="l">In today's brief</span></div>
<div class="stat"><span class="n">{healthy}/{sources.length}</span><span class="l">Sources healthy</span></div>
<div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Accounts</span></div>
{#if stats.client_errors}
<div class="stat" class:alert={stats.client_errors.today > 0}>
<span class="n">{stats.client_errors.today}</span><span class="l">Load errors today</span>
<div class="stat" class:alert={stats.client_errors.unread > 0}>
<span class="n">{stats.client_errors.unread}</span><span class="l">Unread load errors</span>
</div>
{/if}
</div>
<h2 class="ce-head">
<span>Load errors</span>
<span class="ce-tabs">
{#each [['unread', 'Unread'], ['read', 'Read'], ['all', 'All']] as [k, lbl]}
<button class="ce-tab" class:on={ceFilter === k} onclick={() => setCeFilter(k)}>{lbl}</button>
{/each}
{#if clientErrors.some((e) => !e.read)}
<button class="ce-allread" onclick={markAllCeRead}>Mark all read</button>
{/if}
</span>
</h2>
{#if clientErrors.length}
<h2>Recent load errors <span class="count">(last {clientErrors.length} · newest {ago(clientErrors[0]?.created_at)})</span></h2>
<ul class="cerrs">
{#each clientErrors as e (e.created_at + e.reason)}
{#each clientErrors as e (e.id)}
{@const t = errType(e)}
<li class:bot={e.bot}>
<li class:bot={e.bot} class:isread={e.read}>
<span class="ce-when">{fdate(e.created_at)}</span>
<span class="ce-reason"><span class="ce-tag {t.k}">{t.label}</span>{e.reason || '—'}</span>
<span class="ce-path">{e.path || '/'}</span>
<button class="ce-mark" title={e.read ? 'Mark unread' : 'Mark read'}
onclick={() => markCeRead(e.id, !e.read)}>{e.read ? '↩ unread' : '✓ read'}</button>
<span class="ce-ua">{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}</span>
</li>
{/each}
</ul>
{:else}
<p class="ce-empty">{ceFilter === 'unread' ? 'All clear — no unread load errors. ✨' : 'Nothing to show.'}</p>
{/if}
{:else if section === 'content'}
@@ -970,6 +1036,14 @@
<option value="paywalled" selected={s._artSummary.paywall_override === 'paywalled'}>Treat as paywalled</option>
</select>
</div>
<div class="pwctl">
<span>Images: <span class="pwrule" class:on={s.image_policy === 'cache'}>{s.image_policy || 'remote'}</span></span>
<select class="pwsel" onchange={(e) => setImagePolicy(s, e.currentTarget.value)}>
<option value="remote" selected={(s.image_policy || 'remote') === 'remote'}>Remote hotlink (default)</option>
<option value="cache" selected={s.image_policy === 'cache'}>Cache re-host downscaled (cleared only)</option>
<option value="none" selected={s.image_policy === 'none'}>None typographic cover</option>
</select>
</div>
{/if}
<div class="artfilters">
{#each ART_FILTERS as [key, label] (key)}
@@ -1016,7 +1090,16 @@
{:else if section === 'audience'}
<p class="exporthdr"><a class="exportlink" href={'/api/admin/export/audience.csv?days=' + range} download>export audience CSV ({range}d) ↓</a></p>
<section>
<h2>Visitors</h2>
<h2>Engaged readers <span class="count">(distinct visitor-days with a real gesture or deliberate action)</span></h2>
<p class="muted" style="font-size:.85rem;margin:2px 0 12px;">Engagement tracking began 2026-06-30 — rolling windows are still filling, so a low 7d/{range}d here is partly warm-up, not all bots. Compare d7 confidently after a week, the {range}d window after {range} days.</p>
<div class="cards">
<div class="stat"><span class="n">{stats.visitors.engaged_today}</span><span class="l">Today</span></div>
<div class="stat"><span class="n">{stats.visitors.engaged_d7}</span><span class="l">Last 7 days</span></div>
<div class="stat"><span class="n">{stats.visitors.engaged_d30}</span><span class="l">Last {range} days</span></div>
</div>
</section>
<section>
<h2>Recorded visits <span class="count">(raw — includes UA-spoofing bots)</span></h2>
<div class="cards">
<div class="stat"><span class="n">{stats.visitors.today}</span><span class="l">Today</span></div>
<div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Last 7 days</span></div>
@@ -1460,6 +1543,63 @@
</ul>
</details>
{/if}
{:else if section === 'joys'}
<div class="joy-bar">
<div class="joy-kinds">
<button class:on={joyKind === 'onthisday'} onclick={() => (joyKind = 'onthisday')}>On This Day</button>
<button class:on={joyKind === 'quote'} onclick={() => (joyKind = 'quote')}>Quote</button>
<button class:on={joyKind === 'word'} onclick={() => (joyKind = 'word')}>Word</button>
</div>
<button class="joy-repick" onclick={joyRepick}>Re-pick today</button>
</div>
<details class="joy-add">
<summary>Add {joyKind === 'word' ? 'a word' : joyKind === 'quote' ? 'a quote' : 'a fact'}</summary>
<div class="joy-add-fields">
{#if joyKind === 'word'}
<input placeholder="word (looked up in the dictionary)" bind:value={joyAdd.word} />
{:else if joyKind === 'quote'}
<textarea placeholder="quote text" bind:value={joyAdd.text}></textarea>
<input placeholder="author" bind:value={joyAdd.author} />
<input placeholder="work / source (optional)" bind:value={joyAdd.work} />
{:else}
<input placeholder="MM-DD (e.g. 06-22)" bind:value={joyAdd.md} />
<input placeholder="year" type="number" bind:value={joyAdd.year} />
<textarea placeholder="what happened" bind:value={joyAdd.text}></textarea>
<textarea placeholder="summary (optional)" bind:value={joyAdd.summary}></textarea>
{/if}
<button onclick={joyAddSubmit}>Add</button>
{#if joyAddErr}<span class="cerr">{joyAddErr}</span>{/if}
</div>
</details>
{#if joyLoading}<p class="muted">Loading…</p>{/if}
<ul class="joy-list">
{#each joyItems as it (it.id)}
<li class="joy-row" class:blocked={it.blocked}>
<div class="joy-main">
{#if joyKind === 'word'}
<div><strong>{it.word}</strong> <span class="muted small">{it.part_of_speech ?? ''} {it.phonetic ?? ''}</span></div>
<div class="joy-sub">{it.definition}</div>
{:else if joyKind === 'quote'}
<div class="joy-sub">{it.text}</div>
<span class="muted small">{it.author}{#if it.work}, {it.work}{/if}</span>
{:else}
<span class="muted small">{it.md} · {it.year ?? ''}</span>
<div class="joy-sub">{it.text}</div>
{/if}
</div>
<div class="joy-acts">
{#if it.featured}<span class="joy-badge">featured</span>{/if}
{#if it.shown_at}<span class="muted small">shown {it.shown_at}</span>{/if}
<button onclick={() => joyMutate(it.id, it.featured ? 'unfeature' : 'feature')}>{it.featured ? 'Unfeature' : 'Feature'}</button>
<button onclick={() => joyMutate(it.id, it.blocked ? 'unblock' : 'block')}>{it.blocked ? 'Unblock' : 'Block'}</button>
<button class="joy-del" onclick={() => joyMutate(it.id, 'delete')}>Delete</button>
</div>
</li>
{/each}
</ul>
{/if}
{/if}
</main>
@@ -1797,9 +1937,21 @@
.stat.alert { background: #f3e0e0; }
.stat.alert .n { color: #9a3b3b; }
.ce-head { display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap; }
.ce-tabs { display: inline-flex; align-items: center; gap: 6px; }
.ce-tab, .ce-allread { border: 1px solid var(--line); background: var(--surface); color: var(--muted);
border-radius: 999px; padding: 2px 12px; font-size: 0.78rem; font-family: var(--label); }
.ce-tab.on { background: var(--accent-soft); color: var(--accent-deep); border-color: var(--accent-soft); font-weight: 600; }
.ce-allread { margin-left: 6px; color: var(--accent-deep); }
.ce-allread:hover { background: var(--accent-soft); }
.ce-empty { color: var(--muted); font-size: 0.9rem; margin: 10px 0 0; }
.cerrs { list-style: none; padding: 0; margin: 10px 0 0; display: flex; flex-direction: column; gap: 6px; }
.cerrs li { display: grid; grid-template-columns: auto 1fr auto; gap: 6px 12px; align-items: baseline;
.cerrs li { display: grid; grid-template-columns: auto 1fr auto auto; gap: 6px 12px; align-items: baseline;
font-size: 0.82rem; padding: 8px 12px; background: var(--surface); border: 1px solid var(--line); border-radius: 8px; }
.cerrs li.isread { opacity: 0.5; }
.ce-mark { border: 1px solid var(--line); background: var(--surface); color: var(--muted); white-space: nowrap;
border-radius: 6px; padding: 1px 9px; font-size: 0.72rem; font-family: var(--label); }
.ce-mark:hover { border-color: var(--accent); color: var(--accent-deep); }
.ce-when { color: var(--muted); white-space: nowrap; }
.ce-reason { font-family: var(--label); color: #9a3b3b; }
/* Layer tag — html-slow is the only incident-grade one, so it reads loudest. */
@@ -1940,4 +2092,26 @@
.pub-actions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
.pub-confirm { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 10px; background: var(--accent-soft); }
.hin.wide { width: 100%; box-sizing: border-box; margin: 4px 0; }
/* Small Joys */
.joy-bar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; }
.joy-kinds { display: flex; gap: 8px; }
.joy-kinds button { padding: 6px 14px; border: 1px solid var(--line); border-radius: 999px; background: #fff; cursor: pointer; font-size: 0.9rem; }
.joy-kinds button.on { background: var(--accent); color: #fff; border-color: var(--accent); }
.joy-repick { padding: 6px 14px; border: 1px solid var(--line); border-radius: 8px; background: #fff; cursor: pointer; font-size: 0.9rem; }
.joy-add { margin-bottom: 14px; }
.joy-add summary { cursor: pointer; font-weight: 600; color: var(--accent); }
.joy-add-fields { display: flex; flex-direction: column; gap: 8px; max-width: 540px; margin-top: 10px; }
.joy-add-fields input, .joy-add-fields textarea { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; font: inherit; }
.joy-add-fields textarea { min-height: 60px; resize: vertical; }
.joy-add-fields button { align-self: flex-start; padding: 7px 16px; border: none; border-radius: 8px; background: var(--accent); color: #fff; cursor: pointer; }
.joy-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.joy-row { display: flex; justify-content: space-between; gap: 14px; align-items: flex-start; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
.joy-row.blocked { opacity: 0.5; }
.joy-main { min-width: 0; flex: 1; }
.joy-sub { margin-top: 3px; line-height: 1.4; }
.joy-acts { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; flex-shrink: 0; }
.joy-acts button { padding: 4px 10px; border: 1px solid var(--line); border-radius: 7px; background: #fff; cursor: pointer; font-size: 0.82rem; }
.joy-acts .joy-del { color: #b23b3b; border-color: #e6c4c4; }
.joy-badge { font-size: 0.72rem; font-weight: 700; color: var(--accent); background: var(--accent-soft); border-radius: 999px; padding: 2px 8px; }
</style>
+670
View File
@@ -0,0 +1,670 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import { afterNavigate, goto } from '$app/navigation';
import HubBar from '$lib/components/HubBar.svelte';
import Footer from '$lib/components/Footer.svelte';
// Virtual frames the viewer can switch between — remembered locally, no account needed.
const FRAMES = [
{ id: 'walnut', label: 'Walnut' },
{ id: 'oak', label: 'Oak' },
{ id: 'mahogany', label: 'Mahogany' },
{ id: 'gold', label: 'Gold' },
{ id: 'silver', label: 'Silver' },
{ id: 'black', label: 'Black' },
{ id: 'none', label: 'No frame' },
];
let art = $state(null);
let state = $state('loading'); // loading | ready | empty
let zoom = $state(false);
let frame = $state('walnut');
let thickness = $state(1); // frame-scale multiplier, 0.71.9 (mat caps at 1.5)
let lightboxEl = $state(null);
// Wide paintings fill a portrait phone far better turned sideways: when the art is
// landscape we rotate the fullscreen view 90° (CSS handles WHEN — only narrow+portrait
// screens; desktop and portrait art stay upright). Aspect is read off the loaded image.
let landscape = $state(false);
// Desktop zoom: a gallery inspector inside the lightbox. Variable 1×–4× via a floating
// toolbar ( / slider / + / % / Fit); DRAG the artwork to pan (translate persists), so you
// can place a spot and keep zooming into it — zoom holds the viewport-centred point fixed.
// Mobile keeps native pinch — the Zoom button + toolbar are hidden there.
let zoomed = $state(false);
let zoomLevel = $state(1.5); // scale factor; enter at a gentle 1.5×
let tx = $state(0), ty = $state(0); // pan offset in px (translate)
let dragging = $state(false);
let zoomBoxEl, zoomImgEl; // refs for clamping pan to the image bounds
let lastX = 0, lastY = 0, activePointer = null;
function enterZoom() { zoomLevel = 1.5; tx = 0; ty = 0; dragging = false; activePointer = null; zoomed = true; }
function fit() { dragging = false; activePointer = null; zoomed = false; } // back to the framed gallery view
function clampPan() {
if (!zoomImgEl || !zoomBoxEl) return;
const maxX = Math.max(0, (zoomImgEl.offsetWidth * zoomLevel - zoomBoxEl.clientWidth) / 2);
const maxY = Math.max(0, (zoomImgEl.offsetHeight * zoomLevel - zoomBoxEl.clientHeight) / 2);
tx = Math.max(-maxX, Math.min(maxX, tx));
ty = Math.max(-maxY, Math.min(maxY, ty));
}
function setZoom(v) {
const z = Math.max(1, Math.min(4, Math.round(v * 10) / 10));
if (z === zoomLevel) return;
const ratio = z / zoomLevel; // keep the centred spot fixed as we zoom
tx *= ratio; ty *= ratio; zoomLevel = z;
clampPan();
}
function dragStart(e) {
if (dragging || e.button !== 0) return; // primary button only; ignore a 2nd pointer mid-drag
dragging = true; lastX = e.clientX; lastY = e.clientY; activePointer = e.pointerId;
e.currentTarget.setPointerCapture?.(e.pointerId);
}
function dragMove(e) {
if (!dragging || e.pointerId !== activePointer) return; // only the pointer that started the drag
tx += e.clientX - lastX; ty += e.clientY - lastY;
lastX = e.clientX; lastY = e.clientY;
clampPan();
}
// Ends drag on pointerup / pointercancel / lostpointercapture (NOT pointerleave, which
// fights the capture). Release only what we actually hold, so no double-release throws.
function dragEnd(e) {
dragging = false;
const el = e.currentTarget;
if (activePointer !== null && el?.hasPointerCapture?.(activePointer)) el.releasePointerCapture(activePointer);
activePointer = null;
}
function onKey(e) {
if (e.key !== 'Escape') return;
if (zoomed) { zoomed = false; dragging = false; activePointer = null; } // Escape steps out of inspection first, then closes
else if (zoom) zoom = false;
}
// Leaving the lightbox always resets the inspector, so re-opening starts framed.
$effect(() => { if (!zoom) { zoomed = false; dragging = false; activePointer = null; zoomLevel = 1.5; tx = 0; ty = 0; } });
// Move focus to the lightbox when it opens, so Escape/Enter work and focus is trapped sanely.
$effect(() => { if (zoom && lightboxEl) lightboxEl.focus(); });
// Same single-history Back as the hub pages: return to where you came from (the hub),
// or fall back to it on a cold deep-link without pushing a new entry.
let cameFromApp = $state(false);
afterNavigate(({ from }) => { if (from) cameFromApp = true; });
function goBack() {
if (cameFromApp && typeof history !== 'undefined') history.back();
else goto('/', { replaceState: true });
}
// Woods are built from four real mitered rails (grain turns at the corners); metals/none aren't.
let isWood = $derived(['walnut', 'oak', 'mahogany'].includes(frame));
onMount(async () => {
try {
const saved = localStorage.getItem('ub_art_frame');
if (saved && FRAMES.some((f) => f.id === saved)) frame = saved;
const t = parseFloat(localStorage.getItem('ub_art_thickness'));
if (t >= 0.7 && t <= 1.9) thickness = t;
} catch { /* private mode — defaults are fine */ }
try {
art = await getJSON('/api/art/today');
state = art?.image_url ? 'ready' : 'empty';
} catch {
state = 'empty';
}
});
function setFrame(id) {
frame = id;
try { localStorage.setItem('ub_art_frame', id); } catch { /* ignore */ }
}
function saveThickness() {
try { localStorage.setItem('ub_art_thickness', String(thickness)); } catch { /* ignore */ }
}
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
let dateLabel = $derived.by(() => {
if (!art?.date) return '';
const [, m, d] = art.date.split('-').map(Number);
return MONTHS[m - 1] ? `${MONTHS[m - 1]} ${d}` : '';
});
// "Winslow Homer, 1865 — oil on canvas"
let attribution = $derived.by(() => {
if (!art) return '';
const who = [art.artist, art.date_text].filter(Boolean).join(', ');
return art.medium ? (who ? `${who} — ${art.medium}` : art.medium) : who;
});
let dlName = $derived(art ? `${(art.title || 'artwork').replace(/[^\w \-]+/g, '').trim()}.jpg` : 'artwork.jpg');
let detailsOpen = $state(true); // mobile: collapse the writeup to bring the controls up near the art
let copied = $state(false);
async function share() {
const url = art?.source_url || location.href;
try {
if (navigator.share) { await navigator.share({ title: art?.title || 'Daily Art', url }); return; }
await navigator.clipboard.writeText(url);
copied = true; setTimeout(() => (copied = false), 1800);
} catch { /* cancelled / unsupported — no-op */ }
}
</script>
<svelte:window onkeydown={onKey} />
<svelte:head>
<title>Daily Art · upbeatBytes</title>
<meta name="description" content="A masterwork a day from the world's open museum collections — one piece, beautifully framed, on upbeatBytes." />
</svelte:head>
{#snippet woodRails()}
<span class="rail rail--t"></span>
<span class="rail rail--r"></span>
<span class="rail rail--b"></span>
<span class="rail rail--l"></span>
{/snippet}
<div class="room">
<HubBar active="art" />
<main class="gallery">
<button class="back" onclick={goBack} aria-label="Go back">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 18l-6-6 6-6" />
</svg>
Back
</button>
{#if state === 'ready'}
<!-- "The Story": the guide write-up on the left, the framed piece + controls on the right -->
<article class="story-card">
<div class="left">
<div class="head">
<div class="kicker"><span class="kicker-rule"></span>Daily Art{#if dateLabel} · {dateLabel}{/if}</div>
<h1 class="art-title">{art.title}</h1>
{#if attribution}<p class="attribution">{attribution}</p>{/if}
</div>
<div class="body">
<button class="details-toggle" onclick={() => (detailsOpen = !detailsOpen)}
aria-expanded={detailsOpen} aria-controls="art-details">
About this piece
<svg class="chev" class:open={detailsOpen} viewBox="0 0 24 24" width="14" height="14"
fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<div class="body-content" id="art-details" class:collapsed={!detailsOpen}>
{#if art.blurb}<p class="blurb">{art.blurb}</p>{/if}
<div class="meta-cols">
<div class="meta"><span class="meta-label">Collection</span><span class="meta-val">{art.museum}</span></div>
{#if art.license}<div class="meta"><span class="meta-label">Rights</span><span class="meta-val">{art.license}</span></div>{/if}
</div>
{#if art.source_url}
<a class="cta" href={art.source_url} target="_blank" rel="noopener">View at {art.museum}</a>
{/if}
</div>
</div>
</div>
<div class="right">
<div class="art-stage">
<button class="frame frame--{frame} art-frame" style="--frame-scale:{thickness}"
onclick={() => (zoom = true)} aria-label="Expand artwork">
{#if isWood}{@render woodRails()}{/if}
<span class="mat">
<img src={art.image_url} alt={art.title}
onload={(e) => (landscape = e.currentTarget.naturalWidth > e.currentTarget.naturalHeight)} />
<span class="hint">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" />
</svg>
Expand
</span>
</span>
</button>
</div>
<div class="controls">
<div class="ctl-row">
<span class="ctl-label">Frame</span>
<div class="swatches">
{#each FRAMES as f}
<button class="swatch swatch--{f.id}" class:on={frame === f.id}
onclick={() => setFrame(f.id)} aria-pressed={frame === f.id} title={f.label}>
<span class="sr">{f.label}</span>
</button>
{/each}
</div>
</div>
{#if frame !== 'none'}
<div class="ctl-row">
<span class="ctl-label">Thickness</span>
<input type="range" min="0.7" max="1.9" step="0.05"
bind:value={thickness} oninput={saveThickness} aria-label="Frame thickness" />
</div>
{/if}
{#if art.palette?.length}
<div class="ctl-divider"></div>
<div class="ctl-label">Colors in this piece</div>
<div class="palette" aria-hidden="true">
{#each art.palette as c}<span class="chip" style="background:{c}"></span>{/each}
</div>
{/if}
<div class="actions">
<button class="act act-share" onclick={share}>{copied ? 'Link copied' : '↗ Share'}</button>
{#if art.is_public_domain}
<a class="act act-dl" href={art.image_url_large || art.image_url} download={dlName}>↓ Download</a>
{/if}
</div>
</div>
</div>
</article>
{:else if state === 'empty'}
<p class="note">The gallery's resting — a new piece is hung each morning. Check back soon.</p>
{:else}
<p class="note">Hanging today's piece…</p>
{/if}
</main>
<Footer />
</div>
{#if zoom && art}
<div class="lightbox" class:rotate={landscape && !zoomed} class:zoomed bind:this={lightboxEl}
tabindex="-1" role="dialog" aria-modal="true" aria-label="{art.title}, full screen">
<button class="lb-backdrop" onclick={() => (zoom = false)} aria-label="Close artwork"></button>
<button class="lb-close" onclick={() => (zoom = false)} aria-label="Close">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
{#if zoomed}
<!-- gallery inspector: DRAG to pan; zoom with the toolbar; Fit/Esc to exit -->
<div class="lb-zoom" class:dragging bind:this={zoomBoxEl}
onpointerdown={dragStart} onpointermove={dragMove} onpointerup={dragEnd}
onpointercancel={dragEnd} onlostpointercapture={dragEnd}
role="img" aria-label="{art.title}, magnified — drag to pan">
<img bind:this={zoomImgEl} src={art.image_url_large || art.image_url} alt={art.title}
style="transform:translate({tx}px,{ty}px) scale({zoomLevel})" draggable="false" />
</div>
<div class="lb-tools" role="group" aria-label="Zoom controls">
<button class="lb-t" onclick={() => setZoom(zoomLevel - 0.1)} aria-label="Zoom out"></button>
<input class="lb-slider" type="range" min="1" max="4" step="0.1" value={zoomLevel}
oninput={(e) => setZoom(+e.currentTarget.value)}
aria-label="Zoom level" aria-valuetext="{Math.round(zoomLevel * 100)} percent" />
<button class="lb-t" onclick={() => setZoom(zoomLevel + 0.1)} aria-label="Zoom in">+</button>
<span class="lb-pct">{Math.round(zoomLevel * 100)}%</span>
<button class="lb-fit" onclick={fit}>Fit</button>
</div>
{:else}
<span class="lb-stage">
<span class="frame frame--{frame} lb-frame" style="--frame-scale:{thickness}">
{#if isWood}{@render woodRails()}{/if}
<span class="mat"><img src={art.image_url_large || art.image_url} alt={art.title} /></span>
</span>
<span class="lb-cap">{art.title}{#if art.artist}<span class="sep">·</span>{art.artist}{/if}</span>
<button class="lb-zoombtn" onclick={enterZoom}>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="M21 21l-3.5-3.5M11 8v6M8 11h6" /></svg>
Zoom in
</button>
</span>
{/if}
</div>
{/if}
<style>
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-italic-var.woff2') format('woff2'); font-weight: 400 500; font-style: italic; font-display: swap; }
@font-face { font-family: 'Space Mono'; src: url('/fonts/space-mono-latin.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
/* --- "The Story": editorial Daily Art page. Daily-Art identity = the purple accent. --- */
.room {
--canvas: #faf6ee; /* warm room ground */
--card: #ece1cc; /* warm-tan story card */
--art-band: #ddcfb2; /* deeper tan behind the framed piece */
--ink: #232a31; /* dark slate — titles */
--story: #4f4a3f; /* warm body text */
--muted: #8a8273;
--label: #4a4334; /* Space Mono micro-labels — darker so they stand out */
--line: #ece5d8;
--accent: #8857C2; /* Daily-Art purple (matches the home tile) */
--accent-deep: #6f42a8;
min-height: 100vh;
background: var(--canvas);
color: var(--ink);
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
overflow-x: clip; /* seatbelt: nothing can scroll the page sideways */
}
/* /art runs wider than the hub's 1180 column — the artwork is the point, and the
mobile view is separate, so the desktop card gets room for a properly-sized piece. */
.gallery {
flex: 1; width: 100%; max-width: 1280px; margin: 0 auto;
padding: clamp(10px, 2vw, 22px) clamp(18px, 5vw, 44px) clamp(28px, 5vw, 48px);
box-sizing: border-box;
display: flex; flex-direction: column; justify-content: center;
}
/* top-left Back */
.back {
align-self: flex-start; display: inline-flex; align-items: center; gap: 6px;
margin: 0 0 clamp(10px, 2vw, 16px); padding: 6px 10px 6px 0;
background: none; border: none; cursor: pointer; font: inherit; font-size: 14px;
font-weight: 600; color: var(--muted); transition: color 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.back:hover { color: var(--accent); }
.back svg { transition: transform 0.15s ease; }
.back:hover svg { transform: translateX(-2px); }
/* The Story card: writeup (left) + framed art & controls (right). On phones the wrappers
collapse (display:contents) so the pieces reflow to head → art → writeup → controls. */
.story-card {
display: flex; border-radius: 22px; overflow: hidden; background: var(--card);
box-shadow: 0 16px 40px -22px rgba(120, 95, 50, 0.32);
}
.left { flex: 1; min-width: 0; padding: clamp(28px, 4vw, 50px) clamp(24px, 3.4vw, 46px); display: flex; flex-direction: column; justify-content: center; }
.head, .body, .body-content { display: flex; flex-direction: column; } /* so .cta align-self works */
.details-toggle { display: none; } /* desktop: the writeup is always shown in the left column */
.chev { transition: transform 0.2s ease; }
.kicker { display: inline-flex; align-items: center; gap: 9px; font-family: 'Space Mono', monospace; font-size: 12px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--accent); }
.kicker-rule { width: 18px; height: 2px; background: var(--accent); border-radius: 2px; }
.art-title { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: clamp(2rem, 4.2vw, 2.9rem); line-height: 1.05; letter-spacing: -0.01em; color: var(--ink); margin: 14px 0 0; }
.attribution { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: clamp(1rem, 1.6vw, 1.13rem); color: #7a7263; margin: 12px 0 0; }
.blurb { font-size: clamp(0.97rem, 1.25vw, 1.02rem); line-height: 1.72; color: var(--story); margin: 20px 0 0; max-width: 44ch; text-wrap: pretty; }
.meta-cols { display: flex; gap: 36px; margin-top: 26px; padding-top: 22px; border-top: 1px solid rgba(120, 95, 50, 0.20); }
.meta { display: flex; flex-direction: column; gap: 4px; }
.meta-label { font-family: 'Space Mono', monospace; font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--label); }
.meta-val { font-size: 14px; font-weight: 600; color: #3f3a30; }
.cta { align-self: flex-start; margin-top: 26px; background: var(--accent); color: #fff; font-size: 14px; font-weight: 700; padding: 13px 22px; border-radius: 9px; text-decoration: none; transition: background 0.15s ease; -webkit-tap-highlight-color: transparent; }
.cta:hover { background: var(--accent-deep); }
/* right column: the framed piece on a deeper-tan ground, controls beneath */
.right { flex: 0 0 50%; background: var(--art-band); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: clamp(28px, 3vw, 42px) clamp(20px, 2.5vw, 34px); }
.art-stage { display: flex; justify-content: center; width: 100%; }
.art-frame { max-width: 100%; }
.controls { width: 100%; max-width: 440px; margin-top: clamp(24px, 3vw, 32px); }
.ctl-row { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
.ctl-row:first-child { margin-top: 0; }
.ctl-label { font-family: 'Space Mono', monospace; font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--label); }
.ctl-row > .ctl-label { width: 78px; flex: none; }
.swatches { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.ctl-row input[type="range"] { flex: 1; accent-color: var(--accent); cursor: pointer; }
.ctl-divider { height: 1px; background: rgba(120, 95, 50, 0.18); margin: 24px 0 18px; }
.palette { display: flex; gap: 9px; margin-top: 11px; }
.chip { flex: 1; max-width: 46px; height: 34px; border-radius: 8px; }
.actions { display: flex; gap: 11px; margin-top: 24px; }
.act { flex: 1; text-align: center; font-size: 13.5px; font-weight: 700; padding: 12px 0; border-radius: 9px; cursor: pointer; text-decoration: none; -webkit-tap-highlight-color: transparent; }
.act-share { background: var(--accent); color: #fff; border: none; font-family: inherit; transition: background 0.15s ease; }
.act-share:hover { background: var(--accent-deep); }
.act-dl { border: 1.5px solid #c7b48f; color: var(--story); background: transparent; display: inline-flex; align-items: center; justify-content: center; transition: border-color 0.15s ease, color 0.15s ease; }
.act-dl:hover { border-color: var(--accent); color: var(--accent); }
/* Phones: collapse the wrappers so the four blocks reflow into one calm column —
head → artwork → writeup → controls (the artwork sits high, seen before it's read). */
@media (max-width: 760px) {
.gallery { justify-content: flex-start; }
.story-card { flex-direction: column; }
.left, .right { display: contents; } /* promote head/body/art-stage/controls */
.head { order: 1; padding: clamp(22px, 5vw, 28px) clamp(18px, 5vw, 22px) 14px; }
.art-stage { order: 2; background: var(--art-band); padding: 22px 18px; box-sizing: border-box; }
.body { order: 3; padding: 6px clamp(18px, 5vw, 22px) 4px; }
/* writeup collapses (tap "About this piece") so the controls rise up beside the art */
.details-toggle {
display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%;
background: none; border: none; cursor: pointer; padding: 14px 0;
font-family: 'Space Mono', monospace; font-size: 11px; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--label); -webkit-tap-highlight-color: transparent;
border-top: 1px solid rgba(120, 95, 50, 0.16);
}
.chev.open { transform: rotate(180deg); }
.body-content.collapsed { display: none; }
.controls { order: 4; width: auto; max-width: none; box-sizing: border-box;
margin: 16px clamp(14px, 4vw, 18px) clamp(20px, 5vw, 24px);
background: #fff; border: 1px solid #ece3d0; border-radius: 16px; padding: 18px 16px; }
.meta-cols { gap: 28px; }
.cta { align-self: stretch; text-align: center; }
}
/* The frame: a beveled moulding (wood/metal) around a cream mat around the art.
--rail / --mat are the moulding and mat widths; both scale with --frame-scale (the
thickness slider). EVERY variant — even "No frame" — reserves the same footprint, so
switching frames never reflows the page. */
.frame {
box-sizing: border-box;
/* Rail scales across the whole slider; the mat caps at scale 1.5 so the top of the
slider thickens only the moulding (wood), holding the white border steady. */
--rail: calc(clamp(11px, 2.3vw, 22px) * var(--frame-scale, 1));
--mat: calc(clamp(10px, 2.4vw, 22px) * min(var(--frame-scale, 1), 1.5));
border: none; cursor: zoom-in; background: none;
position: relative; line-height: 0; display: inline-block; max-width: 100%;
border-radius: 4px; padding: var(--rail);
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.20), 0 3px 10px rgba(20, 30, 45, 0.10);
}
/* The three woods share one real (CC0, Poly Haven) fine-grain texture, recolored per
species with a CSS filter, with a soft bevel baked into the texture layer. Metals
stay gradient. The texture sits on ::before (behind the mat); miters on ::after. */
.frame--walnut, .frame--oak, .frame--mahogany {
background: linear-gradient(135deg, #5a3a24, #34210f); /* fallback if texture misses */
}
/* Four real mitered rails: the grain TURNS at every corner — horizontal moulding on the
top/bottom rails (rotated texture), vertical on the sides — like four cut lengths of
wood. Each rail is a trapezoid clipped to 45° at the joints; --rail drives size + cut.
A directional bevel per rail (lit top-left) gives the moulding a rounded profile. */
.rail {
position: absolute; z-index: 0; pointer-events: none;
background-position: center; background-size: cover; background-repeat: no-repeat;
}
.rail--t { top: 0; left: 0; right: 0; height: var(--rail);
clip-path: polygon(0 0, 100% 0, calc(100% - var(--rail)) 100%, var(--rail) 100%);
background-image: linear-gradient(to bottom, rgba(255,255,255,0.22), rgba(0,0,0,0.22)), url("/textures/wood-grain-h.jpg"); }
.rail--b { bottom: 0; left: 0; right: 0; height: var(--rail);
clip-path: polygon(var(--rail) 0, calc(100% - var(--rail)) 0, 100% 100%, 0 100%);
background-image: linear-gradient(to top, rgba(255,255,255,0.05), rgba(0,0,0,0.36)), url("/textures/wood-grain-h.jpg"); }
.rail--l { top: 0; bottom: 0; left: 0; width: var(--rail);
clip-path: polygon(0 0, 100% var(--rail), 100% calc(100% - var(--rail)), 0 100%);
background-image: linear-gradient(to right, rgba(255,255,255,0.20), rgba(0,0,0,0.22)), url("/textures/wood-grain.jpg"); }
.rail--r { top: 0; bottom: 0; right: 0; width: var(--rail);
clip-path: polygon(0 var(--rail), 100% 0, 100% 100%, 0 calc(100% - var(--rail)));
background-image: linear-gradient(to left, rgba(255,255,255,0.05), rgba(0,0,0,0.36)), url("/textures/wood-grain.jpg"); }
/* per-species recolor, now applied to the rails */
.frame--walnut .rail { filter: brightness(1.08) saturate(1.05); }
.frame--oak .rail { filter: brightness(1.72) contrast(0.92) saturate(1.12) sepia(0.22) hue-rotate(-6deg); }
.frame--mahogany .rail { filter: brightness(0.98) contrast(1.06) saturate(1.55) sepia(0.4) hue-rotate(-16deg); }
/* Metals: fine brushed striations over an anisotropic sheen. */
.frame--gold {
background:
repeating-linear-gradient(180deg, rgba(255,255,255,0.08) 0 1px, rgba(120,80,10,0.07) 1px 2px),
linear-gradient(135deg, #b88c3d, #ecd293 42%, #a9772f 60%, #dcbd71);
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.20),
inset 0 0 0 1px rgba(255, 255, 255, 0.30),
inset 0 2px 3px rgba(255, 249, 226, 0.60),
inset 0 -3px 9px rgba(92, 62, 12, 0.48);
}
.frame--silver {
background:
repeating-linear-gradient(180deg, rgba(255,255,255,0.11) 0 1px, rgba(40,50,60,0.07) 1px 2px),
linear-gradient(135deg, #a9b0ba, #edf0f3 45%, #98a0ab 62%, #d2d7de);
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.18),
inset 0 0 0 1px rgba(255, 255, 255, 0.50),
inset 0 2px 3px rgba(255, 255, 255, 0.75),
inset 0 -3px 9px rgba(60, 70, 85, 0.42);
}
.frame--black {
background: linear-gradient(135deg, #2a2c30, #15171a 45%, #25282c 60%, #101113);
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.30),
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
inset 0 2px 3px rgba(255, 255, 255, 0.10),
inset 0 -3px 9px rgba(0, 0, 0, 0.60);
}
/* Mitered (45°) corner joints for the woods — each seam runs from the outer corner to
the mat, sized to the rail width so it tracks the thickness slider. */
.frame--walnut::after, .frame--oak::after, .frame--mahogany::after {
content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none; border-radius: inherit;
background:
linear-gradient(45deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) top left / var(--rail) var(--rail) no-repeat,
linear-gradient(135deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) top right / var(--rail) var(--rail) no-repeat,
linear-gradient(135deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) bottom left / var(--rail) var(--rail) no-repeat,
linear-gradient(45deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) bottom right / var(--rail) var(--rail) no-repeat;
}
.mat {
display: block; position: relative; z-index: 1; background: #fbf8f1; border-radius: 1px;
padding: var(--mat);
/* The mat sits recessed below the moulding: a crisp rabbet groove at the lip, the
frame casting shadow onto the top/left of the mat, the bottom/right catching light
— a small chiseled step instead of a flat transition. */
box-shadow:
inset 0 0 0 1px rgba(40, 28, 16, 0.18),
inset 0 6px 9px -3px rgba(0, 0, 0, 0.34),
inset 6px 0 9px -4px rgba(0, 0, 0, 0.24),
inset 0 -3px 6px -2px rgba(255, 255, 255, 0.55),
inset -3px 0 6px -2px rgba(255, 255, 255, 0.40);
}
.frame img { display: block; width: 100%; height: auto; max-height: 72vh; object-fit: contain; border-radius: 1px; }
/* No frame: keep the footprint (so switching never shifts the layout), drop the
moulding + mat, and let the bare art float with a soft drop shadow. */
.frame--none { background: transparent; box-shadow: none; }
.frame--none .mat { background: transparent; box-shadow: none; }
.frame--none img {
border-radius: 6px;
box-shadow: 0 18px 44px rgba(20, 30, 45, 0.20), 0 4px 12px rgba(20, 30, 45, 0.10);
}
/* A quiet, always-there affordance — no hover wobble. */
.hint {
position: absolute; right: 12px; bottom: 12px;
display: inline-flex; align-items: center; gap: 6px;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 0.76rem; font-weight: 600; line-height: 1; color: #fff;
background: rgba(20, 26, 33, 0.50); border-radius: 999px; padding: 7px 11px;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
pointer-events: none; transition: background 0.2s ease;
}
.frame:hover .hint, .frame:focus-visible .hint { background: rgba(20, 26, 33, 0.72); }
.sep { display: inline-block; margin: 0 0.5em; color: var(--muted); } /* used in the lightbox caption */
/* Frame chips read as little beveled beads; selection is a clean offset ring. */
.swatch {
width: 30px; height: 30px; border-radius: 50%; border: none; cursor: pointer; padding: 0;
box-shadow: inset 0 2px 3px rgba(255, 255, 255, 0.38),
inset 0 -3px 5px rgba(0, 0, 0, 0.30), 0 1px 3px rgba(0, 0, 0, 0.22);
outline: 2.5px solid transparent; outline-offset: 2px;
transition: outline-color 0.15s ease, transform 0.15s ease;
}
.swatch:hover { transform: translateY(-1px); }
.swatch.on { outline-color: var(--accent); }
.swatch--walnut { background: linear-gradient(150deg, #6b4528, #8a5d31 55%, #4f3420); }
.swatch--oak { background: linear-gradient(150deg, #b07f46, #cb9c5b 55%, #8c5d2f); }
.swatch--mahogany { background: linear-gradient(150deg, #5a241a, #7e3826 55%, #3a160e); }
.swatch--gold { background: linear-gradient(150deg, #c79a45, #ecd293 55%, #a9772f); }
.swatch--silver { background: linear-gradient(150deg, #aab1bb, #edf0f3 55%, #98a0ab); }
.swatch--black { background: linear-gradient(150deg, #34373c, #16181b 55%, #0c0d0f); }
.swatch--none { background: linear-gradient(150deg, #ffffff, #ece6da); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
.lightbox {
position: fixed; inset: 0; z-index: 50;
/* A soft, top-lit gallery wall — lighter than the page so every frame (Black
included) reads, like a piece hung on a real wall. */
background: linear-gradient(180deg, #efe9dd, #e2dbcc);
display: flex; flex-direction: column;
align-items: center; justify-content: center; padding: 2.5vmin;
}
/* full-bleed click target behind the content — clicking the wall closes */
.lb-backdrop { position: absolute; inset: 0; z-index: 0; border: none; background: none; padding: 0; cursor: zoom-out; }
.lb-close {
position: absolute; top: clamp(10px, 2vw, 20px); right: clamp(10px, 2vw, 20px); z-index: 3;
width: 40px; height: 40px; border-radius: 999px; border: 1px solid rgba(40, 30, 20, 0.12);
background: rgba(255, 255, 255, 0.72); color: #3a3a3a; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.lb-close:hover { background: #fff; }
/* frame + caption travel together so a rotated view turns as one piece */
.lb-stage { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 12px; }
/* magnified inspection view (desktop): cursor pans via transform-origin */
.lb-zoom {
position: relative; z-index: 1; width: 96vw; height: 88vh; overflow: hidden;
cursor: grab; display: flex; align-items: center; justify-content: center; touch-action: none;
}
.lb-zoom.dragging { cursor: grabbing; }
.lb-zoom img {
max-width: 100%; max-height: 100%; width: auto; height: auto;
will-change: transform; user-select: none; -webkit-user-drag: none;
}
/* floating gallery-inspector toolbar — a quiet translucent dark pill, white controls */
.lb-tools {
position: absolute; bottom: clamp(14px, 3vh, 28px); left: 50%; transform: translateX(-50%);
z-index: 3; display: flex; align-items: center; gap: 12px;
background: rgba(20, 18, 16, 0.72); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
border-radius: 999px; padding: 8px 14px; color: #fff; box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.5);
}
.lb-t {
width: 26px; height: 26px; flex: none; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.28);
background: none; color: #fff; cursor: pointer; font-size: 1.05rem; line-height: 1;
display: flex; align-items: center; justify-content: center;
}
.lb-t:hover { background: rgba(255, 255, 255, 0.18); }
.lb-slider { width: clamp(120px, 22vw, 220px); accent-color: #fff; cursor: pointer; }
.lb-pct { font-size: 0.8rem; font-variant-numeric: tabular-nums; min-width: 3.2em; text-align: center; opacity: 0.92; }
.lb-fit {
border: 1px solid rgba(255, 255, 255, 0.32); background: none; color: #fff; cursor: pointer;
border-radius: 999px; padding: 4px 13px; font-size: 0.82rem; font-family: inherit; font-weight: 600;
}
.lb-fit:hover { background: rgba(255, 255, 255, 0.18); }
@media (hover: none) { .lb-tools { display: none; } } /* mobile keeps native pinch */
.lb-zoombtn {
display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
border: 1px solid #d9cdeb; background: #fff; color: var(--accent);
border-radius: 999px; padding: 8px 16px; font-family: inherit; font-size: 0.9rem; font-weight: 600;
transition: background 0.14s ease, color 0.14s ease;
}
.lb-zoombtn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
@media (hover: none) { .lb-zoombtn { display: none; } } /* mobile keeps native pinch-zoom */
/* The full-screen view wears the same frame. Rail + mat are only a touch larger than the
page (keeping the ~1:1 wood:white proportion), and the image is capped so the whole
framed piece — including the bottom rail — always fits on screen. */
.lb-frame {
max-width: 92vw;
--rail: calc(clamp(18px, 1.9vw, 36px) * var(--frame-scale, 1));
--mat: calc(clamp(16px, 1.6vw, 30px) * min(var(--frame-scale, 1), 1.5));
}
.lb-frame.frame--none { --rail: 0px; --mat: 0px; } /* bare art fills the screen */
.lb-frame img { max-width: 86vw; max-height: 66vh; width: auto; height: auto; object-fit: contain; }
.lb-frame.frame--none img { max-width: 96vw; max-height: 88vh; } /* no frame → go big */
.lb-cap { color: #5b636e; font-size: 0.9rem; }
.lb-cap .sep { color: #a6acb4; }
/* On a narrow portrait phone the framed piece (image + mat + rail) overflowed the
viewport — the rail got clipped and the whole thing read as distorted. Pull the
image cap in so the WHOLE frame fits on screen, and leave room for the caption. */
@media (max-width: 640px) {
.lb-frame { max-width: 94vw; }
.lb-frame img { max-width: 72vw; max-height: 60vh; }
.lb-frame.frame--none img { max-width: 92vw; max-height: 78vh; }
}
/* Landscape artwork on a PORTRAIT phone: turn the whole stage 90° so the painting
fills the long axis (turn the phone to view it level; tap anywhere to close). The
image caps are in SWAPPED units — vh drives the on-screen long edge after rotation,
vw the short edge — so it always fits. Desktop and portrait art never hit this. */
@media (max-width: 640px) and (orientation: portrait) {
.lightbox.rotate .lb-stage { transform: rotate(90deg); transform-origin: center; }
.lightbox.rotate .lb-cap { display: none; } /* would sit sideways; the placard's on the page */
/* The image's SHORT edge + the frame's rail/mat map to the phone's narrow width, so
cap it there (max-height, pre-rotation) with a modest fixed frame so the whole
moulding always fits; the long edge (max-width → screen height) stays generous. */
.lightbox.rotate .lb-frame { max-width: 92vh; } /* rail/mat keep scaling with thickness */
/* short edge (→ screen width after rotation) capped low enough that the frame fits even
at max thickness; the long edge stays generous */
.lightbox.rotate .lb-frame img { max-width: 88vh; max-height: 60vw; }
.lightbox.rotate .lb-frame.frame--none img { max-width: 94vh; max-height: 92vw; }
}
</style>
+1 -1
View File
@@ -35,7 +35,7 @@
{:else}
<h1>Couldn't sign you in</h1>
<p class="muted">{error}</p>
<a class="back" href="/">← Back to Upbeat Bytes</a>
<a class="back" href="/">← Back to upbeatBytes</a>
{/if}
</main>
+7
View File
@@ -0,0 +1,7 @@
<script>
// /news — the feed's home: Latest by default, Highlights at ?view=highlights, plus
// search/categories/following/saved. Renders the shared NewsFeed in hub chrome.
import NewsFeed from '$lib/components/NewsFeed.svelte';
</script>
<NewsFeed chrome="hub" />
+179
View File
@@ -0,0 +1,179 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import HubShell from '$lib/components/HubShell.svelte';
let f = $state(null);
let state = $state('loading');
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function dateline(s) {
if (!s) return '';
const [y, m, d] = s.split('-').map(Number);
return `${WEEKDAYS[new Date(y, m - 1, d).getDay()]}, ${MONTHS[m - 1]} ${d}`;
}
let readLabel = $derived(f?.source_url?.includes('wikipedia') ? 'Read more on Wikipedia' : 'Read more');
// Figure-vs-photo: OTD images are uncontrolled (Wikimedia photos, but also seals,
// logos, maps, diagrams). Photos look best full-bleed (cover); figures must be shown
// WHOLE (contain on a clean matte) or they get cropped to nonsense. PNG/SVG sources
// are almost always figures/logos with transparency; refine with aspect ratio on load.
let heroAspect = $state(null);
let heroFit = $derived.by(() => {
const url = f?.image_url || '';
if (/\.(png|svg)(\?|#|$)/i.test(url)) return 'contain';
// filename hints catch JPEG logos/maps/crests the aspect check would crop
if (/(seal|flag|logo|map|diagram|crest|emblem|coat[_-]?of[_-]?arms)/i.test(url)) return 'contain';
if (heroAspect == null) return 'cover';
return (heroAspect < 0.9 || heroAspect > 2.0) ? 'contain' : 'cover';
});
function onHeroLoad(e) {
const img = e.currentTarget;
if (img.naturalWidth && img.naturalHeight) heroAspect = img.naturalWidth / img.naturalHeight;
}
onMount(async () => {
try {
f = await getJSON('/api/onthisday/today');
state = f ? 'ready' : 'empty';
} catch {
state = 'empty';
}
});
</script>
<svelte:head>
<title>On This Day · upbeatBytes</title>
<meta name="description" content="A good thing that happened on today's date in history." />
</svelte:head>
<HubShell active="">
<article class="otd-page">
{#if state === 'ready'}
<!-- CD's "letter" treatment in a green key: deckle frame, dateline, hero with year overlay -->
<div class="card">
<div class="deckle">
<div class="eyebrow">
<span class="eye-rule"></span>
<span class="eye-label">On This Day</span>
</div>
<div class="dateline">
<div class="year-block">
<span class="yb-label">In history</span>
<span class="yb-year">{f.year}</span>
</div>
<span class="dl-rule"></span>
<span class="dl-label">{dateline(f.date)}</span>
</div>
{#if f.image_url}
<div class="hero" class:figure={heroFit === 'contain'}>
<img class="hero-img" src={f.image_url} alt="" loading="lazy" onload={onHeroLoad} />
</div>
{/if}
<p class="headline">{f.text}</p>
{#if f.summary}
<div class="story-head">
<span class="sh-rule"></span>
<span class="sh-label">A little context</span>
</div>
<p class="story">{f.summary}</p>
{/if}
{#if f.source_url}
<div class="cta-row">
<a class="cta" href={f.source_url} target="_blank" rel="noopener">
{readLabel}
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</a>
</div>
{/if}
</div>
</div>
{:else if state === 'empty'}
<p class="note">Today's moment in history is on its way. Check back soon.</p>
{:else}
<p class="note">Finding today's good thing…</p>
{/if}
</article>
</HubShell>
<style>
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-var.woff2') format('woff2'); font-weight: 500 700; font-style: normal; font-display: swap; }
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-italic-var.woff2') format('woff2'); font-weight: 500 700; font-style: italic; font-display: swap; }
.otd-page { max-width: 880px; margin: 0 auto; }
.card {
position: relative; margin-top: clamp(24px, 4vw, 40px); background: #f6ead0;
border: 1px solid rgba(120, 90, 40, 0.10); border-radius: 16px;
box-shadow: 0 26px 50px -32px rgba(60, 80, 55, 0.6); padding: clamp(12px, 1.6vw, 18px);
}
.deckle {
position: relative; border: 1.5px dashed rgba(70, 120, 90, 0.38); border-radius: 11px;
padding: clamp(26px, 5vw, 34px) clamp(20px, 5vw, 34px) clamp(34px, 6vw, 44px); overflow: hidden;
}
/* eyebrow matches the homepage "On this day" card accent (clay) so the card → page read as one */
.eyebrow { display: flex; align-items: center; gap: 14px; }
.eye-rule { width: 34px; height: 3px; background: #9a5a38; border-radius: 2px; flex: none; }
.eye-label { font-family: 'Hanken Grotesk', sans-serif; font-size: clamp(17px, 2.4vw, 22px); font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #9a5a38; }
/* extra breathing room between the title and the content below */
.dateline { display: flex; align-items: baseline; flex-wrap: wrap; gap: 10px 16px; margin: clamp(32px, 5vw, 48px) 0 16px; }
.dl-rule { flex: 1 1 30px; min-width: 24px; height: 1.5px; align-self: baseline; margin: 0; background: rgba(70, 120, 90, 0.26); }
.dl-label { font-family: 'Hanken Grotesk', sans-serif; font-weight: 700; font-size: clamp(14px, 1.7vw, 17px); letter-spacing: 0.1em; text-transform: uppercase; color: #2c5d44; }
/* "2013" is the SOLE in-flow baseline anchor (shares one baseline with the date + rule);
"IN HISTORY" is absolutely positioned in the reserved start-padding so raising it can never
drag that baseline (Codex's structure). Both green so the line ties in the Read-more button. */
.year-block { --ys: clamp(34px, 5.5vw, 46px); position: relative; display: inline-block; padding-inline-start: 90px; line-height: 1; flex: none; }
/* offset tied to the year size (≈cap-top), so it lands by construction and holds across
viewports instead of needing per-pixel re-guesses */
.yb-label { position: absolute; inset-inline-start: 0; top: calc(0.2 * var(--ys) + 1px); line-height: 1; font-family: 'Hanken Grotesk', sans-serif; font-size: 12px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #367653; }
.yb-year { position: static; display: inline-block; font-family: 'Playfair Display', Georgia, serif; font-weight: 700; font-size: var(--ys); line-height: 1; color: #2c5d44; }
/* the full line can't fit cleanly on a narrow phone — wrap it deliberately: year lockup on its
own row, rule + date on the next (instead of accidental mid-line flex wrapping) */
@media (max-width: 520px) { .year-block { flex: 0 0 100%; } }
.hero {
position: relative; border-radius: 12px; overflow: hidden; background: #21392e;
height: clamp(220px, 38vw, 330px);
}
.hero-img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* figures (seals/logos/diagrams) shown whole on a clean light matte, never cropped */
.hero.figure { background: #f4f6f1; border: 1px solid rgba(70, 120, 90, 0.16); }
.hero.figure .hero-img { object-fit: contain; padding: clamp(18px, 3vw, 30px); box-sizing: border-box; }
.headline {
font-family: 'Playfair Display', Georgia, serif; font-style: italic; font-weight: 600;
font-size: clamp(1.45rem, 4vw, 2.06rem); line-height: 1.28; color: #243d31;
margin: clamp(24px, 4vw, 30px) 0 0; max-width: 40ch;
}
.story-head { display: flex; align-items: center; gap: 12px; margin-top: clamp(26px, 4vw, 34px); }
.sh-rule { width: 26px; height: 2px; background: #bcae93; border-radius: 2px; flex: none; }
.sh-label { font-family: 'Hanken Grotesk', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #74633f; }
.story {
font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.8vw, 1.13rem);
line-height: 1.68; color: #4f574f; margin: 12px 0 0; max-width: 64ch;
}
.cta-row { margin-top: clamp(24px, 4vw, 30px); }
/* clay to match the "On This Day" title — gives this card its own identity (AA: white on #9a5a38) */
.cta {
display: inline-flex; align-items: center; gap: 8px; background: #9a5a38; color: #fff;
text-decoration: none; font-family: 'Hanken Grotesk', sans-serif; font-size: 14px; font-weight: 600;
padding: 11px 20px; border-radius: 999px; -webkit-tap-highlight-color: transparent;
transition: background 0.15s ease;
}
.cta:hover { background: #854c2f; }
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
</style>
+202 -124
View File
@@ -3,9 +3,10 @@
import { goto, afterNavigate } from '$app/navigation';
import { page } from '$app/stores';
import { getJSON } from '$lib/api.js';
import HubBar from '$lib/components/HubBar.svelte';
import Footer from '$lib/components/Footer.svelte';
import { pushGameStatesBatch } from '$lib/gamesync.js';
import { ritualState } from '$lib/ritual.js';
import { prefs, initPrefs } from '$lib/prefs.svelte.js';
import { initPrefs } from '$lib/prefs.svelte.js';
import { auth } from '$lib/auth.svelte.js';
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
import { trackGame } from '$lib/analytics.js';
@@ -40,9 +41,11 @@
let date = $state('');
let wordStatus = $state({ 5: null, 6: null });
let wsStatus = $state(null);
// Daily Ritual ("today's calm set") — Brief · Daily Word · Word Search, keyed
// on the server puzzle date; the Brief tick is set on the home end-cap, read here.
let ritual = $state({ items: [], count: 0, total: 0 });
// Decorative Word-Search tile motif: a 6×5 letter grid with BYTES "found" down the diagonal.
const WS_GRID = ['B', 'R', 'O', 'A', 'E', 'S', 'K', 'Y', 'I', 'M', 'U', 'H', 'G', 'E', 'T',
'B', 'O', 'R', 'F', 'I', 'N', 'E', 'L', 'A', 'Z', 'O', 'S', 'T', 'S', 'Y'];
const WS_HL = new Set([0, 7, 14, 21, 28]); // the diagonal B-Y-T-E-S cells
function readWord(v) {
try {
@@ -104,7 +107,6 @@
wsStatus = ws;
bloomStatus = readBloom();
matchStatus = readMatch();
if (date) ritual = ritualState(date, prefs.data.ritual);
}
function fmtMs(ms) {
const s = Math.round(ms / 1000);
@@ -206,6 +208,11 @@
// (no in-app history) it navigates to the parent screen instead of leaving the
// site. Device Back stays browser-native either way.
let appNavDepth = 0;
let cameFromApp = $state(false); // arrived via in-app nav (e.g. the hub) → Back returns there
function leavePlay() {
if (cameFromApp && typeof history !== 'undefined') history.back();
else goto('/', { replaceState: true });
}
function openGame(g) { appNavDepth++; goto('/play?game=' + g); }
function pick(v) { appNavDepth++; goto('/play?game=' + game + '&v=' + v); }
function back() {
@@ -259,94 +266,125 @@
syncAllGames(); // signed-in: pull cross-device status into the cards + upload local progress
});
// Refresh hub/selection statuses whenever we land on a screen (incl. Back).
afterNavigate(() => refreshStatus());
afterNavigate(({ from }) => { if (from) cameFromApp = true; refreshStatus(); });
</script>
<svelte:head>
<!-- Canonical/OG/description for /play are baked into the static play.html at build
time (scripts/patch-play-head.mjs) so non-JS social scrapers get them; we keep
time (scripts/patch-static-heads.mjs) so non-JS social scrapers get them; we keep
only the browser-tab title + dev-gate noindex here to avoid duplicate tags. -->
<title>Play · Upbeat Bytes — calm daily games</title>
<title>Play · upbeatBytes — calm daily games</title>
{#if isDevGated(game)}<meta name="robots" content="noindex" />{/if}
</svelte:head>
<header class="bar">
<div class="container inner">
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
{#if view === 'hub'}
<a class="back" href="/"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>News</a>
{:else}
<button class="back" onclick={back}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>{view === 'play' ? 'Game Selection' : 'Play Hub'}
</button>
{/if}
</div>
</header>
<HubBar active="games" />
<main class="container page" class:gameview={view === 'play'}>
<!-- In a game, a step-back row (selection / hub). On the games landing, Back lives in the
header row instead (right-justified, level with "Play"). -->
{#if view !== 'hub'}
<button class="gameback" onclick={back}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
{view === 'play' ? 'Game Selection' : 'Play Hub'}
</button>
{/if}
{#if view === 'hub'}
<h1>Play</h1>
<p class="sub">A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.</p>
{#if date && ritual.total}
<div class="calmset">
<p class="cs-head">Today's calm set</p>
<ul class="cs-items">
{#each ritual.items as it (it.key)}
<li class="cs-item" class:done={it.done}>
<span class="cs-mark" aria-hidden="true"></span>{#if it.done}{it.label}{:else}<a href={it.href}>{it.label}</a>{/if}
</li>
{/each}
</ul>
<p class="cs-foot">{ritual.count === ritual.total ? `All ${ritual.total} enjoyed today` : `${ritual.count} of ${ritual.total} enjoyed today`} · fresh set tomorrow · <a class="cs-edit" href="/account?section=calmset">make it yours</a></p>
<div class="arcade-head">
<div class="head-left">
<span class="eyelash" aria-hidden="true"></span>
<h1 class="play-title">Play</h1>
<span class="head-div" aria-hidden="true"></span>
<p class="head-sub">A few little games, fresh every morning. Pick one and dive in.</p>
</div>
{/if}
<div class="cards">
<button class="gamecard" onclick={() => openGame('word')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Daily Word</h2>
<p class="gc-sub">Guess the hopeful word · 5 or 6 letters</p>
<p class="gc-status" class:played={wordStatus['5'] || wordStatus['6']}>{wordLabel()}</p>
</div>
<button class="gameback hub-back" onclick={leavePlay}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
Back
</button>
<button class="gamecard" onclick={() => openGame('wordsearch')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Word Search</h2>
<p class="gc-sub">Find the days themed words</p>
<p class="gc-status" class:played={wsStatus && (wsStatus.status === 'done' || wsStatus.found > 0)}>{wsHubLabel()}</p>
</div>
</div>
<div class="arcade">
<button class="tile tile-word" onclick={() => openGame('word')}>
<span class="motif m-word" aria-hidden="true">
{#each ['B', 'Y', 'T', 'E', '', 'C', 'A', 'L', 'M', ''] as ch}
<span class="wt" class:on={ch}>{ch}</span>
{/each}
</span>
<span class="foot">
<span class="t-name">Daily Word</span>
<span class="t-clue">Todays word · 5 or 6 letters</span>
</span>
</button>
<button class="tile tile-ws" onclick={() => openGame('wordsearch')}>
<span class="motif m-ws" aria-hidden="true">
{#each WS_GRID as ch, i}<span class="wc" class:hl={WS_HL.has(i)}>{ch}</span>{/each}
</span>
<span class="foot">
<span class="t-name">Word Search</span>
<span class="t-clue">Todays themed grid</span>
</span>
</button>
{#if !bloomBlocked}
<button class="gamecard" onclick={() => openGame('bloom')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Bloom{#if isDevGated('bloom')}<span class="devtag">dev</span>{/if}</h2>
<p class="gc-sub">Make words from todays letters</p>
<p class="gc-status" class:played={bloomStatus && bloomStatus.count > 0}>{bloomHubLabel()}</p>
</div>
<button class="tile tile-bloom" onclick={() => openGame('bloom')}>
<span class="motif m-bloom" aria-hidden="true">
<span class="bloom-ring">
<span class="petal pc">T</span>
<span class="petal p1">A</span><span class="petal p2">E</span><span class="petal p3">G</span>
<span class="petal p4">L</span><span class="petal p5">R</span><span class="petal p6">Y</span>
</span>
</span>
<span class="foot">
<span class="t-name">Bloom{#if isDevGated('bloom')}<span class="devtag">dev</span>{/if}</span>
<span class="t-clue">Todays letter garden</span>
</span>
</button>
{/if}
{#if !matchBlocked}
<button class="gamecard" onclick={() => openGame('match')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Memory Match{#if isDevGated('match')}<span class="devtag">dev</span>{/if}</h2>
<p class="gc-sub">Find the pairs — icons or colors</p>
<p class="gc-status" class:played={matchStatus}>{matchHubLabel()}</p>
</div>
<button class="tile tile-match" onclick={() => openGame('match')}>
<span class="motif m-match" aria-hidden="true">
{#each [0, 1, 2, 3, 4, 5, 6, 7] as i}
<span class="mm" class:flip={i === 1 || i === 6}>{#if i === 1 || i === 6}<span class="dot"></span>{/if}</span>
{/each}
</span>
<span class="foot">
<span class="t-name">Memory Match{#if isDevGated('match')}<span class="devtag">dev</span>{/if}</span>
<span class="t-clue">Todays board</span>
</span>
</button>
{/if}
{#if !blockedForViewer('zen', auth.user, $page.url)}
<a class="gamecard zencard" href="/zen">
<div class="gc-icon">🐟</div>
<div class="gc-body">
<h2>Zen Den{#if isDevGated('zen')}<span class="devtag">dev</span>{/if}</h2>
<p class="gc-sub">A calm corner — drop in with UB</p>
<p class="gc-status zen">Visit · no scores, just quiet</p>
</div>
</a>
{/if}
<!-- teased, not built yet -->
<div class="tile tile-bubble soon">
<span class="motif m-bubble" aria-hidden="true">
<span class="bub b-a" style="left:38px;top:12px"></span>
<span class="bub b-b" style="left:73px;top:12px"></span>
<span class="bub b-d" style="left:108px;top:12px"></span>
<span class="bub b-a" style="left:143px;top:12px"></span>
<span class="bub b-b" style="left:178px;top:12px"></span>
<span class="bub b-d" style="left:55px;top:41px"></span>
<span class="bub b-a" style="left:90px;top:41px"></span>
<span class="bub b-b" style="left:125px;top:41px"></span>
<span class="bub b-d" style="left:160px;top:41px"></span>
<span class="bub b-b" style="left:73px;top:70px"></span>
<span class="bub b-c" style="left:108px;top:70px"></span>
<span class="bub b-d" style="left:143px;top:70px"></span>
<span class="bub-aim"></span>
<span class="bub-shooter b-c"></span>
</span>
<span class="ribbon">Coming soon</span>
<span class="foot">
<span class="t-name">Bubble Blaster</span>
<span class="t-clue">A gentle bubble-pop puzzle</span>
</span>
</div>
<div class="tile tile-more soon">
<span class="more-plus" aria-hidden="true">+</span>
<span class="t-name">More coming soon</span>
<span class="t-clue">Little games in the workshop</span>
</div>
</div>
{:else if view === 'select'}
@@ -427,14 +465,18 @@
{/if}
</main>
<Footer />
<style>
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
.logo { height: 40px; display: block; }
.back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px;
background: none; border: none; font-family: inherit; cursor: pointer; }
.back svg { width: 17px; height: 17px; display: block; }
.page { padding: 22px 20px 70px; }
/* in-game step-back, below the shared HubBar (matches the hub Back affordance) */
.gameback { color: var(--accent-deep); font-size: 0.9rem; font-weight: 600; display: inline-flex;
align-items: center; gap: 6px; background: none; border: none; font-family: inherit;
cursor: pointer; padding: 6px 10px 6px 0; margin-bottom: 4px;
-webkit-tap-highlight-color: transparent; }
.gameback svg { width: 16px; height: 16px; display: block; transition: transform 0.15s ease; }
.gameback:hover { color: var(--accent); }
.gameback:hover svg { transform: translateX(-2px); }
.page { padding: 16px 20px 70px; }
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 6px; }
.seltitle { font-size: clamp(1.7rem, 4.5vw, 2.2rem); }
.sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; }
@@ -452,51 +494,87 @@
font-size: 0.7rem; color: var(--muted); }
.tc-name { font-family: var(--serif); font-size: 1.7rem; color: var(--accent-deep); line-height: 1.15; }
/* Daily Ritual — "today's calm set". Gentle, non-instrumental. */
.calmset {
max-width: 460px; margin: 0 0 24px; padding: 14px 18px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px; box-shadow: var(--shadow);
}
.cs-head {
margin: 0 0 10px; text-transform: uppercase; letter-spacing: 0.13em;
font-family: var(--label); font-size: 0.64rem; font-weight: 600; color: var(--accent-deep);
}
.cs-items { list-style: none; margin: 0; padding: 0; display: flex; gap: 18px; flex-wrap: wrap; }
.cs-item { display: inline-flex; align-items: center; gap: 7px; font-size: 0.9rem; color: var(--muted); }
.cs-item a { color: inherit; text-decoration: none; }
.cs-item a:hover { color: var(--accent-deep); }
.cs-item.done { color: var(--ink); }
.cs-mark {
width: 16px; height: 16px; border-radius: 50%; border: 1.5px solid var(--line);
flex-shrink: 0; transition: background 0.16s ease, border-color 0.16s ease;
}
.cs-item.done .cs-mark {
background: var(--accent); border-color: var(--accent);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12l5 5 9-10' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-size: 13px; background-repeat: no-repeat; background-position: center;
}
.cs-foot { margin: 12px 0 0; font-family: var(--label); font-size: 0.82rem; color: var(--muted); }
.cs-edit { color: var(--accent-deep); text-decoration: underline; white-space: nowrap; }
/* ===== Arcade hub (CD "Play - Arcade"): colored game tiles, whole-card tap, no
dashboard/streaks/XP — just games. Fonts match the rest of the hub. ===== */
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 800; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
@font-face { font-family: 'Space Mono'; src: url('/fonts/space-mono-latin.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; }
.gamecard {
display: flex; gap: 14px; align-items: center; text-align: left;
background: var(--surface); border: 1px solid var(--line); border-radius: 16px;
padding: 18px 20px; cursor: pointer; font: inherit; color: var(--ink);
box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease;
.arcade-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: clamp(16px, 3vw, 28px) 0 24px; }
.head-left { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; min-width: 0; }
.hub-back { flex: none; align-self: flex-start; }
.eyelash { width: 34px; height: 4px; border-radius: 2px; background: #f0a830; flex: none; }
.play-title { font-family: 'Newsreader', Georgia, serif; font-weight: 600; font-size: clamp(2.4rem, 6vw, 3.1rem); color: #e5882a; letter-spacing: -0.015em; line-height: 0.9; margin: 0; }
.head-div { width: 1px; height: 32px; background: rgba(120, 95, 50, 0.22); flex: none; }
.head-sub { color: var(--muted); font-size: 15px; line-height: 1.45; max-width: 340px; margin: 0; }
.arcade { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.tile {
position: relative; min-width: 0; min-height: 244px; border-radius: 22px; overflow: hidden;
border: none; padding: 0; margin: 0; width: 100%; box-sizing: border-box; display: block;
cursor: pointer; text-align: left; color: inherit; font-family: 'Hanken Grotesk', sans-serif;
transition: transform 0.18s cubic-bezier(.34, 1.4, .5, 1), box-shadow 0.18s ease;
-webkit-tap-highlight-color: transparent;
}
.gamecard:hover { border-color: var(--accent); transform: translateY(-1px); }
/* The Zen Den isn't a game — give it a soft aqua identity so it reads as a calm corner. */
.zencard { text-decoration: none; background: linear-gradient(180deg, #f2fbfc, var(--surface)); }
.zencard:hover { border-color: #7cc3cc; }
.zencard .gc-status.zen { color: #2b8c98; font-weight: 600; }
.gc-icon { font-size: 2rem; color: var(--accent); line-height: 1; flex-shrink: 0; }
.gc-body h2 { font-size: 1.2rem; margin: 0 0 3px; }
.devtag { margin-left: 8px; font-size: 0.6rem; font-family: var(--label); font-weight: 700;
text-transform: uppercase; letter-spacing: 0.08em; color: #fff; background: #c2569b;
border-radius: 5px; padding: 2px 6px; vertical-align: middle; }
.gc-sub { color: var(--muted); font-size: 0.86rem; margin: 0 0 8px; }
.gc-status { font-size: 0.84rem; color: var(--accent-deep); font-weight: 600; margin: 0; }
.tile:hover { transform: translateY(-5px); }
.tile.soon { cursor: default; }
.tile.soon:hover { transform: none; }
@media (prefers-reduced-motion: reduce) { .tile { transition: none; } .tile:hover { transform: none; } }
.tile-word { background: #f0a830; box-shadow: 0 12px 26px -10px rgba(224, 148, 31, 0.55); }
.tile-ws { background: #2f7fb0; box-shadow: 0 12px 26px -10px rgba(47, 127, 176, 0.55); }
.tile-bloom { background: #5a9b63; box-shadow: 0 12px 26px -10px rgba(90, 155, 99, 0.55); }
.tile-match { background: #c0688c; box-shadow: 0 12px 26px -10px rgba(192, 104, 140, 0.55); }
.tile-bubble { background: linear-gradient(160deg, #6a6fc6, #4a4ea0); box-shadow: 0 12px 26px -10px rgba(74, 78, 160, 0.55); }
.tile-more { background: #efe6d4; border: 1.5px dashed #cdbb9a; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 24px; text-align: center; }
.foot { position: absolute; left: 0; right: 0; bottom: 0; padding: 30px 18px 15px; display: flex; flex-direction: column; gap: 3px; background: linear-gradient(to top, rgba(50, 33, 12, 0.62), transparent); }
.t-name { font-weight: 800; font-size: 19px; color: #fff; }
.t-clue { font-family: 'Space Mono', monospace; font-size: 10.5px; letter-spacing: 0.03em; color: rgba(255, 255, 255, 0.84); }
.tile-more .t-name { color: #6f6757; font-size: 16px; }
.tile-more .t-clue { color: #a89a7c; }
.more-plus { font-family: 'Newsreader', serif; font-size: 42px; line-height: 1; color: #cdbb9a; }
.ribbon { position: absolute; top: 14px; right: 14px; font-family: 'Space Mono', monospace; font-size: 9.5px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: #fff; background: rgba(0, 0, 0, 0.3); padding: 5px 10px; border-radius: 12px; }
.devtag { margin-left: 8px; font-size: 0.58rem; font-family: 'Space Mono', monospace; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #fff; background: rgba(0, 0, 0, 0.32); border-radius: 5px; padding: 2px 6px; vertical-align: middle; }
/* tile motifs — decorative; clipped by the tile's overflow:hidden */
.motif { position: absolute; inset: 0; pointer-events: none; }
.m-word { padding: 18px 22px 66px; display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; align-content: center; }
.wt { aspect-ratio: 1; box-sizing: border-box; border: 2.5px solid rgba(255, 255, 255, 0.45); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: clamp(15px, 3.4vw, 22px); color: #cf7d1c; }
.wt.on { background: #fff; border-color: #fff; }
.m-ws { padding: 12px 16px 66px; display: grid; grid-template-columns: repeat(6, 29px); gap: 4px; justify-content: center; align-content: center; }
.wc { width: 29px; height: 29px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 12.5px; color: rgba(255, 255, 255, 0.42); }
.wc.hl { background: rgba(255, 255, 255, 0.93); color: #2f7fb0; border-radius: 7px; box-shadow: 0 2px 7px rgba(0, 0, 0, 0.12); }
/* Bloom is a circular medallion, so center it in the tile's visual field (~43%), lower than
the other top-illustration motifs — just a small padding-bottom to keep the lower petal
off the title/gradient. */
.m-bloom { display: flex; align-items: center; justify-content: center; padding-bottom: 32px; box-sizing: border-box; }
.bloom-ring { position: relative; width: 150px; height: 150px; }
.m-bloom .petal { position: absolute; width: 42px; height: 42px; border-radius: 50%; background: #fff; color: #4f8a58; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 18px; box-shadow: 0 3px 7px rgba(0, 0, 0, 0.1); }
.m-bloom .pc { width: 50px; height: 50px; background: #3e7a47; color: #fff; font-size: 22px; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); z-index: 1; left: 50%; top: 50%; transform: translate(-50%, -50%); }
.m-bloom .p1 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(0, -56px); }
.m-bloom .p2 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(48px, -28px); }
.m-bloom .p3 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(48px, 28px); }
.m-bloom .p4 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(0, 56px); }
.m-bloom .p5 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(-48px, 28px); }
.m-bloom .p6 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(-48px, -28px); }
.m-match { padding: 18px 22px 66px; display: grid; grid-template-columns: repeat(4, 50px); gap: 10px; justify-content: center; align-content: center; }
.mm { width: 50px; height: 50px; border-radius: 9px; background: rgba(255, 255, 255, 0.2); display: flex; align-items: center; justify-content: center; }
.mm.flip { background: #fff; }
.mm .dot { width: 17px; height: 17px; border-radius: 50%; background: #c0688c; }
/* bubble shooter: a packed cluster up top, a dashed aim line, a loaded shooter below */
.bub { position: absolute; width: 34px; height: 34px; border-radius: 50%; box-shadow: 0 3px 8px rgba(0, 0, 0, 0.16); }
/* shooter sits to the RIGHT (clear of the title); a diagonal dashed aim line runs up-left
to the matching blue bubble at the cluster's center, like a shot being lined up */
.bub-aim { position: absolute; left: 62%; bottom: 74px; width: 2px; height: 96px; transform-origin: bottom center; transform: translateX(-50%) rotate(-33deg);
background: repeating-linear-gradient(to top, rgba(255, 255, 255, 0.65) 0 5px, transparent 5px 11px); }
.bub-shooter { position: absolute; left: 62%; bottom: 46px; transform: translateX(-50%); width: 36px; height: 36px; border-radius: 50%; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24); }
.b-a { background: radial-gradient(circle at 32% 28%, #fff, #f0a830 62%); }
.b-b { background: radial-gradient(circle at 32% 28%, #fff, #e8607f 62%); }
.b-c { background: radial-gradient(circle at 32% 28%, #fff, #4fb0e0 62%); }
.b-d { background: radial-gradient(circle at 32% 28%, #fff, #6ad0a8 62%); }
@media (max-width: 880px) { .arcade { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { .arcade { grid-template-columns: 1fr; } }
/* Game-selection options */
.opts { display: flex; flex-direction: column; gap: 12px; max-width: 460px; }
+188
View File
@@ -0,0 +1,188 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import HubShell from '$lib/components/HubShell.svelte';
let q = $state(null);
let state = $state('loading');
const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
let seal = $derived.by(() => {
if (!q?.date) return null;
const [, m, d] = q.date.split('-').map(Number);
return MONTHS[m - 1] ? { mon: MONTHS[m - 1], day: d } : null;
});
// one attributed string for both Copy and Share, so native sharing keeps the author too
let attributed = $derived(q ? `“${q.text}”${q.author ? ` — ${q.author}${q.work ? ', ' + q.work : ''}` : ''}` : '');
let copied = $state(false);
async function copyQuote() {
try {
await navigator.clipboard.writeText(attributed);
copied = true; setTimeout(() => (copied = false), 1800);
} catch { /* no clipboard — silent */ }
}
let shared = $state(false);
async function share() {
const url = location.href;
try {
if (navigator.share) { await navigator.share({ title: 'Quote of the Day · upbeatBytes', text: attributed, url }); return; }
await navigator.clipboard.writeText(url);
shared = true; setTimeout(() => (shared = false), 1800);
} catch { /* cancelled / unsupported */ }
}
onMount(async () => {
try {
q = await getJSON('/api/quote/today');
state = q ? 'ready' : 'empty';
} catch {
state = 'empty';
}
});
</script>
<svelte:head>
<title>Quote of the Day · upbeatBytes</title>
<meta name="description" content="A hopeful quote every day — with its source and a plain-language take on what it means." />
</svelte:head>
<HubShell active="">
<article class="quote-page">
{#if state === 'ready'}
<!-- CD's "letter" treatment: warm card, dashed deckle frame, wax-seal date, faded quote-mark watermark -->
<div class="letter">
{#if seal}
<div class="seal" aria-hidden="true">
<span class="seal-mon">{seal.mon}</span>
<span class="seal-day">{seal.day}</span>
</div>
{/if}
<div class="deckle">
<span class="wm" aria-hidden="true"></span>
<div class="eyebrow">
<span class="eye-rule"></span>
<span class="eye-label">Quote of the Day</span>
</div>
<p class="quote">{q.text}</p>
{#if q.author}
<div class="attrib">
<span class="at-rule"></span>
<span class="at-by">{q.author}{#if q.work}, <span class="work">{q.work}</span>{/if}</span>
</div>
{/if}
{#if q.meaning}
<div class="hr"></div>
<div class="means-head">
<span class="mh-rule"></span>
<span class="mh-label">What it means</span>
</div>
<p class="meaning">{q.meaning}</p>
{/if}
<div class="actions">
<button class="act act-copy" onclick={copyQuote}>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
{copied ? 'Copied' : 'Copy'}
</button>
<button class="act act-share" onclick={share}>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/></svg>
{shared ? 'Link copied' : 'Share'}
</button>
</div>
</div>
</div>
{:else if state === 'empty'}
<p class="note">Today's quote is on its way. Check back soon.</p>
{:else}
<p class="note">Finding today's quote…</p>
{/if}
</article>
</HubShell>
<style>
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-var.woff2') format('woff2'); font-weight: 500 700; font-style: normal; font-display: swap; }
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-italic-var.woff2') format('woff2'); font-weight: 500 700; font-style: italic; font-display: swap; }
.quote-page { max-width: 880px; margin: 0 auto; }
/* the card + its inner deckle frame */
.letter {
position: relative; margin-top: clamp(24px, 4vw, 40px); background: #f6ead0;
border: 1px solid rgba(120, 90, 40, 0.10); border-radius: 16px;
box-shadow: 0 26px 50px -32px rgba(90, 60, 30, 0.6); padding: clamp(12px, 1.6vw, 18px);
}
.deckle {
position: relative; border: 1.5px dashed rgba(170, 110, 135, 0.4); border-radius: 11px;
padding: clamp(28px, 5vw, 40px) clamp(24px, 6vw, 56px) clamp(36px, 6vw, 54px); overflow: hidden;
}
/* giant faded closing-quote watermark, bleeding off the bottom-right */
.wm {
position: absolute; right: clamp(-20px, -2vw, -30px); bottom: clamp(-70px, -12vw, -120px);
font-family: 'Playfair Display', Georgia, serif; font-size: clamp(220px, 46vw, 400px); line-height: 1;
color: rgba(170, 110, 135, 0.08); pointer-events: none; user-select: none;
}
/* wax-seal date stamp, overlapping the top-right corner */
.seal {
position: absolute; top: -26px; right: clamp(20px, 6vw, 52px); z-index: 2;
width: clamp(62px, 9vw, 74px); height: clamp(62px, 9vw, 74px); border-radius: 50%;
background: #fbf3e3; border: 1.5px solid rgba(170, 110, 135, 0.55);
box-shadow: 0 8px 20px -8px rgba(90, 60, 30, 0.5); transform: rotate(-8deg);
display: flex; flex-direction: column; align-items: center; justify-content: center; color: #a4607a;
}
.seal-mon { font-family: 'Hanken Grotesk', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.18em; }
.seal-day { font-family: 'Playfair Display', Georgia, serif; font-size: clamp(28px, 4vw, 33px); font-weight: 700; line-height: 0.82; }
.eyebrow { position: relative; display: flex; align-items: center; gap: 14px; }
.eye-rule { width: 34px; height: 3px; background: #8f5e72; border-radius: 2px; flex: none; }
.eye-label { font-family: 'Hanken Grotesk', sans-serif; font-size: clamp(17px, 2.4vw, 22px); font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #7e4f63; }
.quote {
position: relative; font-family: 'Playfair Display', Georgia, serif; font-style: italic; font-weight: 600;
font-size: clamp(1.7rem, 5vw, 2.85rem); line-height: 1.18; letter-spacing: -0.01em; color: #3c2530;
margin: clamp(18px, 3vw, 24px) 0 0; max-width: 34ch;
}
.attrib { position: relative; display: flex; align-items: center; gap: 14px; margin-top: clamp(20px, 3vw, 26px); }
.at-rule { width: 28px; height: 1.5px; background: #c79bac; flex: none; }
.at-by { font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.6vw, 1.13rem); color: #8b596d; }
.work { font-style: italic; }
.hr { position: relative; height: 1px; background: rgba(120, 90, 40, 0.16); margin: clamp(28px, 4vw, 38px) 0 24px; }
.means-head { position: relative; display: flex; align-items: center; gap: 12px; }
.mh-rule { width: 26px; height: 2px; background: #bcae93; border-radius: 2px; flex: none; }
.mh-label { font-family: 'Hanken Grotesk', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #74633f; }
.meaning {
position: relative; font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.8vw, 1.13rem);
line-height: 1.65; color: #5c5249; margin: 12px 0 0; max-width: 60ch;
}
.actions { position: relative; display: flex; gap: 12px; margin-top: clamp(26px, 4vw, 32px); flex-wrap: wrap; }
.act {
display: inline-flex; align-items: center; gap: 7px; font-family: 'Hanken Grotesk', sans-serif;
font-size: 14px; font-weight: 600; padding: 10px 18px; border-radius: 999px; cursor: pointer;
-webkit-tap-highlight-color: transparent; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
/* AA contrast: white on #8b596d passes; rose text uses the accessible secondary */
.act-copy { background: #8b596d; color: #fff; border: none; }
.act-copy:hover { background: #774a5c; }
.act-share { background: transparent; color: #8b596d; border: 1px solid #d3b3c0; }
.act-share:hover { border-color: #8b596d; color: #774a5c; }
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
/* narrow phones: keep "QUOTE OF THE DAY" + its eyelash from crowding/clipping */
@media (max-width: 380px) {
.deckle { padding-left: clamp(18px, 6vw, 24px); padding-right: clamp(18px, 6vw, 24px); }
.eyebrow { gap: 9px; }
.eye-rule { width: 24px; }
.eye-label { letter-spacing: 0.08em; }
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import HubShell from '$lib/components/HubShell.svelte';
let w = $state(null);
let state = $state('loading'); // loading | ready | empty
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : '');
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
let dateLabel = $derived.by(() => {
if (!w?.date) return '';
const [, m, d] = w.date.split('-').map(Number);
return MONTHS[m - 1] ? `${MONTHS[m - 1]} ${d}` : '';
});
onMount(async () => {
try {
w = await getJSON('/api/word/today');
state = w ? 'ready' : 'empty';
} catch {
state = 'empty';
}
});
function speak() {
if (w?.audio_url) {
const a = new Audio(w.audio_url);
a.play().catch(ttsFallback);
} else {
ttsFallback();
}
}
function ttsFallback() {
try {
const u = new SpeechSynthesisUtterance(w.word);
u.rate = 0.9;
speechSynthesis.cancel();
speechSynthesis.speak(u);
} catch { /* no speech support — silent */ }
}
</script>
<svelte:head>
<title>{w ? cap(w.word) : 'Word of the Day'} · upbeatBytes</title>
<meta name="description" content="A new uplifting word every day — with pronunciation, meaning, and how to use it." />
</svelte:head>
<HubShell active="">
<article class="word-page">
{#if state === 'ready'}
<!-- "Editorial Asymmetric" (CD): faded oversized initial, vertical part-of-speech rail,
big serif word, airy definition, left-ruled example sentences. -->
<div class="wcard">
<span class="wm" aria-hidden="true">{cap(w.word)[0]}</span>
<div class="wtop">
<span class="eyebrow">Word of the day</span>
{#if dateLabel}<span class="edition">{dateLabel}</span>{/if}
</div>
<div class="whead">
{#if w.part_of_speech}<span class="vpos">{w.part_of_speech}</span>{/if}
<div class="whead-main">
<h1 class="word">{cap(w.word)}</h1>
<div class="pron-row">
{#if w.phonetic}<span class="pron">{w.phonetic}</span>{/if}
<button class="listen" onclick={speak} aria-label="Hear it spoken">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H3v6h3l5 4z" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /><path d="M18.5 6a8 8 0 0 1 0 12" />
</svg>
Listen
</button>
</div>
</div>
</div>
<p class="definition">{w.definition}</p>
{#if w.examples?.length}
<section class="examples">
<div class="ex-label">In a sentence</div>
{#each w.examples as ex}<p class="ex">{ex}</p>{/each}
</section>
{/if}
</div>
{:else if state === 'empty'}
<p class="note">Today's word is on its way. Check back soon.</p>
{:else}
<p class="note">Finding today's word…</p>
{/if}
</article>
</HubShell>
<style>
.word-page { max-width: 760px; margin: 0 auto; }
.wcard {
position: relative; overflow: hidden; background: #FBF2E1; border: 1px solid #efe2c6;
border-radius: 18px; padding: clamp(32px, 6vw, 56px) clamp(26px, 5vw, 56px);
box-shadow: 0 18px 50px -34px rgba(80, 60, 25, 0.55);
}
/* faded oversized initial bleeding off the right edge */
/* sits JUST below the date in the top-right corner — clear it, don't drop far beneath */
.wm {
position: absolute; right: clamp(-60px, -4vw, -28px); top: clamp(45px, 7vw, 62px);
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-weight: 400;
font-size: clamp(220px, 42vw, 380px); line-height: 1; color: rgba(62, 110, 151, 0.06);
pointer-events: none; user-select: none;
}
.wtop { position: relative; display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.eyebrow { font-size: 12px; font-weight: 700; letter-spacing: 0.22em; text-transform: uppercase; color: #3E6E97; }
.edition { font-family: 'Newsreader', Georgia, serif; font-size: 15px; color: #a99a80; white-space: nowrap; }
.whead { position: relative; display: flex; align-items: flex-start; gap: clamp(14px, 2.5vw, 22px); margin-top: clamp(28px, 5vw, 46px); }
.vpos {
writing-mode: vertical-rl; transform: rotate(180deg); flex: none; padding-top: 6px;
font-size: 13px; font-weight: 700; letter-spacing: 0.3em; text-transform: uppercase; color: #5f86a6;
}
.whead-main { min-width: 0; }
.word {
font-family: 'Newsreader', Georgia, serif; font-weight: 400; letter-spacing: -0.02em;
font-size: clamp(3.4rem, 13vw, 108px); line-height: 0.92; color: #1c1a16; margin: 0; overflow-wrap: anywhere;
}
.pron-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; margin-top: 18px; }
.pron { font-family: 'Newsreader', Georgia, serif; font-size: 23px; color: #8a8478; }
/* Always filled blue — a little warmth of colour on a laid-back page. Hover is gated to
true pointer devices so it can't "stick" lit after a tap on a phone, and the tap
highlight box is suppressed (that grey/blue flash mobile draws on tap). */
.listen {
display: inline-flex; align-items: center; gap: 7px; cursor: pointer; font-family: inherit;
font-size: 0.82rem; font-weight: 600; letter-spacing: 0.02em; color: #fdf7ec;
background: #3E6E97; border: 1px solid #3E6E97; border-radius: 999px; padding: 7px 15px;
transition: background 0.15s ease;
-webkit-tap-highlight-color: transparent; -webkit-user-select: none; user-select: none;
}
@media (hover: hover) { .listen:hover { background: #34608a; border-color: #34608a; } }
.listen:active { background: #2c557d; border-color: #2c557d; }
.definition {
position: relative; font-family: 'Newsreader', Georgia, serif; font-weight: 400;
font-size: clamp(1.4rem, 4vw, 1.95rem); line-height: 1.32; color: #2c3640;
margin: clamp(30px, 5vw, 44px) 0 0; max-width: 30em;
}
.examples { position: relative; margin-top: clamp(28px, 5vw, 40px); max-width: 34em; }
.ex-label { font-size: 11px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #b3a487; margin-bottom: 16px; }
.ex {
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 1.08rem; line-height: 1.5;
color: #4a4439; padding-left: 16px; border-left: 2px solid #9bb6cf; margin: 0 0 14px;
}
.ex:last-child { margin-bottom: 0; }
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
</style>
+56 -30
View File
@@ -2,8 +2,9 @@
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { auth } from '$lib/auth.svelte.js';
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
import Footer from '$lib/components/Footer.svelte';
let canvas = $state();
let failed = $state(false);
@@ -14,6 +15,8 @@
let dbg = $state(null);
let debug = $state(false);
let copied = $state(false);
let stat = $state(null); // live controller readout (mode / speed / pos / turn)
let statTimer;
function apply() { handle?.setParams($state.snapshot(dbg)); }
async function copyValues() {
@@ -24,12 +27,17 @@
}
onMount(() => {
// Dev-gated while UB is being ironed out: non-admins (no preview token) bounce.
if (blockedForViewer('zen', auth.user, $page.url)) { goto('/play'); return; }
debug = $page.url.searchParams.get('debug') === '1';
let h;
let cancelled = false; // guard the async load against an early unmount
(async () => {
// Don't decide the gate until we actually KNOW who's here. A cold load / hard
// refresh / direct link mounts before auth has revalidated, so auth.user is still
// null and an admin would be wrongly bounced. Revalidate first, THEN gate.
if (!auth.ready) { try { await refreshAuth(); } catch { /* offline → treat as gated */ } }
if (cancelled) return;
// Dev-gated while UB is being ironed out: non-admins (no preview token) bounce.
if (blockedForViewer('zen', auth.user, $page.url)) { goto('/play'); return; }
debug = $page.url.searchParams.get('debug') === '1';
try {
// WebGL guard — fall back to a warm card rather than a blank canvas.
const t = document.createElement('canvas');
@@ -38,25 +46,31 @@
h = await createAquarium(canvas);
if (cancelled) { h.dispose(); return; } // left /zen mid-load — don't start a loop
handle = h;
if (debug) dbg = h.getParams();
if (debug) {
dbg = h.getParams();
statTimer = setInterval(() => {
const s = h.swimmer?.state;
if (s) stat = { mode: s.mode, clip: s.clip, speed: s.speed, turn: s.turnRate, x: s.pos.x, y: s.pos.y, z: s.pos.z };
}, 150);
}
loading = false;
} catch (e) {
console.warn('Zen Den could not start:', e);
if (!cancelled) { failed = true; loading = false; }
}
})();
return () => { cancelled = true; h?.dispose(); };
return () => { cancelled = true; clearInterval(statTimer); h?.dispose(); };
});
</script>
<svelte:head>
<title>The Zen Den · Upbeat Bytes</title>
<title>The Zen Den · upbeatBytes</title>
{#if isDevGated('zen')}<meta name="robots" content="noindex" />{/if}
</svelte:head>
<header class="bar">
<div class="container inner">
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="upbeatBytes" /></a>
<a class="back" href="/play">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Play
</a>
@@ -83,42 +97,51 @@
<div class="panel">
<div class="prow"><strong>UB render tuner</strong><button class="copy" onclick={copyValues}>{copied ? 'copied ✓' : 'copy values'}</button></div>
<label>yaw <span>{dbg.yaw.toFixed(2)}</span>
<input type="range" min="-3.15" max="3.15" step="0.01" bind:value={dbg.yaw} oninput={apply} /></label>
<label>pitch <span>{dbg.pitch.toFixed(2)}</span>
<input type="range" min="-0.6" max="0.6" step="0.01" bind:value={dbg.pitch} oninput={apply} /></label>
<label>preview clip
<select bind:value={dbg.preview} onchange={apply}>
<option value="">— live behavior —</option>
<option>idle</option><option>cruise</option><option>burst</option><option>turnL</option><option>turnR</option>
</select></label>
<label>scale <span>{dbg.scale.toFixed(2)}</span>
<input type="range" min="0.3" max="2.5" step="0.05" bind:value={dbg.scale} oninput={apply} /></label>
<hr />
<div class="ph">Tail</div>
<label class="chk"><input type="checkbox" bind:checked={dbg.tailTranslucent} onchange={apply} /> translucent (off = opaque, coherent)</label>
<label>side
<select bind:value={dbg.tailSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
<label>alphaTest <span>{dbg.tailAlphaTest.toFixed(3)}</span>
<input type="range" min="0" max="0.2" step="0.005" bind:value={dbg.tailAlphaTest} oninput={apply} /></label>
{#if dbg.tailTranslucent}
<label>opacity <span>{dbg.tailOpacity.toFixed(2)}</span>
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.tailOpacity} oninput={apply} /></label>
<div class="ph">Behavior</div>
{#if stat}
<div class="readout">{stat.mode} · {stat.clip} · spd {stat.speed.toFixed(2)} · turn {stat.turn.toFixed(2)}<br />pos {stat.x.toFixed(2)}, {stat.y.toFixed(2)}, {stat.z.toFixed(2)}</div>
{/if}
<label>cruise speed <span>{dbg.cruiseSpeed.toFixed(2)}</span>
<input type="range" min="0.15" max="1.2" step="0.02" bind:value={dbg.cruiseSpeed} oninput={apply} /></label>
<label>roam width <span>{dbg.boundsX.toFixed(2)}</span>
<input type="range" min="0.4" max="2.4" step="0.05" bind:value={dbg.boundsX} oninput={apply} /></label>
<label>roam height <span>{dbg.boundsY.toFixed(2)}</span>
<input type="range" min="0.1" max="1.4" step="0.05" bind:value={dbg.boundsY} oninput={apply} /></label>
<label>roam depth <span>{dbg.boundsZ.toFixed(2)}</span>
<input type="range" min="0" max="1.4" step="0.05" bind:value={dbg.boundsZ} oninput={apply} /></label>
<label>liveliness <span>{dbg.liveliness.toFixed(2)}</span>
<input type="range" min="0" max="2" step="0.1" bind:value={dbg.liveliness} oninput={apply} /></label>
<hr />
<div class="ph">Fins</div>
<div class="ph">Fins &amp; tail</div>
<label class="chk"><input type="checkbox" bind:checked={dbg.finTranslucent} onchange={apply} /> translucent (off = opaque, coherent)</label>
<label>side
<select bind:value={dbg.finSide} onchange={apply}><option>front</option><option>back</option><option>double</option></select></label>
<label>opacity <span>{dbg.finOpacity.toFixed(2)}</span>
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.finOpacity} oninput={apply} /></label>
<label>alphaTest <span>{dbg.finAlphaTest.toFixed(3)}</span>
<input type="range" min="0" max="0.2" step="0.005" bind:value={dbg.finAlphaTest} oninput={apply} /></label>
<input type="range" min="0" max="0.9" step="0.01" bind:value={dbg.finAlphaTest} oninput={apply} /></label>
{#if dbg.finTranslucent}
<label>opacity <span>{dbg.finOpacity.toFixed(2)}</span>
<input type="range" min="0" max="1" step="0.05" bind:value={dbg.finOpacity} oninput={apply} /></label>
{/if}
<hr />
<label class="chk"><input type="checkbox" bind:checked={dbg.paused} onchange={apply} /> freeze frame</label>
{#if dbg.paused}
<label>frame <span>{dbg.frame.toFixed(2)}</span>
<input type="range" min="0" max="1" step="0.01" bind:value={dbg.frame} oninput={apply} /></label>
{/if}
<label class="chk"><input type="checkbox" bind:checked={dbg.paused} onchange={apply} /> freeze</label>
</div>
{/if}
</main>
<Footer />
<style>
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; }
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
@@ -152,6 +175,9 @@
.panel hr { border: none; border-top: 1px solid var(--line); margin: 10px 0 6px; }
.ph { font-family: var(--label); text-transform: uppercase; letter-spacing: 0.04em; font-size: 0.72rem;
color: var(--muted); margin-bottom: 4px; }
.readout { font-family: var(--label); font-size: 0.72rem; line-height: 1.5; color: var(--accent-deep);
background: rgba(136, 87, 194, 0.08); border-radius: 6px; padding: 5px 8px; margin: 4px 0 8px;
font-variant-numeric: tabular-nums; }
.panel label { display: block; margin: 6px 0; color: var(--ink); }
.panel label span { float: right; color: var(--accent-deep); font-variant-numeric: tabular-nums; }
.panel label.chk { display: flex; align-items: center; gap: 7px; }
+13
View File
@@ -0,0 +1,13 @@
manrope-var.woff2 — "Manrope" variable font by Mikhail Sharanda / Mirko Velimirovic.
License: SIL Open Font License 1.1 (free to use/bundle, no attribution required in product UI).
Source: https://github.com/sharanda/manrope (via fontsource).
hanken-var.woff2 — "Hanken Grotesk" by Alfredo Marco Pradil. License: SIL Open Font License 1.1.
newsreader-var.woff2 / newsreader-italic-var.woff2 — "Newsreader" by Production Type (Google Fonts).
License: SIL Open Font License 1.1. All self-hosted (no Google hotlink) for the /home3 design direction.
Work Sans — Wei Huang, OFL 1.1 (Google Fonts). Latin subset, variable 400700.
Space Mono — Colophon Foundry, OFL 1.1 (Google Fonts). Latin subset, 400.
Playfair Display — Claus Eggers Sørensen, OFL 1.1 (Google Fonts). Latin subset, var 500700 + italic. (QOTD)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -1,7 +1,7 @@
{
"name": "Upbeat Bytes",
"short_name": "Upbeat Bytes",
"description": "Calm, constructive news worth your attention — and nothing that isn't.",
"name": "upbeatBytes",
"short_name": "upbeatBytes",
"description": "A calmer, brighter corner of the internet: good news, daily art, small games, and little resets.",
"start_url": "/",
"scope": "/",
"display": "standalone",
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
wood-grain.jpg — "Fine Grained Wood" by Rob Tuytel, Poly Haven (https://polyhaven.com/a/fine_grained_wood)
License: CC0 (Public Domain). Free to use, no attribution required. Recolored in CSS per frame species.
wood-grain-h.jpg — the same texture rotated 90° (horizontal grain), for the top/bottom rails so the grain
turns at each mitered corner. Same CC0 source/license.
Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

+426 -39
View File
@@ -32,11 +32,11 @@ from pathlib import Path
from fastapi import BackgroundTasks, FastAPI, HTTPException, Query, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import auth, bloom, email_send, feeds, games, oauth_google, publishing, queries, share, sources, summarize
from . import art, auth, bloom, daily, email_send, feeds, games, newsimg, oauth_google, onthisday, publishing, queries, quote, readtime, share, sources, summarize, wotd
from .localtime import local_today
from .markup import reply_html_to_text, sanitize_reply_html
from .db import connect
@@ -322,17 +322,38 @@ class Article(BaseModel):
paywalled: bool = False
tags: list[str] = []
summary: str | None = None # our own cached summary (present on the brief)
source_read_minutes: int | None = None # ~minutes to read the FULL source article (null = unknown)
# Subject geography (present on feed rows; absent/empty on the brief). breadth is
# locality|regional|national|multinational|global|unknown; places are ISO codes.
geo_breadth: str | None = None
geo_confidence: str | None = None
geo_places: list[dict] = [] # e.g. [{"country": "US", "state": "NY"}, {"country": "GB", "state": None}]
section: str | None = None # 'near' | 'country' | 'world' when a home is set (Closer to Home)
@classmethod
def from_row(cls, row: dict) -> "Article":
raw_tags = row.get("tags")
places = []
for tok in (row.get("geo_places") or "").split(","):
tok = tok.strip()
if not tok:
continue
cc, _, sc = tok.partition("-")
places.append({"country": cc, "state": sc or None})
return cls(
section=row.get("__section"),
geo_breadth=row.get("geo_breadth"),
geo_confidence=row.get("geo_confidence"),
geo_places=places,
summary=row.get("summary"),
source_read_minutes=readtime.source_read_minutes(row.get("source_words")),
id=row["id"],
title=row["title"],
description=row.get("description"),
url=row["canonical_url"],
image_url=row.get("image_url"),
# Resolve per the source's image policy: our cached copy, the publisher's URL
# (hotlink), or none — so we never re-host an image we haven't cleared.
image_url=newsimg.display_url(row["id"], row.get("image_policy"), row.get("image_url")),
published_at=row.get("published_at"),
source=row["source_name"],
source_id=row.get("source_id"),
@@ -354,6 +375,7 @@ class FeedResponse(BaseModel):
flavor: str | None
count: int
items: list[Article]
next_offset: int | None = None # world-tier offset for the next page (Closer to Home paging)
class BriefResponse(BaseModel):
@@ -368,6 +390,23 @@ class RejectedExample(BaseModel):
reason: str
class JoyAction(BaseModel):
action: str # block | unblock | feature | unfeature | delete | edit
fields: dict | None = None # for edit: {column: value}
class JoyAdd(BaseModel):
text: str | None = None # quote / onthisday
author: str | None = None # quote
work: str | None = None # quote
md: str | None = None # onthisday 'MM-DD'
year: int | None = None # onthisday
summary: str | None = None # onthisday
image_url: str | None = None # onthisday
page_url: str | None = None # onthisday
word: str | None = None # word
class Candidate(BaseModel):
id: int
feed_url: str
@@ -554,6 +593,10 @@ class SourcePaywallBody(BaseModel):
override: str | None = None # None = use domain rule · 'free' · 'paywalled'
class SourceImagePolicyBody(BaseModel):
policy: str = "remote" # 'cache' · 'remote' (default) · 'none'
class CandidateSuggestBody(BaseModel):
feed_url: str = ""
name: str | None = None
@@ -601,6 +644,7 @@ _EVENT_KINDS = {
"share_ub", "copy_source", "native_share",
"not_today", "less_like_this", "hide_topic",
"replace_used", "replace_none", "paywall_replace", "paywalled_source_open",
"engaged", # genuine engagement: ~8s visible + a real gesture (vs. a raw visit)
"client_error", # boot-failure seatbelt beacon (blank-screen risk signal)
} | _GAME_EVENT_KINDS
@@ -730,7 +774,7 @@ def create_app() -> FastAPI:
ok = _do_unsubscribe(u, t)
msg = (
"Youre unsubscribed from the daily digest. No hard feelings — "
"Upbeat Bytes is always here when you want it."
"upbeatBytes is always here when you want it."
if ok else
"That unsubscribe link looks invalid or expired. You can manage the "
"digest from your account settings."
@@ -739,9 +783,9 @@ def create_app() -> FastAPI:
'<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">'
'<div style="max-width:520px;margin:12vh auto;padding:0 24px;text-align:center;'
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
'<h1 style="font-size:22px">Upbeat Bytes</h1>'
'<h1 style="font-size:22px">upbeatBytes</h1>'
f'<p style="font-size:16px;line-height:1.5;color:#3b4754">{msg}</p>'
'<p><a href="/" style="color:#0083ad;text-decoration:none">← Back to Upbeat Bytes</a></p></div>'
'<p><a href="/" style="color:#0083ad;text-decoration:none">← Back to upbeatBytes</a></p></div>'
)
return HTMLResponse(html)
@@ -1074,7 +1118,7 @@ def create_app() -> FastAPI:
with get_conn() as conn:
row = conn.execute(
"SELECT a.id, a.title, a.description, a.image_url, a.canonical_url, "
"a.duplicate_of, a.source_id, src.name AS source_name, s.reason_text, s.accepted, "
"a.duplicate_of, a.source_id, src.name AS source_name, src.image_policy, s.reason_text, s.accepted, "
"(SELECT group_concat(t.tag) FROM article_tags t WHERE t.article_id = a.id) AS tags "
"FROM articles a JOIN sources src ON src.id = a.source_id "
"LEFT JOIN article_scores s ON s.article_id = a.id WHERE a.id = ?",
@@ -1123,8 +1167,13 @@ def create_app() -> FastAPI:
# --- Privacy-respecting first-party analytics -------------------------
@app.post("/api/events")
def record_event(body: EventBody) -> dict:
if body.kind in _EVENT_KINDS:
def record_event(body: EventBody, request: Request) -> dict:
# Don't let crawlers inflate visitor/funnel counts. Many modern bots run JS and
# DO fire this beacon, so filter by User-Agent (same check the load-error beacon
# uses) — catches honest bot UAs (GPTBot, AhrefsBot, headless Chrome, …). The
# response is identical either way, so a bot can't tell it was dropped.
ua = request.headers.get("user-agent", "")
if body.kind in _EVENT_KINDS and not queries.is_bot_ua(ua):
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO events (kind, article_id, visitor_hash, day) "
@@ -1148,15 +1197,37 @@ def create_app() -> FastAPI:
return {"ok": True}
@app.get("/api/admin/client-errors")
def admin_client_errors(request: Request) -> list[dict]:
def admin_client_errors(request: Request,
show: str = Query("unread", pattern="^(unread|read|all)$")) -> list[dict]:
with get_conn() as conn:
_require_admin(conn, request)
where = {"unread": "WHERE read_at IS NULL", "read": "WHERE read_at IS NOT NULL", "all": ""}[show]
rows = conn.execute(
"SELECT reason, path, user_agent, app_version, created_at FROM client_errors ORDER BY id DESC LIMIT 20"
f"SELECT id, reason, path, user_agent, app_version, created_at, read_at "
f"FROM client_errors {where} ORDER BY id DESC LIMIT 50"
).fetchall()
# Bots stay visible in the list (tagged) but are excluded from the
# headline counts — see queries.admin_stats.
return [{**dict(r), "bot": queries.is_bot_ua(r["user_agent"])} for r in rows]
# Bots stay visible (tagged) but are excluded from the headline count — see admin_stats.
return [{**dict(r), "read": r["read_at"] is not None,
"bot": queries.is_bot_ua(r["user_agent"])} for r in rows]
@app.post("/api/admin/client-errors/read-all")
def admin_client_errors_read_all(request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
cur = conn.execute("UPDATE client_errors SET read_at = CURRENT_TIMESTAMP WHERE read_at IS NULL")
conn.commit()
return {"ok": True, "marked": cur.rowcount}
@app.post("/api/admin/client-errors/{eid}/read")
def admin_client_error_read(eid: int, body: FeedbackReadBody, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
ts = "CURRENT_TIMESTAMP" if body.read else "NULL"
cur = conn.execute(f"UPDATE client_errors SET read_at = {ts} WHERE id = ?", (eid,))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="load error not found")
conn.commit()
return {"ok": True, "read": body.read}
@app.post("/api/feedback")
def submit_feedback(body: FeedbackBody, request: Request, background_tasks: BackgroundTasks) -> dict:
@@ -1352,6 +1423,26 @@ def create_app() -> FastAPI:
conn.commit()
return {"ok": True, "override": ov}
@app.post("/api/admin/sources/{sid}/image-policy")
def admin_source_image_policy(sid: int, body: SourceImagePolicyBody, request: Request) -> dict:
# Image rights policy: 'cache' (re-host a downscaled copy — only for sources we've
# cleared: open license / permission / public-domain), 'remote' (hotlink the
# publisher's image), 'none' (no image). Default is the conservative 'remote'.
pol = body.policy
if pol not in ("cache", "remote", "none"):
raise HTTPException(status_code=422, detail="policy must be 'cache', 'remote', or 'none'")
with get_conn() as conn:
_require_admin(conn, request)
cur = conn.execute("UPDATE sources SET image_policy = ? WHERE id = ?", (pol, sid))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="source not found")
conn.commit()
# Leaving 'cache' (e.g. permission revoked) → take the re-hosted copies down now,
# not just make them inaccessible. Setting TO 'cache' just flips the flag; the
# cycle warms it.
purged = newsimg.purge_source(conn, sid) if pol != "cache" else 0
return {"ok": True, "policy": pol, "purged": purged}
# --- Source candidates (supervised add-a-source pipeline) ----------------
def _candidate_dict(row) -> dict:
@@ -1658,19 +1749,32 @@ def create_app() -> FastAPI:
return HTMLResponse(share.render_not_found(PUBLIC_BASE_URL), status_code=404)
return HTMLResponse(share.render_digest(items, PUBLIC_BASE_URL, b.get("brief_date")))
@app.get("/sitemap.xml")
@app.api_route("/sitemap.xml", methods=["GET", "HEAD"])
def sitemap() -> Response:
with get_conn() as conn:
pwx = queries.paywalled_source_ids(conn)
pw_clause = f" AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
# Only articles with a real summary (the page has substance), paywalled excluded.
# Cap near the 50k protocol ceiling so older canonical pages aren't dropped.
rows = conn.execute(
"SELECT a.id, COALESCE(a.published_at, a.discovered_at) AS lm "
"FROM articles a JOIN article_scores s ON s.article_id = a.id "
"WHERE s.accepted = 1 AND a.duplicate_of IS NULL "
"ORDER BY lm DESC LIMIT 5000"
"WHERE s.accepted = 1 AND a.duplicate_of IS NULL" + pw_clause + " "
"AND EXISTS (SELECT 1 FROM article_summaries asum WHERE asum.article_id = a.id) "
"ORDER BY lm DESC LIMIT 50000",
pwx,
).fetchall()
base = PUBLIC_BASE_URL
# Hub + the public sections, then every summarized article page.
urls = [
f"<url><loc>{base}/</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>",
f"<url><loc>{base}/news</loc><changefreq>hourly</changefreq><priority>0.9</priority></url>",
f"<url><loc>{base}/today</loc><changefreq>daily</changefreq><priority>0.9</priority></url>",
f"<url><loc>{base}/art</loc><changefreq>daily</changefreq><priority>0.6</priority></url>",
f"<url><loc>{base}/play</loc><changefreq>weekly</changefreq><priority>0.5</priority></url>",
f"<url><loc>{base}/word</loc><changefreq>daily</changefreq><priority>0.5</priority></url>",
f"<url><loc>{base}/quote</loc><changefreq>daily</changefreq><priority>0.5</priority></url>",
f"<url><loc>{base}/onthisday</loc><changefreq>daily</changefreq><priority>0.5</priority></url>",
]
for r in rows:
lm = (r["lm"] or "")[:10]
@@ -1777,6 +1881,7 @@ def create_app() -> FastAPI:
source_id: int | None = Query(None, ge=1, description="show only this source's articles"),
sort: str = Query("ranked", pattern="^(ranked|latest)$", description="ranked (best-first) or latest (newest-first)"),
following: bool = Query(False, description="restrict to the signed-in user's followed sources/tags"),
home: str | None = Query(None, max_length=8, description="Closer to Home: reader's home as 'US' or 'US-NY'"),
request: Request = None,
) -> FeedResponse:
# Edge-cacheable ONLY when the response depends purely on the URL: not the
@@ -1784,12 +1889,19 @@ def create_app() -> FastAPI:
# (prefs/dismissals are per-reader). The shareable cases — the default
# home feed, topic/flavor/tag/source browse — are identical for everyone,
# so the edge can serve one copy to all. Everything else stays private.
shareable = not following and not prefs and not exclude.strip()
shareable = not following and not prefs and not exclude.strip() and not home
response.headers["Cache-Control"] = _EDGE_FEED if shareable else _PRIVATE
if topic and topic.lower() not in TOPICS:
raise HTTPException(400, f"unknown topic: {topic}")
if flavor and flavor.lower() not in FLAVORS:
raise HTTPException(400, f"unknown flavor: {flavor}")
# Parse the reader's home: 'US' or 'US-NY'. State granularity is US-only for v1.
home_country = home_state = None
if home:
parts = home.upper().split("-", 1)
home_country = (parts[0][:2] or None)
if home_country == "US" and len(parts) > 1:
home_state = parts[1][:2] or None
fp = prefs_from_json(prefs)
now = datetime.now(timezone.utc)
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
@@ -1807,28 +1919,72 @@ def create_app() -> FastAPI:
).fetchall()
kw["follow_sources"] = [int(r["value"]) for r in frows if r["kind"] == "source" and r["value"].isdigit()]
kw["follow_tags"] = [r["value"] for r in frows if r["kind"] == "tag"]
if fp.avoid_terms or excl:
# Over-fetch enough to cover what the Python pass might remove.
fetch_n = min(2000, (offset + limit) * 4 + 50 + len(excl))
raw = queries.feed(
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
limit=fetch_n, offset=0, tag=tag, source_id=source_id, sort=sort, **kw,
def _fetch(scope, lim, off):
# One scoped page, applying the avoid-terms/dismissal Python pass when needed.
if fp.avoid_terms or excl:
fetch_n = min(2000, (off + lim) * 4 + 50 + len(excl))
raw = queries.feed(
conn, topic=topic, flavor=flavor, accepted_only=accepted_only, limit=fetch_n, offset=0,
tag=tag, source_id=source_id, sort=sort,
home_country=home_country, home_state=home_state, geo_scope=scope, **kw,
)
kept = [a for a in filter_articles(raw, fp, now) if a["id"] not in excl]
return kept[off : off + lim]
return queries.feed(
conn, topic=topic, flavor=flavor, accepted_only=accepted_only, limit=lim, offset=off,
tag=tag, source_id=source_id, sort=sort,
home_country=home_country, home_state=home_state, geo_scope=scope, **kw,
)
kept = [a for a in filter_articles(raw, fp, now) if a["id"] not in excl]
rows = kept[offset : offset + limit]
next_offset = None
if home_country:
# Closer to Home. 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. Thin
# tiers fold down so a header is never shown empty (Codex: lead, don't trap).
NEAR_CAP, COUNTRY_CAP, MIN_TIER = 8, 8, 3
if offset == 0:
near = _fetch("near", NEAR_CAP, 0)
country = _fetch("country", COUNTRY_CAP, 0) if home_state else []
world = _fetch("world", limit, 0)
next_offset = limit if len(world) == limit else None
tiers = []
if home_state:
if len(near) >= MIN_TIER:
tiers.append(("near", near))
else:
country = near + country # fold sparse "near" into your country
if len(country) >= MIN_TIER:
tiers.append(("country", country))
else:
world = country + world # fold sparse country into the world
elif len(near) >= MIN_TIER:
tiers.append(("near", near)) # near == your whole country here
else:
world = near + world
tiers.append(("world", world))
rows = []
for key, group in tiers:
for r in group:
r["__section"] = key
rows.append(r)
else:
rows = _fetch("world", limit, offset)
for r in rows:
r["__section"] = "world"
next_offset = offset + limit if len(rows) == limit else None
else:
rows = queries.feed(
conn, topic=topic, flavor=flavor, accepted_only=accepted_only,
limit=limit, offset=offset, tag=tag, source_id=source_id, sort=sort, **kw,
)
# Keep the top of a browse view readable: stable-sort paywalled items
# below readable ones (composite order preserved within each group).
rows = sorted(rows, key=lambda r: is_paywalled_for_source(r["canonical_url"], r["paywall_override"]))
rows = _fetch(None, limit, offset)
next_offset = offset + len(rows) if len(rows) == limit else None
# Section grouping only — paywalled-source stories are excluded upstream now.
_SEC = {"near": 0, "country": 1, "world": 2}
rows = sorted(rows, key=lambda r: _SEC.get(r.get("__section"), 0))
return FeedResponse(
topic=topic,
flavor=flavor,
count=len(rows),
items=[Article.from_row(r) for r in rows],
next_offset=next_offset,
)
@app.get("/api/search", response_model=FeedResponse)
@@ -2112,25 +2268,44 @@ def create_app() -> FastAPI:
limit: int = Query(10, ge=1, le=50),
prefs: str | None = Query(None),
exclude: str = Query("", description="comma-separated article ids the reader has dismissed"),
home: str | None = Query(None, max_length=8, description="local-first highlights: 'US' or 'US-NY'"),
scope: str = Query("nearby", pattern="^(nearby|region|country|world)$", description="radius dial"),
) -> BriefResponse:
# The default highlights are global (date-keyed, no session) → edge-cacheable
# so a new visitor's "Gathering the good news…" resolves from their POP, not
# a pull to the residential origin. Personal filters stay private.
shareable = not prefs and not exclude.strip()
# a pull to the residential origin. Personal filters (incl. a home) stay private.
home_country = home_state = None
if home:
parts = home.upper().split("-", 1)
home_country = parts[0][:2] or None
if home_country == "US" and len(parts) > 1:
home_state = parts[1][:2] or None
shareable = not prefs and not exclude.strip() and not home_country
response.headers["Cache-Control"] = _EDGE_FEED if shareable else _PRIVATE
fp = prefs_from_json(prefs)
now = datetime.now(timezone.utc)
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
with get_conn() as conn:
data = queries.brief(conn, brief_date=date, limit=limit)
if home_country and scope != "world":
# The reader's home + scope dial lead the landing: closest-first good news,
# blended outward so it's always a full set. Over-fetch to survive dismissal/
# boundary filtering, then cap to limit. scope='world' = the global brief.
meta = queries.brief(conn, brief_date=date, limit=1)
data = {"brief_date": meta["brief_date"], "title": "Close to home", "created_at": meta.get("created_at")}
pool = queries.home_brief(conn, home_country, home_state, scope=scope, limit=limit + 12)
else:
data = queries.brief(conn, brief_date=date, limit=limit)
pool = data["items"]
# Drop dismissed (replaced-away) items and anything the reader's
# boundaries hide; avoid-terms take precedence over curation.
items = [a for a in data["items"] if a["id"] not in excl]
items = [a for a in pool if a["id"] not in excl
and not is_paywalled_for_source(a.get("canonical_url"), a.get("paywall_override"))]
if not fp.is_empty():
items = filter_articles(items, fp, now)
items = items[:limit] # home mode over-fetches to survive filtering; cap here
# Keep the highlights full: if a boundary or a dismissal removed a
# story, top up with other readable, boundary-respecting good news
# rather than show fewer.
# rather than show fewer. (Home mode's home_brief already blends to world.)
if len(items) < limit:
have = {a["id"] for a in items} | excl
pool = queries.feed(
@@ -2144,7 +2319,16 @@ def create_app() -> FastAPI:
have.add(a["id"])
# Lead with a gentle, readable story (charged or paywalled stories stay
# in the set, just not as the first thing seen).
items = _pick_lead(items)
if home_country and scope != "world" and items:
# Keep "closest first": pick the gentlest hero from ONLY the closest non-empty
# section, so _pick_lead can never float a wider-region/world story above a
# local one. Wider tiers stay in their order behind it.
lead = items[0].get("__section")
head = [a for a in items if a.get("__section") == lead]
tail = [a for a in items if a.get("__section") != lead]
items = _pick_lead(head) + tail
else:
items = _pick_lead(items)
return BriefResponse(
brief_date=data["brief_date"],
title=data["title"],
@@ -2157,6 +2341,209 @@ def create_app() -> FastAPI:
with get_conn() as conn:
return queries.available_dates(conn, limit=limit)
# --- Daily Art (the /art room) -----------------------------------------
@app.get("/api/art/today")
def art_today(response: Response) -> dict:
with get_conn() as conn:
a = art.get_today(conn)
if not a:
response.headers["Cache-Control"] = _PRIVATE
raise HTTPException(status_code=404, detail="No art yet.")
response.headers["Cache-Control"] = _EDGE_FEED # one piece a day, same for everyone
museums = {"met": "The Met", "aic": "Art Institute of Chicago", "si": "Smithsonian"}
return {
"date": a["art_date"], "object_id": a["object_id"], "title": a["title"],
"artist": a["artist"], "date_text": a["date_text"], "medium": a["medium"],
"department": a["department"], "credit": a["credit"], "source_url": a["source_url"],
"source": a["source"], "museum": museums.get(a["source"], a["source"]),
"is_public_domain": bool(a["is_public_domain"]),
"license": "Public Domain (CC0)" if a["is_public_domain"] else None,
"blurb": a.get("blurb"),
"palette": json.loads(a["palette"]) if a.get("palette") else [],
"image_url": f"/api/art/image/{a['object_id']}",
"image_url_large": f"/api/art/image/{a['object_id']}?size=full",
}
@app.api_route("/api/img/{article_id}", methods=["GET", "HEAD"])
def article_image(article_id: int) -> FileResponse:
"""Serve the locally-cached, downscaled WebP for an accepted, canonical article.
Cache HITS ONLY this never fetches (the cycle owns all fetching via newsimg.warm),
so the public endpoint has no SSRF/worker-exhaustion surface. A miss 404s; the
frontend handles that (retry, then the typographic cover)."""
with get_conn() as conn:
row = conn.execute(
"SELECT a.image_url FROM articles a JOIN article_scores s ON s.article_id = a.id "
"JOIN sources src ON src.id = a.source_id "
"WHERE a.id = ? AND s.accepted = 1 AND a.duplicate_of IS NULL AND src.image_policy = 'cache'",
(article_id,),
).fetchone()
url = row["image_url"] if row else None
path = newsimg.path_for(url) if url else None
if not path:
raise HTTPException(status_code=404, detail="image not cached")
# NOT immutable: a re-enriched article can change image_url, so the bytes behind a
# given id can change. A day's browser cache is plenty (we're direct-origin, no CDN).
return FileResponse(str(path), media_type="image/webp",
headers={"Cache-Control": "public, max-age=86400"})
@app.api_route("/api/art/image/{object_id}", methods=["GET", "HEAD"])
def art_image(object_id: int, size: str = Query("")) -> FileResponse:
cdir = art.cache_dir()
matches = sorted(cdir.glob(f"{object_id}-full.*")) if size == "full" else []
if not matches: # fall back to the web-large copy
matches = sorted(cdir.glob(f"{object_id}.*"))
if not matches:
raise HTTPException(status_code=404, detail="Not cached.")
# Cached museum image: immutable for a given object id.
return FileResponse(str(matches[0]), headers={"Cache-Control": "public, max-age=31536000, immutable"})
@app.get("/api/onthisday/today")
def onthisday_today(response: Response) -> dict:
with get_conn() as conn:
a = onthisday.get_today(conn)
if not a:
response.headers["Cache-Control"] = _PRIVATE
raise HTTPException(status_code=404, detail="No fact yet.")
response.headers["Cache-Control"] = _EDGE_FEED # one fact a day, same for everyone
return {
"date": a["feature_date"], "year": a["year"], "text": a["text"],
"summary": a["summary"], "image_url": a["image_url"], "source_url": a["page_url"],
"source": a["source"],
}
@app.get("/api/quote/today")
def quote_today(response: Response) -> dict:
with get_conn() as conn:
q = quote.get_today(conn)
if not q:
response.headers["Cache-Control"] = _PRIVATE
raise HTTPException(status_code=404, detail="No quote yet.")
response.headers["Cache-Control"] = _EDGE_FEED
return {"date": q["feature_date"], "text": q["text"], "author": q["author"],
"work": q["work"], "year": q["year"], "meaning": q["meaning"], "source": q["source"]}
@app.get("/api/word/today")
def word_today(response: Response) -> dict:
with get_conn() as conn:
w = wotd.get_today(conn)
if not w:
response.headers["Cache-Control"] = _PRIVATE
raise HTTPException(status_code=404, detail="No word yet.")
response.headers["Cache-Control"] = _EDGE_FEED
# Prefer the LLM-polished gloss + everyday sentences; fall back to the raw dictionary.
raw_examples = w.get("usage") or w.get("examples")
try:
examples = json.loads(raw_examples) if raw_examples else []
except (ValueError, TypeError):
examples = []
return {"date": w["feature_date"], "word": w["word"], "part_of_speech": w["part_of_speech"],
"phonetic": w["phonetic"], "definition": w.get("gloss") or w["definition"], "examples": examples,
"audio_url": f"/api/word/audio/{w['word']}" if w["audio_file"] else None}
@app.api_route("/api/word/audio/{word}", methods=["GET", "HEAD"])
def word_audio(word: str) -> FileResponse:
matches = sorted(wotd.cache_dir().glob(f"{word.lower()}.*"))
matches = [m for m in matches if not m.name.startswith(".")]
if not matches:
raise HTTPException(status_code=404, detail="No audio.")
return FileResponse(str(matches[0]), headers={"Cache-Control": "public, max-age=31536000, immutable"})
# --- Small-joys admin: manage the WOTD / QOTD / On-This-Day pools ----------------
_JOY_TABLES = {"onthisday": "onthisday_pool", "quote": "quote_pool", "word": "wotd_pool"}
_JOY_DAILY = {"onthisday": "daily_onthisday", "quote": "daily_quote", "word": "daily_wotd"}
_JOY_MODULES = {"onthisday": onthisday, "quote": quote, "word": wotd}
_JOY_EDITABLE = { # whitelist of editable columns
"onthisday": {"text", "summary", "year"},
"quote": {"text", "author", "work", "year", "meaning"},
"word": {"definition", "part_of_speech", "phonetic"},
}
@app.get("/api/admin/joys/{kind}")
def admin_joys_list(kind: str, request: Request, limit: int = Query(300, ge=1, le=2000)) -> list[dict]:
table = _JOY_TABLES.get(kind)
if not table:
raise HTTPException(status_code=404, detail="Unknown joy.")
with get_conn() as conn:
_require_admin(conn, request)
rows = conn.execute(f"SELECT * FROM {table} ORDER BY featured DESC, id DESC LIMIT ?", (limit,)).fetchall()
return [dict(r) for r in rows]
@app.post("/api/admin/joys/{kind}/items/{item_id}") # /items/ so 'add'/'repick' don't parse as an id
def admin_joys_mutate(kind: str, item_id: int, body: JoyAction, request: Request) -> dict:
table = _JOY_TABLES.get(kind)
if not table:
raise HTTPException(status_code=404, detail="Unknown joy.")
with get_conn() as conn:
_require_admin(conn, request)
a = body.action
if a in ("block", "unblock"):
conn.execute(f"UPDATE {table} SET blocked=? WHERE id=?", (1 if a == "block" else 0, item_id))
elif a in ("feature", "unfeature"):
conn.execute(f"UPDATE {table} SET featured=? WHERE id=?", (1 if a == "feature" else 0, item_id))
elif a == "delete":
conn.execute(f"DELETE FROM {table} WHERE id=?", (item_id,))
elif a == "edit":
cols = _JOY_EDITABLE[kind] & set((body.fields or {}).keys())
if cols:
sets = ", ".join(f"{c}=?" for c in cols)
conn.execute(f"UPDATE {table} SET {sets} WHERE id=?", (*(body.fields[c] for c in cols), item_id))
else:
raise HTTPException(status_code=400, detail="Unknown action.")
conn.commit()
return {"ok": True}
@app.post("/api/admin/joys/{kind}/add")
def admin_joys_add(kind: str, body: JoyAdd, request: Request) -> dict:
with get_conn() as conn:
_require_admin(conn, request)
if kind == "quote":
if not body.text:
raise HTTPException(status_code=400, detail="text required")
conn.execute("INSERT OR IGNORE INTO quote_pool (source, ckey, text, author, work) VALUES ('admin',?,?,?,?)",
(daily.content_key(body.text, body.author), body.text, body.author, body.work))
elif kind == "onthisday":
if not body.text or not body.md:
raise HTTPException(status_code=400, detail="md + text required")
conn.execute("INSERT OR IGNORE INTO onthisday_pool (source, md, year, ckey, text, summary, image_url, page_url) "
"VALUES ('admin',?,?,?,?,?,?,?)",
(body.md, body.year, daily.content_key(body.md, body.year, body.text),
body.text, body.summary, body.image_url, body.page_url))
elif kind == "word":
if not body.word:
raise HTTPException(status_code=400, detail="word required")
info = wotd._lookup(body.word.strip().lower()) # network up front
if not info:
raise HTTPException(status_code=400, detail="Word not found in dictionary.")
audio_file = wotd._cache_audio(info["audio_url"], info["word"])
polished = wotd._polish(LocalModelClient.from_env(), info["word"], info["part_of_speech"], info["definition"])
gloss = polished["gloss"] if polished else None
usage = json.dumps(polished["examples"]) if polished else None
conn.execute("INSERT OR IGNORE INTO wotd_pool (source, word, part_of_speech, phonetic, audio_file, audio_url, definition, examples, gloss, usage) "
"VALUES ('admin',?,?,?,?,?,?,?,?,?)",
(info["word"], info["part_of_speech"], info["phonetic"], audio_file, info["audio_url"],
info["definition"], json.dumps(info["examples"]), gloss, usage))
else:
raise HTTPException(status_code=404, detail="Unknown joy.")
conn.commit()
return {"ok": True}
@app.post("/api/admin/joys/{kind}/repick")
def admin_joys_repick(kind: str, request: Request) -> dict:
mod = _JOY_MODULES.get(kind)
if not mod:
raise HTTPException(status_code=404, detail="Unknown joy.")
with get_conn() as conn:
_require_admin(conn, request)
cur = conn.execute(
f"SELECT pool_id FROM {_JOY_DAILY[kind]} WHERE feature_date=?", (local_today(),)
).fetchone()
avoid = cur["pool_id"] if cur else None # force a DIFFERENT item, not the same one
kwargs = {"force": True, "avoid": avoid}
if kind in ("quote", "word"): # these polish lazily (gloss / meaning)
kwargs["client"] = LocalModelClient.from_env()
picked = mod.pick_daily(conn, **kwargs)
return {"ok": True, "picked": bool(picked)}
@app.get("/api/replacement", response_model=Article | None)
def replacement(
exclude: str = Query("", description="comma-separated article ids already shown"),
+276
View File
@@ -0,0 +1,276 @@
"""Daily Art — the /art room. One gorgeous public-domain masterwork a day, picked from
a curated pool of museum highlights and cached to OUR origin (image + metadata), so the
homepage never waits on, nor hotlinks, the museum.
Source: The Met Collection API (no key; public-domain works are CC0 free, unrestricted,
caching encouraged). Curation = isHighlight + isPublicDomain + hasImages, so the pool is
masterworks, never potsherds. Built to be bulletproof: a failed pick falls through to the
next candidate, and a failed day keeps yesterday's piece — the room is never empty.
Network calls go through module-level _http_* helpers so tests can monkeypatch them.
"""
from __future__ import annotations
import hashlib
import json
import os
import sqlite3
import urllib.error
import urllib.request
from pathlib import Path
from .localtime import local_today
MET_BASE = "https://collectionapi.metmuseum.org/public/collection/v1"
# Broad, visual, museum-grade terms. Each is filtered to public-domain highlights with
# images, then deduped — a diverse pool of a few thousand masterworks.
HARVEST_QUERIES = ("painting", "portrait", "landscape", "still life", "flowers",
"sculpture", "drawing", "garden", "river", "sunset")
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
_PICK_ATTEMPTS = 8 # candidates to try before giving up for the day
_NO_REPEAT_POOL = 40 # pick the daily piece from the N least-recently-shown
_MIN_IMAGE_BYTES = 3000 # smaller than this = not a real image
def _http_json(url: str, timeout: int = 20) -> dict:
req = urllib.request.Request(url, headers=_UA)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
def _http_bytes(url: str, timeout: int = 30) -> tuple[bytes, str]:
req = urllib.request.Request(url, headers=_UA)
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read(), (r.headers.get("Content-Type") or "")
def cache_dir() -> Path:
"""Where cached images live — beside the DB, so the host cycle writes and the API
container reads the same mounted volume."""
override = os.environ.get("GOODNEWS_ART_CACHE")
if override:
d = Path(override)
else:
db = Path(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
d = db.parent / "art_cache"
d.mkdir(parents=True, exist_ok=True)
return d
# --- harvest: build the curated pool of object IDs -------------------------------
def harvest_pool(conn: sqlite3.Connection, queries=HARVEST_QUERIES, source: str = "met") -> dict:
"""Query the Met for public-domain highlight images across broad art terms; store the
deduped object IDs. Cheap: each search returns all matching IDs in one call. Per-query
failure is non-fatal."""
found, errors = set(), 0
for q in queries:
url = (f"{MET_BASE}/search?isHighlight=true&hasImages=true&isPublicDomain=true"
f"&q={urllib.request.quote(q)}")
try:
data = _http_json(url)
for oid in (data.get("objectIDs") or []):
if isinstance(oid, int):
found.add(oid)
except Exception: # noqa: BLE001 — non-fatal per query
errors += 1
before = conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0]
conn.executemany(
"INSERT OR IGNORE INTO art_pool (source, object_id) VALUES (?, ?)",
[(source, oid) for oid in found],
)
conn.commit()
after = conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0]
return {"queried": len(queries), "errors": errors, "found": len(found),
"added": after - before, "pool": after}
# --- daily pick: choose, fetch, cache --------------------------------------------
def _object(object_id: int) -> dict:
return _http_json(f"{MET_BASE}/objects/{object_id}")
def _fetch_to_cache(url: str | None, stem: str) -> str | None:
"""Download one image URL to cache as {stem}.{ext}; return the filename or None.
Writes to a temp file then atomically renames, so a reader never sees a half-file."""
if not url:
return None
try:
data, ctype = _http_bytes(url)
except Exception: # noqa: BLE001
return None
if not ctype.startswith("image/") or len(data) < _MIN_IMAGE_BYTES:
return None
ext = ".png" if "png" in ctype else ".jpg"
fname = f"{stem}{ext}"
cdir = cache_dir()
tmp = cdir / f".{stem}.tmp"
try:
tmp.write_bytes(data)
os.replace(tmp, cdir / fname) # atomic
except OSError:
try:
tmp.unlink()
except OSError:
pass
return None
return fname
def _download_image(obj: dict, object_id: int) -> str | None:
"""Cache the day's images to our origin. Stores the web-large display copy as
{id}.{ext} (what the page shows) and, when available, the full-resolution copy as
{id}-full.{ext} (what the lightbox opens, so zoom fills the screen). Returns the
display filename or None if even the display copy couldn't be fetched."""
display = _fetch_to_cache(obj.get("primaryImageSmall") or obj.get("primaryImage"), str(object_id))
if not display:
return None
full_url = obj.get("primaryImage")
if full_url and full_url != obj.get("primaryImageSmall"):
_fetch_to_cache(full_url, f"{object_id}-full") # best-effort hi-res for zoom
return display
def _palette(image_path: "Path", n: int = 5) -> list[str]:
"""Extract ~n representative hex colors from the cached image (for the 'colors in this
piece' strip). Best-effort: any failure → empty list (the strip just hides)."""
try:
from PIL import Image
with Image.open(image_path) as im:
im = im.convert("RGB")
im.thumbnail((120, 120)) # tiny — palette, not fidelity
# Adaptive median-cut to a small palette, then order by how much of the image each covers.
q = im.quantize(colors=max(n * 2, 8), method=Image.Quantize.MEDIANCUT)
pal = q.getpalette()
counts = sorted(q.getcolors(), reverse=True) # [(count, index), ...] most-used first
out, seen = [], set()
for _count, idx in counts:
r, g, b = pal[idx * 3], pal[idx * 3 + 1], pal[idx * 3 + 2]
hexc = f"#{r:02x}{g:02x}{b:02x}"
if hexc in seen:
continue
seen.add(hexc)
out.append(hexc)
if len(out) >= n:
break
return out
except Exception: # noqa: BLE001 — palette is decorative; never break the pick
return []
_BLURB_SYSTEM = (
"You are the calm, knowledgeable curator of a daily-art feature for a general audience — "
"people who enjoy a beautiful painting but aren't art historians. In 2 to 3 warm, plain "
"sentences, help them appreciate the piece and why it's worth a moment: its mood, the "
"artist, the era or movement, and a little real context or significance.\n"
"GROUNDING (important): the catalogue details below — especially the title and the "
"'Depicts' tags — are your only reliable guide to the SUBJECT. You cannot actually see the "
"image, so do NOT assert literal visual specifics you can't verify: do not state how many "
"figures are shown, their exact poses or actions, colors, or background details. Lean on "
"what's certain (title, tags, medium, date, artist, movement) and on feeling/significance. "
"If you don't recognize the exact work, stay general and contextual rather than inventing. "
"No preamble, no title repetition, no hype, no markdown — just the note."
)
def _blurb(client, obj: dict) -> str | None:
"""A short 'museum guide' note for the piece, grounded in the Met catalogue metadata.
Best-effort + cached by the caller; returns None on any trouble."""
tags = ", ".join(t.get("term", "") for t in (obj.get("tags") or []) if t.get("term"))[:200]
facts = "\n".join(f"{k}: {v}" for k, v in (
("Title", obj.get("title")), ("Artist", obj.get("artistDisplayName")),
("Artist bio", obj.get("artistDisplayBio")), ("Date", obj.get("objectDate")),
("Medium", obj.get("medium")), ("Type", obj.get("objectName")),
("Classification", obj.get("classification")), ("Culture", obj.get("culture")),
("Period", obj.get("period")), ("Depicts", tags),
) if v)
user = f"Catalogue details:\n{facts}\n\nWrite the note."
try:
out = client.chat_text([{"role": "system", "content": _BLURB_SYSTEM},
{"role": "user", "content": user}]) or ""
except Exception: # noqa: BLE001
return None
out = " ".join(out.replace("*", "").replace("_", " ").split()).strip()[:600] # no stray markdown
return out or None
def _candidates(conn: sqlite3.Connection, art_date: str, source: str) -> list[int]:
"""The N least-recently-shown pool IDs, rotated deterministically by the date so the
same piece shows for everyone that day and pieces don't repeat soon."""
rows = conn.execute(
"SELECT object_id FROM art_pool WHERE source=? AND blocked=0 "
"ORDER BY shown_at IS NOT NULL, shown_at, object_id LIMIT ?",
(source, _NO_REPEAT_POOL),
).fetchall()
ids = [r[0] for r in rows]
if not ids:
return ids
seed = int(hashlib.sha256(art_date.encode()).hexdigest(), 16) % len(ids)
return ids[seed:] + ids[:seed] # rotate so the daily choice is stable but varies
def pick_daily(conn: sqlite3.Connection, art_date: str | None = None, source: str = "met",
force: bool = False, client=None) -> dict | None:
"""Pick + cache the day's art. Idempotent (skips if today's already done unless force).
Tries successive candidates so a bad object/image never breaks the day; returns the
stored row, or None if nothing could be fetched (caller keeps the prior day's piece)."""
art_date = art_date or local_today()
existing = conn.execute("SELECT * FROM daily_art WHERE art_date=?", (art_date,)).fetchone()
if existing and not force:
return dict(existing)
for oid in _candidates(conn, art_date, source):
try:
obj = _object(oid)
except Exception: # noqa: BLE001
continue
if not obj.get("isPublicDomain"):
continue
fname = _download_image(obj, oid)
if not fname:
continue
# All network/LLM/compute is done up front; only then a brief write txn + commit.
palette = json.dumps(_palette(cache_dir() / fname)) or None
blurb = _blurb(client, obj) if client else None
conn.execute(
"INSERT INTO daily_art (art_date, source, object_id, title, artist, date_text, medium, "
"department, credit, source_url, image_file, image_url_full, is_public_domain, blurb, palette) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(art_date) DO UPDATE SET object_id=excluded.object_id, title=excluded.title, "
"artist=excluded.artist, date_text=excluded.date_text, medium=excluded.medium, "
"department=excluded.department, credit=excluded.credit, source_url=excluded.source_url, "
"image_file=excluded.image_file, image_url_full=excluded.image_url_full, "
"is_public_domain=excluded.is_public_domain, blurb=excluded.blurb, palette=excluded.palette",
(art_date, source, oid, obj.get("title") or "Untitled",
obj.get("artistDisplayName") or None, obj.get("objectDate") or None,
obj.get("medium") or None, obj.get("department") or None,
obj.get("creditLine") or None, obj.get("objectURL") or None, fname,
obj.get("primaryImage") or None, 1 if obj.get("isPublicDomain") else 0, blurb, palette),
)
conn.execute("UPDATE art_pool SET shown_at=? WHERE source=? AND object_id=?",
(art_date, source, oid))
conn.commit()
return dict(conn.execute("SELECT * FROM daily_art WHERE art_date=?", (art_date,)).fetchone())
return None # nothing fetched today — get_today falls back to the latest piece
def get_today(conn: sqlite3.Connection, art_date: str | None = None) -> dict | None:
"""Today's piece if present, else the most recent one cached (room is never empty)."""
if art_date:
row = conn.execute("SELECT * FROM daily_art WHERE art_date=?", (art_date,)).fetchone()
if row:
return dict(row)
row = conn.execute("SELECT * FROM daily_art ORDER BY art_date DESC LIMIT 1").fetchone()
return dict(row) if row else None
def run_daily(conn: sqlite3.Connection, source: str = "met", client=None) -> dict:
"""Cycle entry point: ensure the pool exists, then ensure today has a piece. Bounded
and non-fatal safe to call every cycle (it no-ops once the day is picked)."""
pool = conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0]
harvested = None
if pool == 0:
harvested = harvest_pool(conn, source=source)
picked = pick_daily(conn, source=source, client=client)
return {"pool": conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0],
"harvested": harvested, "picked_object": picked.get("object_id") if picked else None}
+11 -7
View File
@@ -4,6 +4,7 @@ import sqlite3
from .localtime import local_today
from .paywall import is_paywalled, is_paywalled_for_source
from .queries import paywalled_source_ids
def build_daily_brief(
@@ -15,11 +16,10 @@ def build_daily_brief(
) -> int:
target_date = brief_date or local_today()
# Compose the selection first so we can tell whether anything actually
# changed. A calm daily brief shouldn't repeatedly hand the reader a locked
# door: push paywalled candidates below readable ones (stable sort) first.
# Compose the selection first so we can tell whether anything actually changed.
# A calm daily brief never hands the reader a locked door: paywalled-source
# candidates are excluded in _candidate_articles (before LIMIT) — no unreadable news.
rows = _candidate_articles(conn, target_date, window_days)
rows = sorted(rows, key=lambda r: is_paywalled_for_source(r["canonical_url"], r["paywall_override"]))
selected = _select_diverse(rows, limit)
selected_ids = [row["id"] for row in selected]
@@ -107,10 +107,13 @@ def _candidate_articles(
`window_days` so the brief still fills on slow news days. Anything already
featured in a brief within the last 7 days (other than this same date, which
is being rebuilt) is excluded so backfilled stories cannot linger across
consecutive days.
consecutive days. Paywalled sources are excluded here (before LIMIT) so they
can't consume candidate slots and leave an otherwise-full brief thin.
"""
pwx = paywalled_source_ids(conn)
pw_clause = f"AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
return conn.execute(
"""
f"""
SELECT
a.id,
a.title,
@@ -152,6 +155,7 @@ def _candidate_articles(
AND b.brief_date <= date(?)
AND b.brief_date > date(?, '-7 days')
)
{pw_clause}
ORDER BY
is_today DESC,
(s.constructive_score + s.agency_score + s.human_benefit_score + src.trust_score
@@ -159,7 +163,7 @@ def _candidate_articles(
COALESCE(a.published_at, a.discovered_at) DESC
LIMIT 50
""",
(target_date, target_date, target_date, window_days, target_date, target_date, target_date),
(target_date, target_date, target_date, window_days, target_date, target_date, target_date, *pwx),
).fetchall()
+96 -1
View File
@@ -12,7 +12,9 @@ from .digest import send_due_digests
from .games import generate_daily_puzzles
from .localtime import local_today
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, cluster_duplicates, dedup as run_dedup
from .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images
from .geo import tag_articles as tag_geo
from . import art, newsimg, onthisday, quote, wotd
from .enrich import enrich_brief_images, enrich_read_times, enrich_recent_images, enrich_summarized_images
from .summarize import generate_summary, get_summary
from .feeds import (
fetch_feed,
@@ -132,6 +134,10 @@ def main() -> None:
cycle_parser.add_argument("--classify-limit", type=int, default=40)
cycle_parser.add_argument("--no-classify", action="store_true", help="Skip the LLM classify step")
cycle_parser.add_argument("--no-dedup", action="store_true", help="Skip the embedding dedup step")
cycle_parser.add_argument("--no-geo", action="store_true", help="Skip tagging article subject-geography")
cycle_parser.add_argument("--geo-limit", type=int, default=60, help="Max articles to geo-tag per cycle")
cycle_parser.add_argument("--no-art", action="store_true", help="Skip the Daily Art pick")
cycle_parser.add_argument("--no-joys", action="store_true", help="Skip the small-joys picks (On This Day, etc.)")
cycle_parser.add_argument("--no-brief", action="store_true", help="Skip rebuilding today's brief")
cycle_parser.add_argument("--no-review", action="store_true", help="Skip recomputing source review flags")
cycle_parser.add_argument("--no-digest", action="store_true", help="Skip sending due daily digests")
@@ -147,6 +153,16 @@ def main() -> None:
)
enrich_images_parser.add_argument("--limit", type=int, default=50, help="Max articles to fetch this batch")
art_parser = subparsers.add_parser("art", help="Daily Art: harvest the pool and/or pick today's cached piece")
art_parser.add_argument("--harvest", action="store_true", help="(Re)harvest the curated museum pool")
art_parser.add_argument("--force", action="store_true", help="Re-pick today's art even if already chosen")
geo_parser = subparsers.add_parser("geo", help="Tag article subject-geography (backfill / manual). Cycle-locked.")
geo_parser.add_argument("--limit", type=int, default=200, help="Max articles to tag this batch")
geo_parser.add_argument("--reclassify", action="store_true", help="Re-tag even rows already at the current geo version")
geo_parser.add_argument("--base-url", help="OpenAI-compatible base URL")
geo_parser.add_argument("--model", help="Local model name")
dedup_parser = subparsers.add_parser("dedup", help="Cluster near-duplicate stories via local embeddings")
dedup_parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD, help="Cosine similarity cutoff")
dedup_parser.add_argument("--window-days", type=int, default=DEFAULT_WINDOW_DAYS)
@@ -298,6 +314,26 @@ def main() -> None:
elif args.command == "enrich-images":
found = enrich_summarized_images(conn, limit=args.limit)
print(f"enrich-images: {found} new image(s) for summarized articles")
elif args.command == "art":
init_db(conn)
if args.harvest:
h = art.harvest_pool(conn)
print(f"art harvest: found={h['found']} added={h['added']} pool={h['pool']} errors={h['errors']}")
picked = art.pick_daily(conn, force=args.force, client=LocalModelClient.from_env())
if picked:
print(f"art pick: {picked['art_date']} -> #{picked['object_id']} "
f"\"{picked['title']}\"{picked['artist'] or 'Unknown'}")
else:
print("art pick: nothing fetched (kept the last piece)")
elif args.command == "geo":
init_db(conn)
# Cycle-locked so a manual backfill can't contend with the scheduled cycle.
with cycle_lock(args.db) as acquired:
if not acquired:
print("geo: a cycle is already running; try again after it finishes")
return
g = tag_geo(conn, llm_client_from_args(args), limit=args.limit, reclassify=args.reclassify)
print(f"geo: tagged={g['tagged']} errors={g['errors']} (of {g['candidates']} candidates)")
elif args.command == "dedup":
init_db(conn)
if args.force_recluster:
@@ -506,6 +542,45 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
except Exception as exc:
print(f"dedup: skipped ({exc})")
# Geo: tag newly-accepted, non-duplicate articles with subject geography (its own
# LLM pass, decoupled from scoring). Bounded per cycle; idempotent (skips rows
# already at the current GEO_VERSION). Non-fatal like every other step.
if not args.no_geo:
try:
g = tag_geo(conn, llm_client_from_args(args), limit=args.geo_limit)
print(f"geo: tagged={g['tagged']} errors={g['errors']} (of {g['candidates']} untagged)")
except Exception as exc:
print(f"geo: skipped ({exc})")
# Daily Art: ensure the pool exists, then ensure today has a cached piece. No-ops
# once the day is picked; non-fatal like every other step.
if not args.no_art:
try:
a = art.run_daily(conn, client=LocalModelClient.from_env()) # client → the guide blurb
print(f"art: pool={a['pool']} picked={a['picked_object']}")
except Exception as exc:
print(f"art: skipped ({exc})")
# Small joys: On This Day (history), Quote of the Day, Word of the Day. Each is
# bounded + non-fatal; one shared LLM client for tone/explainer/word proposals.
if not args.no_joys:
joy_client = LocalModelClient.from_env()
try:
o = onthisday.run_daily(conn, client=joy_client)
print(f"onthisday: md={o['md']} picked={'yes' if o['picked'] else 'no'}")
except Exception as exc:
print(f"onthisday: skipped ({exc})")
try:
q = quote.run_daily(conn, client=joy_client)
print(f"quote: pool={q['pool']} picked={q['picked']}")
except Exception as exc:
print(f"quote: skipped ({exc})")
try:
w = wotd.run_daily(conn, client=joy_client)
print(f"word: pool={w['pool']} picked={w['picked']}")
except Exception as exc:
print(f"word: skipped ({exc})")
if not args.no_brief:
today = local_today()
try:
@@ -524,6 +599,26 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
except Exception as exc:
print(f"recent images: skipped ({exc})")
# Cache downscaled display copies of those images on our own origin (so the
# hub/feed serve local files, not flaky third-party hotlinks), then enforce the
# size ceiling with LRU eviction. Both bounded + non-fatal.
try:
warmed = newsimg.warm(conn)
pr = newsimg.prune()
print(f"images: cached={warmed} size={pr['after'] // 1048576}MB/"
f"{pr['cap'] // 1048576}MB evicted={pr['removed']}")
except Exception as exc:
print(f"images: skipped ({exc})")
# Full-article read-times: count words for recent accepted articles so the
# front door can show "Full story · ~N min" next to our gist (bounded per cycle).
try:
reads = enrich_read_times(conn)
if reads:
print(f"read-times: {reads} counted")
except Exception as exc:
print(f"read-times: skipped ({exc})")
# Pre-warm summaries for today's brief so Today reads as a calm briefing.
# Idempotent: cached items are skipped, so this only hits the LLM for new ones.
try:
+54
View File
@@ -0,0 +1,54 @@
"""Shared helpers for the daily "small joys" features — On This Day, Word of the Day,
Quote of the Day (and whatever calm little delights come next).
Each joy keeps its own pool + daily table, but they all share this skeleton:
harvest -> pool deterministic daily pick (date-seeded, least-recently-shown)
cache row in a daily_* table API page.
This module holds only the genuinely shared bits (network + the deterministic pick), so a
new joy is a small self-contained module, not a copy-paste of plumbing. Network calls go
through http_json so tests can monkeypatch them.
"""
from __future__ import annotations
import hashlib
import json
import urllib.request
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
def http_json(url: str, timeout: int = 20) -> dict:
req = urllib.request.Request(url, headers=_UA)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
def seeded_order(ids: list, date_str: str) -> list:
"""Rotate a list deterministically by the date, so the day's pick is the same for
everyone and varies day to day (the same trick Daily Art uses)."""
if not ids:
return ids
seed = int(hashlib.sha256(date_str.encode()).hexdigest(), 16) % len(ids)
return ids[seed:] + ids[:seed]
def freshest(rows: list) -> list:
"""The cohort to feature today, from pool rows carrying `id` + `shown_at`: every
NEVER-shown item (shown_at NULL) while any remain, else every item tied for the
OLDEST shown_at. Guarantees each pool item is featured ONCE before any repeat, then
cycles oldest-first. Pick deterministically *within* this cohort (seeded_order)
NEVER across the whole pool, which re-feeds recent items (the QOTD/WOTD repeat bug)."""
never = [r["id"] for r in rows if r["shown_at"] is None]
if never:
return sorted(never)
if not rows:
return []
oldest = min(r["shown_at"] for r in rows)
return sorted(r["id"] for r in rows if r["shown_at"] == oldest)
def content_key(*parts) -> str:
"""A stable dedup key for a pool item (so re-harvesting never duplicates a row)."""
raw = "|".join("" if p is None else str(p) for p in parts)
return hashlib.sha256(raw.encode()).hexdigest()[:24]
+177
View File
@@ -49,6 +49,8 @@ CREATE TABLE IF NOT EXISTS articles (
title_hash TEXT,
duplicate_of INTEGER REFERENCES articles(id) ON DELETE SET NULL,
image_checked_at TEXT,
source_words INTEGER, -- full-article word count (metadata only; never the body)
read_checked_at TEXT, -- when we last tried to count words (retry guard)
FOREIGN KEY (source_id) REFERENCES sources(id)
);
@@ -217,6 +219,146 @@ CREATE TABLE IF NOT EXISTS article_summaries (
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Where a story is ABOUT (subject geography), kept SEPARATE from article_scores so
-- durable geography isn't coupled to volatile scoring/acceptance. "local" is never
-- stored here it's relative to the reader; the UI computes "Near you" by comparing
-- these places to the visitor's chosen home. geo_version lets us re-backfill cleanly
-- when the prompt/taxonomy changes. 'global' is a real category, not a failure.
CREATE TABLE IF NOT EXISTS article_geo (
article_id INTEGER PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE,
breadth TEXT NOT NULL DEFAULT 'unknown', -- locality|regional|national|multinational|global|unknown
confidence TEXT NOT NULL DEFAULT 'low', -- high|medium|low
rationale TEXT,
geo_version TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 0..N normalized places per article (a story can span regions). Codes are ISO
-- (country = alpha-2, state = US 2-letter / ISO-3166-2 subdivision), normalized in
-- code never trusting the model's free text.
CREATE TABLE IF NOT EXISTS article_places (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
country_code TEXT,
state_code TEXT,
locality TEXT,
ord INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_article_places_article ON article_places(article_id);
CREATE INDEX IF NOT EXISTS idx_article_places_country ON article_places(country_code);
CREATE INDEX IF NOT EXISTS idx_article_geo_breadth ON article_geo(breadth);
-- Daily Art (the /art room). art_pool = a curated set of public-domain, highlighted
-- museum object IDs (so the daily pick never hits a potsherd). daily_art = one cached
-- piece per day (metadata + a locally-cached image), so the homepage never waits on or
-- hotlinks the museum. shown_at lets us avoid repeating a piece too soon.
CREATE TABLE IF NOT EXISTS art_pool (
object_id INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'met',
shown_at TEXT,
blocked INTEGER NOT NULL DEFAULT 0, -- manual lever: skip an odd/unsuitable piece
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (source, object_id)
);
CREATE TABLE IF NOT EXISTS daily_art (
art_date TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'met',
object_id INTEGER NOT NULL,
title TEXT,
artist TEXT,
date_text TEXT,
medium TEXT,
department TEXT,
credit TEXT, -- museum credit line
source_url TEXT, -- canonical museum object page
image_file TEXT, -- our cached (web-large) image
image_url_full TEXT, -- source full-res URL, for a later richer /art view
is_public_domain INTEGER, -- license marker (CC0/public domain), stored for citizenship
blurb TEXT, -- LLM "museum guide" note: what you're looking at (cached)
palette TEXT, -- JSON array of hex colors extracted from the image
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- "Small joys" daily features. On This Day: a good/neutral thing that happened on
-- today's calendar date, harvested + tone-filtered into a pool, then one picked per day.
CREATE TABLE IF NOT EXISTS onthisday_pool (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT 'wikimedia', -- multi-source ready (wikimedia | admin | ...)
md TEXT NOT NULL, -- 'MM-DD'
year INTEGER,
ckey TEXT NOT NULL UNIQUE, -- dedup hash so re-harvest never duplicates
text TEXT NOT NULL,
summary TEXT,
image_url TEXT,
page_url TEXT,
shown_at TEXT, -- last date this was the pick (no-soon-repeat)
blocked INTEGER NOT NULL DEFAULT 0, -- admin lever: never pick this
featured INTEGER NOT NULL DEFAULT 0, -- admin lever: prefer this for its date
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS daily_onthisday (
feature_date TEXT PRIMARY KEY, -- 'YYYY-MM-DD'
pool_id INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'wikimedia',
md TEXT NOT NULL,
year INTEGER,
text TEXT,
summary TEXT,
image_url TEXT,
page_url TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Quote of the Day: curated public-domain quotes; one picked per day. `meaning` is an
-- AI explainer of the (real) quote, filled lazily the first time it's shown.
CREATE TABLE IF NOT EXISTS quote_pool (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT 'curated',
ckey TEXT NOT NULL UNIQUE,
text TEXT NOT NULL,
author TEXT,
work TEXT,
year TEXT,
meaning TEXT,
shown_at TEXT,
blocked INTEGER NOT NULL DEFAULT 0,
featured INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS daily_quote (
feature_date TEXT PRIMARY KEY,
pool_id INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'curated',
text TEXT, author TEXT, work TEXT, year TEXT, meaning TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Word of the Day: LLM-proposed positive words, validated/enriched against a real
-- dictionary (definition, IPA, examples, cached audio clip); one picked per day.
CREATE TABLE IF NOT EXISTS wotd_pool (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT 'llm',
word TEXT NOT NULL UNIQUE,
part_of_speech TEXT,
phonetic TEXT, -- IPA
audio_file TEXT, -- our cached pronunciation clip (or null browser TTS)
audio_url TEXT, -- source clip URL
definition TEXT NOT NULL, -- raw dictionary gloss (anchor / ground truth)
examples TEXT, -- JSON array of raw dictionary example sentences (anchor)
gloss TEXT, -- LLM plain-language rewrite of the definition (for display)
usage TEXT, -- JSON array of LLM everyday example sentences (for display)
shown_at TEXT,
blocked INTEGER NOT NULL DEFAULT 0,
featured INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS daily_wotd (
feature_date TEXT PRIMARY KEY,
pool_id INTEGER NOT NULL,
word TEXT, part_of_speech TEXT, phonetic TEXT, audio_file TEXT,
definition TEXT, examples TEXT, gloss TEXT, usage TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Privacy-respecting, first-party analytics. NO IP / user-agent / referrer / raw
-- URL. visitor_hash is a hash of a random localStorage token (never email/IP).
-- The UNIQUE key dedups to one row per (kind, article, visitor, day) that both
@@ -457,6 +599,10 @@ def _migrate(conn: sqlite3.Connection) -> None:
)
if "image_checked_at" not in article_cols:
conn.execute("ALTER TABLE articles ADD COLUMN image_checked_at TEXT")
if "source_words" not in article_cols: # full-article read-time (count only, no body)
conn.execute("ALTER TABLE articles ADD COLUMN source_words INTEGER")
if "read_checked_at" not in article_cols:
conn.execute("ALTER TABLE articles ADD COLUMN read_checked_at TEXT")
# Created here (not in SCHEMA) so it runs after the column exists on upgrades.
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_duplicate_of ON articles(duplicate_of)")
@@ -486,12 +632,35 @@ def _migrate(conn: sqlite3.Connection) -> None:
conn.execute("ALTER TABLE sources ADD COLUMN content_visible INTEGER NOT NULL DEFAULT 1")
if "retry_after_at" not in source_cols:
conn.execute("ALTER TABLE sources ADD COLUMN retry_after_at TEXT")
# Image rights policy per source: 'cache' (cleared to re-host a downscaled copy),
# 'remote' (hotlink the publisher's image — the conservative DEFAULT), 'none' (no
# image). Caching is opt-in; unknown/new sources are never re-hosted.
if "image_policy" not in source_cols:
conn.execute("ALTER TABLE sources ADD COLUMN image_policy TEXT NOT NULL DEFAULT 'remote'")
# Daily Art columns added after the tables first shipped.
pool_cols = {row["name"] for row in conn.execute("PRAGMA table_info(art_pool)")}
if pool_cols and "blocked" not in pool_cols:
conn.execute("ALTER TABLE art_pool ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0")
art_cols = {row["name"] for row in conn.execute("PRAGMA table_info(daily_art)")}
if art_cols and "image_url_full" not in art_cols:
conn.execute("ALTER TABLE daily_art ADD COLUMN image_url_full TEXT")
if art_cols and "is_public_domain" not in art_cols:
conn.execute("ALTER TABLE daily_art ADD COLUMN is_public_domain INTEGER")
for column in ("blurb", "palette"): # richer /art page: guide note + extracted colors
if art_cols and column not in art_cols:
conn.execute(f"ALTER TABLE daily_art ADD COLUMN {column} TEXT")
# feedback.read_at (admin inbox read/unread) added later.
fb_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback)")}
if fb_cols and "read_at" not in fb_cols:
conn.execute("ALTER TABLE feedback ADD COLUMN read_at TEXT")
# client_errors.read_at — admin triages load-error beacons (default view = unread).
ce_cols = {row["name"] for row in conn.execute("PRAGMA table_info(client_errors)")}
if ce_cols and "read_at" not in ce_cols:
conn.execute("ALTER TABLE client_errors ADD COLUMN read_at TEXT")
# feedback_replies.message_html (rendered Markdown subset) added later.
rep_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback_replies)")}
if rep_cols and "message_html" not in rep_cols:
@@ -502,3 +671,11 @@ def _migrate(conn: sqlite3.Connection) -> None:
for column in ("what_happened", "why_matters", "why_belongs"):
if sum_cols and column not in sum_cols:
conn.execute(f"ALTER TABLE article_summaries ADD COLUMN {column} TEXT")
# WOTD display polish: LLM plain-language gloss + everyday example sentences, kept
# alongside the raw dictionary def/examples (which stay the anchor / ground truth).
for tbl in ("wotd_pool", "daily_wotd"):
cols = {row["name"] for row in conn.execute(f"PRAGMA table_info({tbl})")}
for column in ("gloss", "usage"):
if cols and column not in cols:
conn.execute(f"ALTER TABLE {tbl} ADD COLUMN {column} TEXT")
+16 -11
View File
@@ -17,6 +17,7 @@ from html import escape
from . import email_send
from .localtime import local_now, local_today
from .paywall import is_paywalled, is_paywalled_for_source
from .queries import paywalled_source_ids
DIGEST_HOUR = int(os.environ.get("GOODNEWS_DIGEST_HOUR", "7"))
DIGEST_WINDOW_HOURS = 4 # send between DIGEST_HOUR and +4h, site-local
@@ -29,8 +30,10 @@ def _base_url() -> str:
def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> list[dict]:
"""The brief's items with the bits a calm email needs (visible sources only)."""
pwx = paywalled_source_ids(conn)
pw_clause = f" AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
rows = conn.execute(
"""
f"""
SELECT a.id, a.title, a.canonical_url, s.name AS source, s.paywall_override, sc.reason_text,
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
FROM daily_briefs b
@@ -38,11 +41,11 @@ def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> l
JOIN articles a ON a.id = bi.article_id
JOIN sources s ON s.id = a.source_id
LEFT JOIN article_scores sc ON sc.article_id = a.id
WHERE b.brief_date = ? AND s.content_visible = 1
WHERE b.brief_date = ? AND s.content_visible = 1{pw_clause}
ORDER BY bi.rank
LIMIT ?
""",
(brief_date, limit),
(brief_date, *pwx, limit),
).fetchall()
items = []
for r in rows:
@@ -72,6 +75,8 @@ def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, l
f"AND at.tag IN ({','.join('?' * len(ftags))}))"
)
params += ftags
pwx = paywalled_source_ids(conn)
pw_clause = f" AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
rows = conn.execute(
f"""
SELECT a.id, a.title, a.canonical_url, s.name AS source, s.paywall_override, a.source_id, sc.reason_text,
@@ -79,12 +84,12 @@ def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, l
FROM articles a
JOIN sources s ON s.id = a.source_id
JOIN article_scores sc ON sc.article_id = a.id
WHERE sc.accepted = 1 AND a.duplicate_of IS NULL AND s.content_visible = 1
WHERE sc.accepted = 1 AND a.duplicate_of IS NULL AND s.content_visible = 1{pw_clause}
AND ({' OR '.join(ors)})
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
LIMIT 30
""",
params,
[*pwx, *params],
).fetchall()
exclude, per_source, out = set(exclude_ids), {}, []
for r in rows:
@@ -127,7 +132,7 @@ def _item_html(it: dict, base: str) -> str:
f'<div style="color:#5d6b78;font-size:13px;margin:3px 0 8px">{escape(it["source"])}</div>'
f'{summary}{why}'
'<div style="margin-top:10px;font-size:14px">'
f'<a href="{base}/a/{it["id"]}" style="color:#0083ad;text-decoration:none">Read on Upbeat Bytes</a>'
f'<a href="{base}/a/{it["id"]}" style="color:#0083ad;text-decoration:none">Read on upbeatBytes</a>'
f' &nbsp;·&nbsp; <a href="{escape(it["canonical_url"])}" style="color:#5d6b78;text-decoration:none">Full story at source{lock}</a>'
'</div></div>'
)
@@ -145,12 +150,12 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str |
followed = followed or []
n = len(items)
weekday = _weekday(brief_date)
subject = f"{weekday}'s Upbeat Bytes · {n} calm read{'' if n == 1 else 's'}"
subject = f"{weekday}'s upbeatBytes · {n} calm read{'' if n == 1 else 's'}"
if weekday == "today":
subject = f"Today's Upbeat Bytes · {n} calm reads"
subject = f"Today's upbeatBytes · {n} calm reads"
text_lines = [
f"Upbeat Bytes — Daily Highlights",
f"upbeatBytes — Daily Highlights",
f"{n} calm read{'' if n == 1 else 's'} for {weekday}.\n",
"Good morning. A small, hopeful handful of what's going right — and there's",
"always more waiting on the site whenever you want it.\n",
@@ -181,7 +186,7 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str |
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
# The real logo as a small hosted PNG (SVG isn't email-safe); alt text
# keeps the brand when a client blocks remote images.
f'<img src="{base}/logo-email.png" alt="Upbeat Bytes" width="180" '
f'<img src="{base}/logo-email.png" alt="upbeatBytes" width="180" '
'style="display:block;border:0;outline:none;text-decoration:none;height:auto;margin:0 0 2px">'
'<div style="font-size:11px;letter-spacing:0.14em;text-transform:uppercase;color:#0083ad;'
f'margin:5px 0 0">Daily Highlights · {escape(weekday)}</div>'
@@ -192,7 +197,7 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str |
+ main_blocks
+ followed_html
+ '<p style="font-size:15px;color:#3f7048;margin:8px 0 0">Thats todays highlights — more good news is '
f'always <a href="{base}" style="color:#3f7048">waiting on Upbeat Bytes</a>. See you tomorrow.</p>'
f'always <a href="{base}" style="color:#3f7048">waiting on upbeatBytes</a>. See you tomorrow.</p>'
f'<p style="font-size:12px;color:#9aa6b2;margin-top:24px">Youre getting this because you turned on '
f'the daily digest. <a href="{unsub_url}" style="color:#9aa6b2">Unsubscribe</a>.</p>'
'</div>'
+10 -10
View File
@@ -24,7 +24,7 @@ def _cfg() -> dict:
"port": int(os.environ.get("GOODNEWS_SMTP_PORT", "587")),
"user": os.environ.get("GOODNEWS_SMTP_USER", ""),
"password": os.environ.get("GOODNEWS_SMTP_PASSWORD", ""),
"sender": os.environ.get("GOODNEWS_SMTP_FROM", "Upbeat Bytes <hello@upbeatbytes.com>"),
"sender": os.environ.get("GOODNEWS_SMTP_FROM", "upbeatBytes <hello@upbeatbytes.com>"),
# Where a reader's reply should land; falls back to the From address.
"reply_to": os.environ.get("GOODNEWS_REPLY_TO_EMAIL", ""),
}
@@ -59,7 +59,7 @@ def send_email(to: str, subject: str, text: str, html: str | None = None, reply_
def send_feedback(to: str, category: str, message: str, contact: str | None, who: str) -> None:
"""Notify the admin of new user feedback (plain text is plenty here)."""
subject = f"Upbeat Bytes feedback · {category}"
subject = f"upbeatBytes feedback · {category}"
reply = contact or "(none given)"
text = (
f"New feedback ({category})\n"
@@ -74,14 +74,14 @@ def send_feedback_reply(to: str, reply_text: str, reply_html: str | None, origin
"""Reply to a reader's feedback from the admin inbox. Sends multipart
text/plain + text/html (the HTML is the pre-sanitized Markdown render). Quotes
their original note for context; exposes no analytics/account details."""
subject = "Re: Your Upbeat Bytes feedback"
subject = "Re: Your upbeatBytes feedback"
quoted = "\n".join("> " + line for line in (original_message or "").splitlines())
text = (
f"{reply_text}\n\n"
"\n"
"In reply to your note to Upbeat Bytes:\n"
"In reply to your note to upbeatBytes:\n"
f"{quoted}\n\n"
"Thanks for reaching out.\nUpbeat Bytes\n"
"Thanks for reaching out.\nupbeatBytes\n"
)
body_html = None
if reply_html:
@@ -90,9 +90,9 @@ def send_feedback_reply(to: str, reply_text: str, reply_html: str | None, origin
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
'color:#16263a;font-size:15px;line-height:1.5">'
f"{reply_html}"
'<p style="color:#5d6b78;font-size:13px;margin-top:20px">In reply to your note to Upbeat Bytes:</p>'
'<p style="color:#5d6b78;font-size:13px;margin-top:20px">In reply to your note to upbeatBytes:</p>'
f'<blockquote style="color:#5d6b78;border-left:3px solid #e8e3d8;margin:0;padding-left:12px">{oq}</blockquote>'
"<p>Thanks for reaching out.<br>— Upbeat Bytes</p></div>"
"<p>Thanks for reaching out.<br>— upbeatBytes</p></div>"
)
# Route the reader's reply to our chosen inbox (never back to the reader).
cfg = _cfg()
@@ -102,9 +102,9 @@ def send_feedback_reply(to: str, reply_text: str, reply_html: str | None, origin
def send_magic_link(to: str, link: str) -> None:
"""Send a calm, single-purpose sign-in email."""
subject = "Your Upbeat Bytes sign-in link"
subject = "Your upbeatBytes sign-in link"
text = (
"Welcome back to Upbeat Bytes.\n\n"
"Welcome back to upbeatBytes.\n\n"
f"Tap to sign in:\n{link}\n\n"
"This link works once and expires in 15 minutes.\n"
"If you didn't request it, you can safely ignore this email."
@@ -113,7 +113,7 @@ def send_magic_link(to: str, link: str) -> None:
html = (
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
'color:#16263a;line-height:1.6">'
"<p>Welcome back to <strong>Upbeat Bytes</strong>.</p>"
"<p>Welcome back to <strong>upbeatBytes</strong>.</p>"
f'<p><a href="{safe}" style="display:inline-block;background:#0083ad;color:#fff;'
'text-decoration:none;padding:11px 20px;border-radius:999px;font-weight:600">'
"Sign in</a></p>"
+80
View File
@@ -174,6 +174,47 @@ def fetch_og_image(url: str | None) -> str | None:
return None # too many redirects
# Word counting reads more of the body than image metadata (which only needs <head>).
_READ_MAX_BYTES = 900_000
def fetch_source_words(url: str | None) -> int | None:
"""Fetch a page and return its full-article word count (furniture stripped), or
None on any failure or a too-thin extraction (JS/video/paywall pages). Same SSRF
safety as fetch_og_image; we read the count only, never store the body."""
from .readtime import source_read_minutes, word_count_from_html
opener = urllib.request.build_opener(_NoRedirect)
for _ in range(MAX_REDIRECTS + 1):
if not url:
return None
parts = urlsplit(url)
if parts.scheme not in ("http", "https") or not _host_is_public(parts.hostname):
return None
request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT, "Accept": "text/html"})
try:
response = opener.open(request, timeout=TIMEOUT)
except (urllib.error.URLError, OSError, ValueError):
return None
status = getattr(response, "status", 200) or 200
if status in (301, 302, 303, 307, 308):
location = response.headers.get("Location")
response.close()
if not location:
return None
url = urljoin(url, location)
continue
if "html" not in response.headers.get("Content-Type", "").lower():
response.close()
return None
try:
body = response.read(_READ_MAX_BYTES)
finally:
response.close()
words = word_count_from_html(body)
return words if source_read_minutes(words) is not None else None
return None # too many redirects
def _image_dimensions(data: bytes) -> "tuple[int, int] | None":
"""Best-effort (width, height) from an image file's header bytes — PNG, GIF,
JPEG, WebP. Returns None for formats we can't cheaply measure (e.g. SVG)."""
@@ -411,3 +452,42 @@ def enrich_summarized_images(
if enrich_article_image(conn, row["id"], fetch=fetch, retry_days=retry_days):
found += 1
return found
def enrich_read_times(
conn: sqlite3.Connection, fetch=fetch_source_words, limit: int = 40, retry_days: int = 14
) -> int:
"""Give recent accepted articles a full-article word count, so the front door can
show "Full story · ~N min" next to our one-minute gist. Bounded per run (mirrors
the image enrichers); fetches each article once, retrying a failed/too-thin
extraction only after `retry_days`. Returns how many real counts were stored."""
rows = conn.execute(
"""
SELECT a.id, a.canonical_url FROM articles a
JOIN article_scores s ON s.article_id = a.id
WHERE s.accepted = 1 AND a.duplicate_of IS NULL
AND a.source_words IS NULL
AND (a.read_checked_at IS NULL OR a.read_checked_at < datetime('now', ?))
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
LIMIT ?
""",
(f"-{retry_days} days", limit),
).fetchall()
found = 0
for row in rows:
try:
words = fetch(row["canonical_url"])
except Exception:
words = None
# Only ever write a REAL count; never overwrite a good value with null/zero.
# Always stamp the check time so failed/thin pages aren't re-fetched until retry.
if words:
conn.execute(
"UPDATE articles SET source_words = ?, read_checked_at = CURRENT_TIMESTAMP WHERE id = ?",
(words, row["id"]),
)
found += 1
else:
conn.execute("UPDATE articles SET read_checked_at = CURRENT_TIMESTAMP WHERE id = ?", (row["id"],))
conn.commit()
return found
+256
View File
@@ -0,0 +1,256 @@
"""Subject-geography for articles ("where is this story ABOUT").
Kept deliberately separate from scoring (see article_geo / article_places in db.py):
geography is durable metadata, scoring is volatile. The LLM proposes place NAMES;
this module disposes by normalizing to ISO codes in code, never trusting the model's
free text (so "Europe" never gets stored as a country). 'global'/placeless is a real,
first-class result, not a failure. "local" is NOT stored it's relative to the reader;
the UI decides "Near you" by comparing these places to the visitor's chosen home.
"""
from __future__ import annotations
import json
import re
import sqlite3
from .llm import LocalModelClient, parse_classifier_json
# Bump when the prompt/taxonomy changes, so a re-backfill can target stale rows.
GEO_VERSION = "geo-v1"
BREADTHS = ("locality", "regional", "national", "multinational", "global", "unknown")
CONFIDENCES = ("high", "medium", "low")
# --- normalization data (LLM returns names; we map to ISO, drop the unmappable) ----
US_STATES = {
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS",
"missouri": "MO", "montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH",
"new jersey": "NJ", "new mexico": "NM", "new york": "NY", "north carolina": "NC",
"north dakota": "ND", "ohio": "OH", "oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA",
"rhode island": "RI", "south carolina": "SC", "south dakota": "SD", "tennessee": "TN",
"texas": "TX", "utah": "UT", "vermont": "VT", "virginia": "VA", "washington": "WA",
"west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
"district of columbia": "DC", "washington dc": "DC", "washington d c": "DC",
}
# US Census Bureau regions — the "Regional" grain for the scope dial. Standard,
# explainable, not arbitrary. DC sits in the South (South Atlantic) per Census.
US_REGIONS = {
"Northeast": {"CT", "ME", "MA", "NH", "RI", "VT", "NJ", "NY", "PA"},
"Midwest": {"IL", "IN", "MI", "OH", "WI", "IA", "KS", "MN", "MO", "NE", "ND", "SD"},
"South": {"DE", "FL", "GA", "MD", "NC", "SC", "VA", "DC", "WV", "AL", "KY", "MS",
"TN", "AR", "LA", "OK", "TX"},
"West": {"AZ", "CO", "ID", "MT", "NV", "NM", "UT", "WY", "AK", "CA", "HI", "OR", "WA"},
}
def region_of(state_code: str | None) -> str | None:
"""The Census region name containing a US state code, or None."""
if not state_code:
return None
sc = state_code.upper()
for name, states in US_REGIONS.items():
if sc in states:
return name
return None
def region_states(state_code: str | None) -> list[str]:
"""Sorted state codes in the same Census region as a state (incl. it)."""
name = region_of(state_code)
return sorted(US_REGIONS[name]) if name else []
# Common countries + aliases (extensible). Anything not here returns None -> we drop
# the country rather than store garbage. breadth still captures national/global, etc.
COUNTRY_TO_ISO = {
"united states": "US", "united states of america": "US", "usa": "US", "us": "US", "america": "US",
"united kingdom": "GB", "uk": "GB", "britain": "GB", "great britain": "GB", "england": "GB",
"scotland": "GB", "wales": "GB", "northern ireland": "GB",
"canada": "CA", "australia": "AU", "new zealand": "NZ", "ireland": "IE",
"france": "FR", "germany": "DE", "spain": "ES", "portugal": "PT", "italy": "IT",
"netherlands": "NL", "belgium": "BE", "luxembourg": "LU", "switzerland": "CH", "austria": "AT",
"denmark": "DK", "sweden": "SE", "norway": "NO", "finland": "FI", "iceland": "IS",
"poland": "PL", "czech republic": "CZ", "czechia": "CZ", "slovakia": "SK", "hungary": "HU",
"greece": "GR", "romania": "RO", "bulgaria": "BG", "croatia": "HR", "serbia": "RS",
"ukraine": "UA", "russia": "RU", "turkey": "TR", "turkiye": "TR",
"china": "CN", "japan": "JP", "south korea": "KR", "korea": "KR", "north korea": "KP",
"india": "IN", "pakistan": "PK", "bangladesh": "BD", "sri lanka": "LK", "nepal": "NP",
"indonesia": "ID", "malaysia": "MY", "singapore": "SG", "thailand": "TH", "vietnam": "VN",
"philippines": "PH", "taiwan": "TW", "hong kong": "HK",
"israel": "IL", "palestine": "PS", "saudi arabia": "SA", "united arab emirates": "AE",
"uae": "AE", "qatar": "QA", "iran": "IR", "iraq": "IQ", "egypt": "EG", "jordan": "JO",
"south africa": "ZA", "nigeria": "NG", "kenya": "KE", "ethiopia": "ET", "ghana": "GH",
"tanzania": "TZ", "uganda": "UG", "rwanda": "RW", "morocco": "MA", "tunisia": "TN",
"mexico": "MX", "brazil": "BR", "argentina": "AR", "chile": "CL", "colombia": "CO",
"peru": "PE", "venezuela": "VE", "ecuador": "EC", "bolivia": "BO", "uruguay": "UY",
"costa rica": "CR", "panama": "PA", "guatemala": "GT", "cuba": "CU", "jamaica": "JM",
}
# Words that look like countries but are regions/continents -> never a country_code.
_NON_COUNTRY = {"europe", "asia", "africa", "north america", "south america", "latin america",
"the americas", "middle east", "scandinavia", "eu", "european union", "world",
"global", "international", "earth", "the world"}
def _norm_key(name) -> str:
s = re.sub(r"[^a-z0-9 ]", " ", str(name or "").lower())
s = re.sub(r"\bthe\b", " ", s)
return re.sub(r"\s+", " ", s).strip()
def normalize_country(name) -> str | None:
key = _norm_key(name)
if not key or key in _NON_COUNTRY:
return None
return COUNTRY_TO_ISO.get(key)
def normalize_state(name, country_code) -> str | None:
if country_code != "US":
return None # only US subdivisions for v1
return US_STATES.get(_norm_key(name))
def normalize_places(raw) -> list[dict]:
"""LLM place dicts -> cleaned, deduped [{country_code, state_code, locality}]."""
out, seen = [], set()
if not isinstance(raw, list):
return out
for p in raw:
if not isinstance(p, dict):
continue
cc = normalize_country(p.get("country"))
sc = normalize_state(p.get("state_province"), cc)
loc = str(p.get("locality") or "").strip() or None
if not (cc or sc or loc):
continue # entirely empty -> drop
key = (cc, sc, (loc or "").lower())
if key in seen:
continue
seen.add(key)
out.append({"country_code": cc, "state_code": sc, "locality": loc})
return out
# --- LLM extraction (separate pass; does not touch the scoring prompt) ------------
SYSTEM = (
"You tag the real-world geography of a news story for a calm good-news site. "
"Identify the place(s) the story is fundamentally ABOUT or where it HAPPENED, "
"NOT places mentioned only in passing. Many good-news stories (general science, "
"space, broad research, health) have no specific place; those are 'global'. If a "
"location is only incidental or genuinely unclear, use 'unknown'. Never guess. "
"Reply with ONLY a JSON object, no prose."
)
INSTRUCT = (
"Return JSON exactly like:\n"
'{"breadth": "<locality|regional|national|multinational|global|unknown>", '
'"places": [{"country": "<name or null>", "state_province": "<name or null>", '
'"locality": "<city/town or null>"}], "confidence": "<high|medium|low>", '
'"rationale": "<one short clause: where it happened and why>"}\n'
"breadth: locality=a specific city/town/county; regional=a state/province/region; "
"national=about a whole country; multinational=a few specific countries; "
"global=worldwide or no specific country; unknown=incidental/unclear. "
"places may list more than one when a story genuinely spans regions; use null for parts you can't support.\n"
"confidence: use 'high' ONLY when the location is explicitly stated or unmistakable; "
"'medium' when reasonably inferred; 'low' when shaky. Do NOT default to high."
)
def _article_text(row) -> str:
parts = [f"TITLE: {row['title']}"]
for label, key in (("SUMMARY", "summary"), ("WHAT HAPPENED", "what_happened"),
("WHY IT MATTERS", "why_matters"), ("PUBLISHER BLURB", "description")):
try:
v = row[key]
except (KeyError, IndexError):
v = None
if v:
parts.append(f"{label}: {v}")
return "\n".join(parts)
def classify_geo(client: LocalModelClient, row) -> dict:
"""One geo pass over an article row -> normalized result. Raises on unparseable."""
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": _article_text(row) + "\n\n" + INSTRUCT},
]
data = parse_classifier_json(client.chat_text(messages))
breadth = data.get("breadth")
if breadth not in BREADTHS:
breadth = "unknown"
confidence = data.get("confidence")
if confidence not in CONFIDENCES:
confidence = "low"
return {
"breadth": breadth,
"confidence": confidence,
"rationale": (str(data.get("rationale") or "")[:300]) or None,
"places": normalize_places(data.get("places")),
}
def store_geo(conn: sqlite3.Connection, article_id: int, result: dict, version: str = GEO_VERSION) -> None:
"""Upsert article_geo and replace article_places. Geo is fully re-derivable, so
replacing places (unlike scores, which we never delete) is safe."""
conn.execute(
"INSERT INTO article_geo (article_id, breadth, confidence, rationale, geo_version, updated_at) "
"VALUES (?,?,?,?,?, datetime('now')) "
"ON CONFLICT(article_id) DO UPDATE SET breadth=excluded.breadth, confidence=excluded.confidence, "
"rationale=excluded.rationale, geo_version=excluded.geo_version, updated_at=excluded.updated_at",
(article_id, result["breadth"], result["confidence"], result.get("rationale"), version),
)
conn.execute("DELETE FROM article_places WHERE article_id=?", (article_id,))
for i, p in enumerate(result.get("places") or []):
conn.execute(
"INSERT INTO article_places (article_id, country_code, state_code, locality, ord) VALUES (?,?,?,?,?)",
(article_id, p.get("country_code"), p.get("state_code"), p.get("locality"), i),
)
def tag_articles(conn: sqlite3.Connection, client: LocalModelClient, limit: int = 200,
reclassify: bool = False) -> dict:
"""Tag accepted, non-duplicate articles that lack current geo. Idempotent: skips
rows already at GEO_VERSION unless reclassify=True. Used both by the cycle (new
articles) and the backfill (existing ones). Per-article failure is non-fatal."""
if reclassify:
where = "1=1"
else:
where = "(g.article_id IS NULL OR g.geo_version IS NOT ?)"
rows = conn.execute(
f"""SELECT a.id, a.title, a.description,
sm.summary, sm.what_happened, sm.why_matters
FROM articles a
JOIN article_scores s ON s.article_id = a.id
LEFT JOIN article_summaries sm ON sm.article_id = a.id
LEFT JOIN article_geo g ON g.article_id = a.id
WHERE s.accepted = 1 AND a.duplicate_of IS NULL AND {where}
ORDER BY a.discovered_at DESC
LIMIT ?""",
(() if reclassify else (GEO_VERSION,)) + (limit,),
).fetchall()
tagged = errors = 0
for r in rows:
try:
store_geo(conn, r["id"], classify_geo(client, r))
# Keep live auth/admin writes healthy while the scheduled cycle runs.
# Geo classification calls the LLM per article; if we batch commits, the
# first stored article opens a write transaction that can stay open while
# the next several LLM calls run. That starves login/session writes long
# enough to trip SQLite's busy timeout. Commit each successful article so
# the writer lock is held for milliseconds, not minutes.
conn.commit()
tagged += 1
except Exception: # noqa: BLE001 — non-fatal, like other cycle steps
conn.rollback()
errors += 1
conn.commit()
return {"candidates": len(rows), "tagged": tagged, "errors": errors}
+1 -1
View File
@@ -78,7 +78,7 @@ _RESPONSE_FORMATS = (
)
SYSTEM_PROMPT = """You classify article metadata for Upbeat Bytes, a calm news digest.
SYSTEM_PROMPT = """You classify article metadata for upbeatBytes, a calm news digest.
The bar is NOT "is this happy?" it is "will a reader finish this calm or a little better, never worse?" ACCEPT stories that are calm, neutral, insightful, or uplifting: they inform, teach, delight, or show progress or benefit. Neutral-but-absorbing is welcome a discovery, a clear explainer, a clever build or gadget, a fascinating bit of science, space, nature, design, or culture, a genuinely useful insight even when it isn't "feel-good."
+304
View File
@@ -0,0 +1,304 @@
"""Local image cache + downscale for news article images.
Article images used to be hotlinked from the source, so a slow/flaky third-party CDN
left a blank graphic until a refresh. Instead the CYCLE fetches a downscaled WebP copy
to data/img_cache/ (beside the DB, mounted into the API container, mirrors art_cache),
and the API serves only cache HITS it never fetches, so the public endpoint has no
SSRF or worker-exhaustion surface. The cache is bounded by a hard size ceiling with LRU
eviction, so it can't grow without limit no matter the ingest rate.
Security posture (the fetch runs only in the trusted cycle, but feed image URLs are
still externally supplied, so we treat them as untrusted):
* SSRF-safe fetch reuses enrich._host_is_public + bounded redirect re-validation
(same path as feeds.safe_fetch_feed) no private/loopback/link-local targets,
http(s) only, every redirect hop re-checked.
* Only successfully-decoded RASTER images are re-encoded to WebP and stored; SVG and
anything undecodable is REJECTED (never retained as a same-origin file).
* Decompression-bomb + dimension guards.
* Definitive failures are negative-cached (a .fail marker) so a bad URL isn't refetched
every cycle; transient network errors are not, so they retry.
Concurrency: all fetching happens inside the cycle, which holds an exclusive lock, so no
two fetches race; writes are atomic (temp + rename) regardless.
"""
from __future__ import annotations
import hashlib
import io
import os
import time
import urllib.error
import urllib.request
from pathlib import Path
from urllib.parse import urljoin, urlsplit
from .enrich import MAX_REDIRECTS, _NoRedirect, _host_is_public
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
_MIN_IMAGE_BYTES = 500
_MAX_FETCH_BYTES = 20 * 1024 * 1024 # never pull an absurd original into memory
_MAX_PIXELS = 50_000_000 # decompression-bomb ceiling (≈50 MP)
_MAX_DIM = 12000 # reject pathological single-axis dimensions
DISPLAY_WIDTH = 800 # cards / feed never show wider than this
WEBP_QUALITY = 80
DEFAULT_CAP_BYTES = 1024 * 1024 * 1024 # 1 GB hard ceiling (override via env)
_FAIL_TTL_S = 3 * 24 * 3600 # don't refetch a definitively-bad URL for 3 days
def cache_dir() -> Path:
override = os.environ.get("GOODNEWS_IMG_CACHE")
db = Path(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
d = Path(override) if override else db.parent / "img_cache"
d.mkdir(parents=True, exist_ok=True)
return d
def cap_bytes() -> int:
try:
return int(os.environ.get("GOODNEWS_IMG_CACHE_CAP", DEFAULT_CAP_BYTES))
except ValueError:
return DEFAULT_CAP_BYTES
def _key(url: str) -> str:
return hashlib.sha1(url.encode("utf-8")).hexdigest()
def display_url(article_id: int, image_policy: str | None, raw_url: str | None) -> str | None:
"""The image URL the frontend should use, honoring the SOURCE's image policy:
'cache' our locally-cached copy (/api/img/<id>) only for sources we've cleared
to re-host (open license / explicit permission / public-domain).
'remote' the publisher's own URL (hotlinked + the frontend's graceful retry). The
conservative DEFAULT: we display but never re-host.
'none' no image (typographic cover).
Returns None when there's no image or the policy is 'none'."""
if not raw_url:
return None
if image_policy == "cache":
return f"/api/img/{article_id}"
if image_policy == "none":
return None
return raw_url # 'remote' (default) — hotlink, never re-hosted
class _FetchError(Exception):
"""permanent=True → negative-cache (won't retry soon); False → transient, retry."""
def __init__(self, msg: str, permanent: bool):
super().__init__(msg)
self.permanent = permanent
def _safe_fetch(url: str, timeout: int = 12) -> tuple[bytes, str]:
"""SSRF-safe fetch of an untrusted image URL: http(s) only, every redirect hop
re-validated against public IPs, bounded redirects, body capped. Raises _FetchError
(permanent for policy refusals, transient for network errors)."""
opener = urllib.request.build_opener(_NoRedirect)
current = url
for _ in range(MAX_REDIRECTS + 1):
parts = urlsplit(current)
if parts.scheme not in ("http", "https") or not _host_is_public(parts.hostname):
raise _FetchError(f"non-public or non-http(s): {current}", permanent=True)
req = urllib.request.Request(current, headers=_UA)
try:
resp = opener.open(req, timeout=timeout)
except urllib.error.HTTPError as exc:
# _NoRedirect makes urllib RAISE on 3xx (rather than return a response), so
# redirects arrive here. Re-validate the destination on the next loop. 4xx
# (except 429) is a permanent miss → negative-cache; 429/5xx → transient.
if exc.code in (301, 302, 303, 307, 308):
loc = exc.headers.get("Location")
exc.close()
if not loc:
raise _FetchError("redirect without location", permanent=True) from exc
current = urljoin(current, loc)
continue
permanent = 400 <= exc.code < 500 and exc.code != 429
raise _FetchError(f"http {exc.code}", permanent=permanent) from exc
except (urllib.error.URLError, OSError, ValueError) as exc:
raise _FetchError(f"fetch failed: {exc}", permanent=False) from exc
try:
return resp.read(_MAX_FETCH_BYTES + 1), (resp.headers.get("Content-Type") or "")
finally:
resp.close()
raise _FetchError("too many redirects", permanent=True)
def _encode(data: bytes) -> bytes | None:
"""Downscale a decoded RASTER image to DISPLAY_WIDTH and re-encode as WebP. None if
it isn't a decodable raster (e.g. SVG), is a decompression bomb, or has pathological
dimensions the caller then REJECTS it (never stores arbitrary bytes)."""
try:
from PIL import Image
Image.MAX_IMAGE_PIXELS = _MAX_PIXELS # backstop; Pillow only WARNS at this, raises ~2x
im = Image.open(io.BytesIO(data)) # lazy: header (size) read without decoding pixels
# Enforce the pixel/dimension ceiling BEFORE load() so a decompression bomb is never
# actually decoded (Pillow's own MAX_IMAGE_PIXELS only warns at the threshold).
if (im.width * im.height > _MAX_PIXELS or im.width > _MAX_DIM or im.height > _MAX_DIM
or im.width < 1 or im.height < 1):
return None
im.load() # decode now (also catches truncated data)
if im.mode not in ("RGB", "RGBA"):
im = im.convert("RGBA" if ("A" in im.mode or im.mode == "P") else "RGB")
if im.width > DISPLAY_WIDTH:
h = max(1, round(im.height * DISPLAY_WIDTH / im.width))
im = im.resize((DISPLAY_WIDTH, h), Image.LANCZOS)
out = io.BytesIO()
im.save(out, format="WEBP", quality=WEBP_QUALITY, method=4)
return out.getvalue()
except Exception: # noqa: BLE001 — UnidentifiedImageError, DecompressionBombError, SVG, truncated …
return None
def _fail_path(url: str) -> Path:
return cache_dir() / f"{_key(url)}.fail"
def _mark_failed(url: str) -> None:
try:
_fail_path(url).touch()
except OSError:
pass
def _failed_recently(url: str) -> bool:
try:
return (time.time() - _fail_path(url).stat().st_mtime) < _FAIL_TTL_S
except OSError:
return False
def path_for(url: str) -> Path | None:
"""The cached WebP for this URL if present (and bump its mtime, the LRU marker).
A pure cache lookup never fetches."""
if not url:
return None
p = cache_dir() / f"{_key(url)}.webp"
if p.exists():
try:
os.utime(p, None) # touch → last-used time for LRU eviction
except OSError:
pass
return p
return None
def fetch_and_cache(url: str | None) -> Path | None:
"""Fetch (SSRF-safe), downscale to WebP, and cache atomically. CYCLE-ONLY — the API
endpoint never calls this. None on any failure; definitive failures are negative-cached
so they aren't retried every cycle."""
if not url or not url.startswith(("http://", "https://")):
return None
try:
data, _ctype = _safe_fetch(url)
except _FetchError as exc:
if exc.permanent:
_mark_failed(url)
return None
if not (_MIN_IMAGE_BYTES <= len(data) <= _MAX_FETCH_BYTES):
_mark_failed(url)
return None
blob = _encode(data)
if blob is None: # SVG / undecodable / bomb / bad dimensions
_mark_failed(url)
return None
cdir = cache_dir()
key = _key(url)
tmp = cdir / f".{key}.tmp"
dest = cdir / f"{key}.webp"
try:
tmp.write_bytes(blob)
os.replace(tmp, dest) # atomic
except OSError:
try:
tmp.unlink()
except OSError:
pass
return None
return dest
def purge_source(conn, source_id: int) -> int:
"""Delete every cached file for a source's article image URLs. Called when a source
leaves 'cache' policy (revoked permission / re-classified), so the re-hosted copies
come down immediately rather than lingering inaccessible on disk. Returns webp count."""
rows = conn.execute(
"SELECT DISTINCT image_url FROM articles WHERE source_id = ? "
"AND image_url IS NOT NULL AND image_url != ''",
(source_id,),
).fetchall()
cdir = cache_dir()
removed = 0
for r in rows:
key = _key(r[0])
for suffix in (".webp", ".fail"):
p = cdir / f"{key}{suffix}"
try:
if p.exists():
p.unlink()
if suffix == ".webp":
removed += 1
except OSError:
pass
return removed
def warm(conn, limit: int = 200) -> int:
"""Pre-fetch display copies for the newest ACCEPTED, CANONICAL articles whose SOURCE
is cleared to cache (image_policy='cache'), so the API only ever serves cache hits.
Bounded; skips already-cached and recently-failed URLs. Returns how many it newly
cached. Sources default to 'remote' (hotlink, never re-hosted), so this caches
nothing until a source is explicitly set to 'cache'."""
rows = conn.execute(
"SELECT DISTINCT a.image_url FROM article_scores s JOIN articles a ON a.id = s.article_id "
"JOIN sources src ON src.id = a.source_id "
"WHERE s.accepted=1 AND a.duplicate_of IS NULL AND src.image_policy='cache' "
"AND a.image_url IS NOT NULL AND a.image_url != '' ORDER BY a.id DESC LIMIT ?",
(limit,),
).fetchall()
made = 0
for r in rows:
url = r[0]
if path_for(url) or _failed_recently(url):
continue
if fetch_and_cache(url):
made += 1
return made
def prune(cap: int | None = None) -> dict:
"""Enforce the size ceiling: delete least-recently-used WebPs (oldest mtime first)
until under the cap; also sweep stale .fail markers. Returns {before, after, removed, cap}."""
if cap is None:
cap = cap_bytes()
now = time.time()
files, total = [], 0
for p in cache_dir().iterdir():
if p.name.startswith("."):
continue
if p.suffix == ".fail":
try:
if now - p.stat().st_mtime >= _FAIL_TTL_S:
p.unlink()
except OSError:
pass
continue
if p.suffix != ".webp" or not p.is_file():
continue
try:
st = p.stat()
except OSError:
continue
files.append((st.st_mtime, st.st_size, p))
total += st.st_size
before, removed = total, 0
if total > cap:
files.sort() # oldest mtime first = least recently used
for _mtime, size, p in files:
if total <= cap:
break
try:
p.unlink()
total -= size
removed += 1
except OSError:
pass
return {"before": before, "after": total, "removed": removed, "cap": cap}
+202
View File
@@ -0,0 +1,202 @@
"""On This Day — a good thing that happened on today's date in history.
Source: Wikimedia's "On this day" feed (free, CC) — comprehensive, and it carries a
summary extract + thumbnail per event, which makes for a rich page. Multi-source ready
(a `source` column), so admin-curated entries and any future source slot in cleanly.
Pipeline (mirrors Daily Art): harvest today's MM-DD events → tone-filter to good/neutral
(keyword floor + optional LLM refine) pool deterministic daily pick cached row.
All network/LLM work happens before any DB write, so the write txn is brief.
"""
from __future__ import annotations
import json
import re
import sqlite3
from . import daily
from .localtime import local_today
WIKI_BASE = "https://en.wikipedia.org/api/rest_v1/feed/onthisday/events"
_NO_REPEAT_POOL = 40 # pick from the N least-recently-shown for today's date
# Keyword floor: drop the obviously grim before the LLM ever sees it (and a safety net
# for when the LLM is unavailable). Substring match on a lowercased event text.
_NEG = (
"war", "kill", "died", "dies", "death", "deaths", "dead", "massacre", "genocide",
"disaster", "earthquake", "hurricane", "tsunami", "flood", "famine", "plague",
"bomb", "attack", "assassinat", "murder", "shooting", "shot dead", "riot", "crash",
"invad", "slaughter", "execut", "tragedy", "terror", "nuclear", "explosion",
"sank", "sink", "wreck", "epidemic", "pandemic", "outbreak", "hostage", "coup",
)
# Wikimedia's feed hands us a 330px `thumbnail`, which upscales (blurry) in our hero. It also
# gives `originalimage` — a sharp, full-size URL that's always valid. We can't just request a
# bigger thumbnail width: for very large source images Wikimedia only serves pre-generated
# bucket sizes and 400s on arbitrary widths (e.g. 500px ok, 800/1024px fail, 1280px ok). So
# prefer the originalimage (reliably sharp), falling back to the thumbnail.
def _best_image(page: dict) -> str | None:
"""The sharpest reliably-served image URL: originalimage, else the 330px thumbnail."""
orig = (page.get("originalimage") or {}).get("source")
thumb = (page.get("thumbnail") or {}).get("source")
return orig or thumb or None
def _fetch_events(md: str) -> list[dict]:
"""All events for a MM-DD from Wikimedia, normalized to our candidate shape."""
mm, dd = md.split("-")
data = daily.http_json(f"{WIKI_BASE}/{mm}/{dd}")
out = []
for e in (data.get("events") or []):
text = (e.get("text") or "").strip()
if not text:
continue
page = (e.get("pages") or [{}])[0] or {}
out.append({
"md": md,
"year": e.get("year"),
"text": text,
"summary": (page.get("extract") or "").strip() or None,
"image_url": _best_image(page),
"page_url": (((page.get("content_urls") or {}).get("desktop") or {}).get("page")) or None,
})
return out
def _keyword_ok(text: str) -> bool:
t = text.lower()
return not any(neg in t for neg in _NEG)
def _llm_keep(client, candidates: list[dict]) -> list[dict]:
"""Ask the LLM which candidates are genuinely positive/neutral. On any trouble,
keep the keyword-passed set (never lose the day to a model hiccup)."""
lines = [f"{i}: {c['text']}" for i, c in enumerate(candidates)]
user = (
"These are 'on this day' history events. Return the indices of the ones that are "
"GENUINELY UPLIFTING — a reader should feel a small lift of wonder, hope, or delight. "
"Keep: discoveries, inventions, scientific breakthroughs, the arts and culture, "
"exploration, human achievement, acts of courage or kindness, milestones of progress "
"(rights won, things built, records set). EXCLUDE war, violence, disasters, death, or "
"tragedy, AND exclude merely procedural or political-administrative events that carry no "
"warmth (a coronation or accession, a treaty signing, an election, a law passed, a "
"boundary or office change). When unsure whether something is truly uplifting, leave it "
"out.\n\n" + "\n".join(lines) +
'\n\nReply with JSON only, exactly: {"keep": [<indices>]}'
)
txt = client.chat_text([{"role": "user", "content": user}])
m = re.search(r"\{.*\}", txt, re.S)
if not m:
return candidates
keep = json.loads(m.group(0)).get("keep", [])
idx = {int(i) for i in keep if str(i).lstrip("-").isdigit()}
sub = [c for i, c in enumerate(candidates) if i in idx]
return sub or candidates
def _tone_filter(candidates: list[dict], client=None) -> list[dict]:
kept = [c for c in candidates if _keyword_ok(c["text"])]
if client and kept:
try:
kept = _llm_keep(client, kept)
except Exception: # noqa: BLE001 — LLM is best-effort; keyword floor stands
pass
return kept
def _pool_count(conn: sqlite3.Connection, md: str) -> int:
return conn.execute(
"SELECT COUNT(*) FROM onthisday_pool WHERE md=? AND blocked=0", (md,)
).fetchone()[0]
def harvest(conn: sqlite3.Connection, md: str | None = None, client=None) -> dict:
"""Fetch + tone-filter today's MM-DD events into the pool. Per-day, idempotent
(dedup by content key). Non-fatal on network failure."""
md = md or local_today()[5:]
try:
events = _fetch_events(md)
except Exception: # noqa: BLE001
return {"md": md, "fetched": 0, "kept": 0, "added": 0, "pool": _pool_count(conn, md)}
kept = _tone_filter(events, client) # all network/LLM done before the write
before = _pool_count(conn, md)
conn.executemany(
"INSERT OR IGNORE INTO onthisday_pool (source, md, year, ckey, text, summary, image_url, page_url) "
"VALUES ('wikimedia', ?, ?, ?, ?, ?, ?, ?)",
[(c["md"], c["year"], daily.content_key(c["md"], c["year"], c["text"]),
c["text"], c["summary"], c["image_url"], c["page_url"]) for c in kept],
)
conn.commit()
after = _pool_count(conn, md)
return {"md": md, "fetched": len(events), "kept": len(kept), "added": after - before, "pool": after}
def _candidates(conn: sqlite3.Connection, md: str, avoid: int | None = None) -> list[int]:
"""The pick pool for a date: if admin has featured any, pick only among those;
otherwise the N least-recently-shown. `avoid` drops a specific id (admin re-pick)
unless it's the only option."""
featured = conn.execute(
"SELECT id FROM onthisday_pool WHERE md=? AND blocked=0 AND featured=1 ORDER BY id", (md,)
).fetchall()
if featured:
ids = [r[0] for r in featured]
else:
rows = conn.execute(
"SELECT id FROM onthisday_pool WHERE md=? AND blocked=0 "
"ORDER BY shown_at IS NOT NULL, shown_at, id LIMIT ?",
(md, _NO_REPEAT_POOL),
).fetchall()
ids = [r[0] for r in rows]
if avoid is not None:
ids = [i for i in ids if i != avoid] or ids
return ids
def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, force: bool = False,
avoid: int | None = None) -> dict | None:
"""Pick + cache today's fact. Idempotent (skips if today's done unless force).
Returns the stored row, or None if the pool has nothing for today's date."""
feature_date = feature_date or local_today()
md = feature_date[5:]
existing = conn.execute("SELECT * FROM daily_onthisday WHERE feature_date=?", (feature_date,)).fetchone()
if existing and not force:
return dict(existing)
ids = _candidates(conn, md, avoid)
if not ids:
return None
pick_id = daily.seeded_order(ids, feature_date)[0]
row = conn.execute("SELECT * FROM onthisday_pool WHERE id=?", (pick_id,)).fetchone()
conn.execute(
"INSERT INTO daily_onthisday (feature_date, pool_id, source, md, year, text, summary, image_url, page_url) "
"VALUES (?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(feature_date) DO UPDATE SET pool_id=excluded.pool_id, source=excluded.source, "
"year=excluded.year, text=excluded.text, summary=excluded.summary, image_url=excluded.image_url, "
"page_url=excluded.page_url",
(feature_date, row["id"], row["source"], row["md"], row["year"], row["text"],
row["summary"], row["image_url"], row["page_url"]),
)
conn.execute("UPDATE onthisday_pool SET shown_at=? WHERE id=?", (feature_date, pick_id))
conn.commit()
return dict(conn.execute("SELECT * FROM daily_onthisday WHERE feature_date=?", (feature_date,)).fetchone())
def get_today(conn: sqlite3.Connection, feature_date: str | None = None) -> dict | None:
"""Today's fact if present, else the most recent (the room is never empty)."""
if feature_date:
row = conn.execute("SELECT * FROM daily_onthisday WHERE feature_date=?", (feature_date,)).fetchone()
if row:
return dict(row)
row = conn.execute("SELECT * FROM daily_onthisday ORDER BY feature_date DESC LIMIT 1").fetchone()
return dict(row) if row else None
def run_daily(conn: sqlite3.Connection, client=None) -> dict:
"""Cycle entry point: ensure today's date has a pool, then ensure it has a pick.
Bounded + non-fatal safe to call every cycle (no-ops once the day is picked)."""
md = local_today()[5:]
harvested = None
if _pool_count(conn, md) == 0:
harvested = harvest(conn, md, client)
picked = pick_daily(conn)
return {"md": md, "harvested": harvested, "picked": picked["text"] if picked else None}
+189 -3
View File
@@ -20,6 +20,19 @@ from .paywall import is_paywalled, is_paywalled_for_source
BOT_UA_MARKS = ("headlesschrome", "bot", "spider", "crawl", "python", "curl", "wget", "phantomjs")
_NOT_BOT_SQL = " AND ".join(f"instr(lower(user_agent), '{m}')=0" for m in BOT_UA_MARKS)
# "Engaged reader" = a distinct visitor-day with DELIBERATE activity, as opposed to a raw
# visit (which a JS-capable bot can trip). Counts the gesture-gated 'engaged' beacon OR a
# genuine deliberate action. Deliberately EXCLUDES auto-fired/passive kinds (visit,
# summary_viewed, open), replace_none, and game *_arrival (a share-loop landing, not engagement).
_ENGAGED_GAMES = ("word", "wordsearch", "bloom", "match")
ENGAGED_EVENT_KINDS = (
"engaged", "full_story", "source_click",
"share_ub", "copy_source", "native_share",
"replace_used", "paywall_replace", "paywalled_source_open",
"not_today", "less_like_this", "hide_topic",
*(f"{g}_{e}" for g in _ENGAGED_GAMES for e in ("started", "completed", "shared")),
)
def is_bot_ua(ua: str | None) -> bool:
low = (ua or "").lower()
@@ -55,11 +68,26 @@ _ARTICLE_COLUMNS = f"""
s.reason_text,
s.model_name,
src.paywall_override AS paywall_override,
src.image_policy AS image_policy,
a.source_words,
(SELECT group_concat(t.tag) FROM article_tags t WHERE t.article_id = a.id) AS tags,
{RANK_SCORE_SQL} AS rank_score
"""
def paywalled_source_ids(conn: sqlite3.Connection) -> list[int]:
"""Source ids whose stories are paywalled — the domain rule (PAYWALL_DOMAINS),
overridable per source in admin. Computed live so an admin flag takes effect at
once. Small set (a handful of sources), so the lookup is cheap."""
rows = conn.execute(
"SELECT id, homepage_url, feed_url, paywall_override FROM sources"
).fetchall()
return [
r["id"] for r in rows
if is_paywalled_for_source((r["homepage_url"] or r["feed_url"]), r["paywall_override"])
]
def feed(
conn: sqlite3.Connection,
topic: str | None = None,
@@ -80,6 +108,10 @@ def feed(
follow_tags: list[str] | None = None,
since: str | None = None,
match: str | None = None,
home_country: str | None = None,
home_state: str | None = None,
geo_scope: str | None = None, # 'near' | 'country' | 'world' relative to the reader's home
include_paywalled: bool = False, # default: hide paywalled-source stories (no unreadable news in the feed)
) -> list[dict]:
"""Return articles with categorical filters applied in SQL.
@@ -102,6 +134,13 @@ def feed(
fts_join = "JOIN article_search ON article_search.article_id = a.id"
clauses.append("article_search MATCH ?")
params.append(match)
# Hard-exclude paywalled sources (admin-overridable). Added after MATCH so the FTS
# bound param keeps leading; the NOT IN list (a handful of ids) follows.
if not include_paywalled:
pwx = paywalled_source_ids(conn)
if pwx:
clauses.append("a.source_id NOT IN (%s)" % ",".join("?" * len(pwx)))
params.extend(pwx)
if accepted_only:
clauses.append("s.accepted = 1")
if topic:
@@ -162,6 +201,49 @@ def feed(
params.extend(ftags)
clauses.append("(" + " OR ".join(ors) + ")" if ors else "0")
# Home-aware scoping for "Closer to Home" (server-side). Relative to the reader's
# chosen home; geo_scope=None leaves the feed exactly as it is today. A STATE match
# only counts when geo confidence is high/medium (don't surface "Near you" on a
# shaky location). Untagged articles have no places, so they land in 'world' — never
# lost while the backfill is still running.
if geo_scope == "near":
# Anything elevated as "Near you" / "Close to home" requires high/medium geo
# confidence — the feature's promise is relevance, so don't surface shaky locals.
if home_state and home_country:
clauses.append(
"g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
"WHERE p.article_id = a.id AND p.country_code = ? AND p.state_code = ?)")
params.extend([home_country, home_state])
elif home_country:
clauses.append(
"g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
"WHERE p.article_id = a.id AND p.country_code = ?)")
params.append(home_country)
elif geo_scope == "country" and home_country:
clauses.append("EXISTS (SELECT 1 FROM article_places p WHERE p.article_id = a.id AND p.country_code = ?)")
params.append(home_country)
if home_state:
# "elsewhere in your country" excludes ONLY what actually went to "near" (a
# high/medium-confidence home-state match). A low-confidence home-state story
# isn't near, so it must still surface here, not vanish between tiers.
clauses.append(
"NOT (g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p2 "
"WHERE p2.article_id = a.id AND p2.country_code = ? AND p2.state_code = ?))")
params.extend([home_country, home_state])
elif geo_scope == "world" and home_country:
if home_state:
# State mode: the "country" tier catches all home-country stories (incl.
# low-confidence ones), so world is simply everything outside your country.
clauses.append("NOT EXISTS (SELECT 1 FROM article_places p WHERE p.article_id = a.id AND p.country_code = ?)")
params.append(home_country)
else:
# Country-only mode has no "country" tier, so a LOW-confidence home-country
# story isn't "near" and must land here rather than vanish between tiers.
clauses.append(
"NOT (g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
"WHERE p.article_id = a.id AND p.country_code = ?))")
params.append(home_country)
where = "WHERE " + " AND ".join(clauses)
params.extend([limit, offset])
@@ -173,10 +255,15 @@ def feed(
order_by = "rank_score DESC, COALESCE(a.published_at, a.discovered_at) DESC"
rows = conn.execute(
f"""
SELECT {_ARTICLE_COLUMNS}
SELECT {_ARTICLE_COLUMNS},
g.breadth AS geo_breadth, g.confidence AS geo_confidence,
(SELECT group_concat(
p.country_code || CASE WHEN p.state_code IS NOT NULL THEN '-' || p.state_code ELSE '' END, ',')
FROM article_places p WHERE p.article_id = a.id) AS geo_places
FROM articles a
JOIN sources src ON src.id = a.source_id
JOIN article_scores s ON s.article_id = a.id
LEFT JOIN article_geo g ON g.article_id = a.id
{fts_join}
{where}
ORDER BY {order_by}
@@ -208,6 +295,86 @@ def reindex_search(conn: sqlite3.Connection) -> int:
return conn.execute("SELECT COUNT(*) FROM article_search").fetchone()[0]
# Scope dial: the reader's "emotional radius". Each tier is a closest->widest lead
# preference, not a hard filter; 'world' is the implicit final tier. State + region
# are confidence-gated (high/medium) so a shaky location is never promoted as local.
_STATE_SQL = ("(g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
"WHERE p.article_id = a.id AND p.country_code = ? AND p.state_code = ?))")
_COUNTRY_SQL = "(EXISTS (SELECT 1 FROM article_places p WHERE p.article_id = a.id AND p.country_code = ?))"
SCOPES = ("nearby", "region", "country", "world")
def _region_sql(n: int) -> str:
placeholders = ",".join("?" * n)
return ("(g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
f"WHERE p.article_id = a.id AND p.country_code = ? AND p.state_code IN ({placeholders})))")
def home_tiers(home_country: str, home_state: str | None, scope: str) -> list[tuple]:
"""Ordered [(section_key, predicate_sql, params)] closest->widest for a home + scope.
Evaluated first-match (CASE WHEN / composed in order), so tiers needn't be SQL-exclusive.
'world' is implicit (everything not matched). 'region'/'nearby' need a US state; otherwise
they gracefully fall back to country (country-only / non-US homes collapse to Country/World).
"""
from .geo import region_states
rs = region_states(home_state) if home_state else []
tiers: list[tuple] = []
if scope == "nearby" and home_state:
tiers.append(("state", _STATE_SQL, [home_country, home_state]))
if rs:
tiers.append(("region", _region_sql(len(rs)), [home_country, *rs]))
tiers.append(("country", _COUNTRY_SQL, [home_country]))
elif scope == "region" and home_state and rs:
tiers.append(("region", _region_sql(len(rs)), [home_country, *rs])) # includes the state
tiers.append(("country", _COUNTRY_SQL, [home_country]))
else: # country scope, country-only / non-US home, or any fallback
tiers.append(("country", _COUNTRY_SQL, [home_country]))
return tiers
def home_brief(conn: sqlite3.Connection, home_country: str, home_state: str | None = None,
scope: str = "nearby", limit: int = 7, window_days: int = 3) -> list[dict]:
"""Scope-aware local-first landing highlights. Leads with the reader's chosen radius
(state / region / country) then blends outward so the set is always full "closest
first", never three stale local stories. Prefers already-summarized stories so the
calm read stays rich. Brief-shaped rows tagged with a concrete section key.
"""
tiers = home_tiers(home_country, home_state, scope)
whens, params = [], []
for i, (_key, pred, ps) in enumerate(tiers):
whens.append(f"WHEN {pred} THEN {i}")
params += ps
world_rank = len(tiers)
section_case = ("CASE " + " ".join(whens) + f" ELSE {world_rank} END") if whens else "0"
section_keys = [k for k, _, _ in tiers] + ["world"]
rows = conn.execute(
f"""
SELECT {_ARTICLE_COLUMNS},
sm.summary AS summary,
{section_case} AS section_rank,
(sm.summary IS NOT NULL) AS has_summary
FROM articles a
JOIN sources src ON src.id = a.source_id
JOIN article_scores s ON s.article_id = a.id
LEFT JOIN article_geo g ON g.article_id = a.id
LEFT JOIN article_summaries sm ON sm.article_id = a.id
WHERE a.duplicate_of IS NULL AND src.content_visible = 1 AND s.accepted = 1
AND a.discovered_at >= datetime('now', ?)
ORDER BY section_rank ASC, has_summary DESC, rank_score DESC,
COALESCE(a.published_at, a.discovered_at) DESC
LIMIT ?
""",
params + [f"-{window_days} days", limit],
).fetchall()
out = []
for r in rows:
d = dict(r)
rank = d.pop("section_rank", world_rank)
d["__section"] = section_keys[rank] if 0 <= rank < len(section_keys) else "world"
out.append(d)
return out
def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int = 10) -> dict:
"""Return a stored daily brief (latest if no date) with its ranked items."""
target_date = brief_date or _latest_brief_date(conn)
@@ -221,6 +388,8 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int =
if not header:
return {"brief_date": target_date, "title": None, "created_at": None, "items": []}
pwx = paywalled_source_ids(conn)
pw_clause = f" AND a.source_id NOT IN ({','.join('?' * len(pwx))})" if pwx else ""
rows = conn.execute(
f"""
SELECT bi.rank, bi.selection_reason, {_ARTICLE_COLUMNS},
@@ -230,11 +399,11 @@ def brief(conn: sqlite3.Connection, brief_date: str | None = None, limit: int =
JOIN articles a ON a.id = bi.article_id
JOIN sources src ON src.id = a.source_id
LEFT JOIN article_scores s ON s.article_id = a.id
WHERE b.brief_date = ? AND src.content_visible = 1
WHERE b.brief_date = ? AND src.content_visible = 1{pw_clause}
ORDER BY bi.rank
LIMIT ?
""",
(target_date, limit),
(target_date, *pwx, limit),
).fetchall()
return {
"brief_date": header["brief_date"],
@@ -370,6 +539,7 @@ def source_health(conn: sqlite3.Connection) -> list[dict]:
s.id, s.name, s.feed_url, s.homepage_url, s.default_category AS category, s.active,
s.status, s.content_visible, s.retry_after_at,
s.consecutive_failures AS failures, s.review_flag, s.review_reason, s.paywall_override,
s.image_policy,
s.poll_interval_minutes AS interval_minutes,
s.last_success_at, s.last_error_at, substr(s.last_error, 1, 160) AS last_error,
(SELECT MAX(r.finished_at) FROM ingest_runs r
@@ -589,13 +759,25 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
def scalar(sql, params=()):
return conn.execute(sql, params).fetchone()[0] or 0
eng_ph = ",".join("?" * len(ENGAGED_EVENT_KINDS))
visitors = {
# Recorded visits — the raw/noisy count (one daily 'visit' beacon per device).
"today": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
"WHERE kind='visit' AND visitor_hash!='' AND day=date('now')"),
"d7": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
"WHERE kind='visit' AND visitor_hash!='' AND day>=date('now','-7 days')"),
"d30": scalar("SELECT COUNT(DISTINCT visitor_hash) FROM events "
"WHERE kind='visit' AND visitor_hash!='' AND day>=date('now',?)", (since,)),
# Engaged readers — distinct visitor-day with deliberate activity (the honest number).
"engaged_today": scalar(f"SELECT COUNT(DISTINCT visitor_hash) FROM events "
f"WHERE kind IN ({eng_ph}) AND visitor_hash!='' AND day=date('now')",
ENGAGED_EVENT_KINDS),
"engaged_d7": scalar(f"SELECT COUNT(DISTINCT visitor_hash) FROM events "
f"WHERE kind IN ({eng_ph}) AND visitor_hash!='' AND day>=date('now','-7 days')",
ENGAGED_EVENT_KINDS),
"engaged_d30": scalar(f"SELECT COUNT(DISTINCT visitor_hash) FROM events "
f"WHERE kind IN ({eng_ph}) AND visitor_hash!='' AND day>=date('now',?)",
(*ENGAGED_EVENT_KINDS, since)),
}
# Returning (seen on ≥2 distinct days) vs one-and-done, over the window.
@@ -731,6 +913,10 @@ def admin_stats(conn: sqlite3.Connection, days: int = 30) -> dict:
f"SELECT COUNT(*) FROM client_errors WHERE created_at>=date('now',?) AND {_NOT_BOT_SQL}",
(since,),
),
# Drives the headline now: how many still need a look (clears as you mark them read).
"unread": scalar(
f"SELECT COUNT(*) FROM client_errors WHERE read_at IS NULL AND {_NOT_BOT_SQL}",
),
},
}
+120
View File
@@ -0,0 +1,120 @@
"""Quote of the Day — a hopeful, public-domain quote a day.
Curated, never LLM-invented (misattribution is the one thing that would burn trust). A
vetted starter set seeds the pool; admin grows it. The "what it means" explainer IS
LLM-generated, but it only *interprets a real, known quote* low risk and is filled
lazily the first time a quote is shown, then cached.
Same lifecycle as the other small joys: pool deterministic daily pick cached row.
"""
from __future__ import annotations
import sqlite3
from . import daily
from .localtime import local_today
# Public-domain (ancient / author died well over a century ago), uplifting. Admin curates.
SEED = [
("Very little is needed to make a happy life; it is all within yourself, in your way of thinking.", "Marcus Aurelius", "Meditations"),
("The happiness of your life depends upon the quality of your thoughts.", "Marcus Aurelius", "Meditations"),
("The journey of a thousand miles begins with a single step.", "Lao Tzu", "Tao Te Ching"),
("Nature does not hurry, yet everything is accomplished.", "Lao Tzu", "Tao Te Ching"),
("It does not matter how slowly you go as long as you do not stop.", "Confucius", None),
("Knowing yourself is the beginning of all wisdom.", "Aristotle", None),
("We suffer more often in imagination than in reality.", "Seneca", None),
("It's not what happens to you, but how you react to it that matters.", "Epictetus", None),
("The wound is the place where the Light enters you.", "Rumi", None),
("Write it on your heart that every day is the best day in the year.", "Ralph Waldo Emerson", None),
("Go confidently in the direction of your dreams. Live the life you have imagined.", "Henry David Thoreau", None),
("Hope is the thing with feathers that perches in the soul.", "Emily Dickinson", None),
("The best portion of a good man's life: his little, nameless, unremembered acts of kindness and of love.", "William Wordsworth", None),
("Great things are done by a series of small things brought together.", "Vincent van Gogh", None),
("I am not afraid of storms, for I am learning how to sail my ship.", "Louisa May Alcott", "Little Women"),
("I exist as I am, that is enough.", "Walt Whitman", "Leaves of Grass"),
]
def seed(conn: sqlite3.Connection) -> int:
"""Insert the curated starter quotes (idempotent via content key). Returns # added."""
before = conn.execute("SELECT COUNT(*) FROM quote_pool").fetchone()[0]
conn.executemany(
"INSERT OR IGNORE INTO quote_pool (source, ckey, text, author, work) VALUES ('curated', ?, ?, ?, ?)",
[(daily.content_key(text, author), text, author, work) for text, author, work in SEED],
)
conn.commit()
return conn.execute("SELECT COUNT(*) FROM quote_pool").fetchone()[0] - before
def _explain(client, text: str, author: str | None) -> str | None:
user = (
"In one or two plain, warm sentences, explain what this quote means and why it's worth "
"remembering, for a general audience who may not be familiar with it. No preamble, no "
f'quoting it back.\n\nQuote: "{text}"' + (f"\n{author}" if author else "")
)
out = " ".join(client.chat_text([{"role": "user", "content": user}]).split()).strip()
return out or None
def _candidates(conn: sqlite3.Connection, avoid: int | None = None) -> list[int]:
featured = conn.execute(
"SELECT id FROM quote_pool WHERE blocked=0 AND featured=1 ORDER BY id"
).fetchall()
if featured:
ids = [r[0] for r in featured]
else:
# The freshest cohort only (never-shown, else the oldest-shown group) — picking
# across the whole pool is what re-fed recent quotes day to day.
rows = conn.execute("SELECT id, shown_at FROM quote_pool WHERE blocked=0").fetchall()
ids = daily.freshest(rows)
if avoid is not None:
ids = [i for i in ids if i != avoid] or ids
return ids
def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, client=None,
force: bool = False, avoid: int | None = None) -> dict | None:
feature_date = feature_date or local_today()
existing = conn.execute("SELECT * FROM daily_quote WHERE feature_date=?", (feature_date,)).fetchone()
if existing and not force:
return dict(existing)
ids = _candidates(conn, avoid)
if not ids:
return None
pick_id = daily.seeded_order(ids, feature_date)[0]
row = conn.execute("SELECT * FROM quote_pool WHERE id=?", (pick_id,)).fetchone()
meaning = row["meaning"]
if not meaning and client: # lazy, cached; LLM done before the write
try:
meaning = _explain(client, row["text"], row["author"])
if meaning:
conn.execute("UPDATE quote_pool SET meaning=? WHERE id=?", (meaning, pick_id))
except Exception: # noqa: BLE001 — explainer is optional
meaning = None
conn.execute(
"INSERT INTO daily_quote (feature_date, pool_id, source, text, author, work, year, meaning) "
"VALUES (?,?,?,?,?,?,?,?) "
"ON CONFLICT(feature_date) DO UPDATE SET pool_id=excluded.pool_id, text=excluded.text, "
"author=excluded.author, work=excluded.work, year=excluded.year, meaning=excluded.meaning",
(feature_date, row["id"], row["source"], row["text"], row["author"], row["work"], row["year"], meaning),
)
conn.execute("UPDATE quote_pool SET shown_at=? WHERE id=?", (feature_date, pick_id))
conn.commit()
return dict(conn.execute("SELECT * FROM daily_quote WHERE feature_date=?", (feature_date,)).fetchone())
def get_today(conn: sqlite3.Connection, feature_date: str | None = None) -> dict | None:
if feature_date:
row = conn.execute("SELECT * FROM daily_quote WHERE feature_date=?", (feature_date,)).fetchone()
if row:
return dict(row)
row = conn.execute("SELECT * FROM daily_quote ORDER BY feature_date DESC LIMIT 1").fetchone()
return dict(row) if row else None
def run_daily(conn: sqlite3.Connection, client=None) -> dict:
if conn.execute("SELECT COUNT(*) FROM quote_pool").fetchone()[0] == 0:
seed(conn)
picked = pick_daily(conn, client=client)
return {"pool": conn.execute("SELECT COUNT(*) FROM quote_pool").fetchone()[0],
"picked": (picked or {}).get("author")}
+43
View File
@@ -0,0 +1,43 @@
"""Estimate a SOURCE article's full read time from its fetched HTML.
We never store the publisher's body — only a word COUNT (metadata) — to derive a
"Full story · ~N min" hint that contrasts with our one-minute gist. That tiny
detail sells the value: the calm summary now, the deep dive only if you want it.
Extraction is deliberately light (no readability parser yet): drop the obvious
non-article furniture (scripts, styles, nav, header, footer, forms, buttons,
asides), strip tags, count words. ~225 wpm offsets the boilerplate that still
slips through. Below a floor we assume failed/blocked extraction and return None
so the UI shows NO badge rather than a misleading "1 min".
"""
from __future__ import annotations
import re
_WPM = 225
_MIN_WORDS = 200 # below this → assume failed/too-thin extraction → no badge
# Blocks whose CONTENT is furniture, removed wholesale before counting.
_FURNITURE = re.compile(
rb"<(script|style|noscript|template|svg|nav|header|footer|form|button|aside|select|option)\b[^>]*>.*?</\1>",
re.IGNORECASE | re.DOTALL,
)
_TAGS = re.compile(rb"<[^>]+>")
_WS = re.compile(r"\s+")
def word_count_from_html(raw: bytes | None) -> int:
"""Rough article word count from raw HTML bytes, furniture stripped."""
if not raw:
return 0
cleaned = _FURNITURE.sub(b" ", raw)
text = _TAGS.sub(b" ", cleaned).decode("utf-8", "replace")
return len(_WS.sub(" ", text).split())
def source_read_minutes(words: int | None) -> int | None:
"""Whole-minute estimate for the FULL article, or None when the count looks
failed/too thin (so callers omit the badge instead of showing a wrong number)."""
if not words or words < _MIN_WORDS:
return None
return max(2, round(words / _WPM))
+162 -41
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
from html import escape
from .newsimg import display_url
def _tag(name: str, content: str | None, attr: str = "property") -> str:
if not content:
@@ -17,43 +19,170 @@ def _tag(name: str, content: str | None, attr: str = "property") -> str:
return f'<meta {attr}="{escape(name)}" content="{escape(content)}">'
# --- Shared top bar -----------------------------------------------------------------
# A static replica of the SPA's HubBar so server-rendered share pages (/a/<id>, the
# digest) carry the SAME toolbar as the rest of the site. These pages can't run the
# Svelte component, so this is kept in sync with frontend/src/lib/components/HubBar.svelte
# (+ HubShell's borderless Back) BY HAND — change both together. `active` highlights a
# section ('' = none, as on an article). The account glyph is the signed-out state;
# _TOP_BAR_JS swaps in the cached avatar for signed-in readers, just like HubBar.
_TOP_NAV = (("/", "Home", "home"), ("/news", "News", "news"),
("/play", "Games", "games"), ("/art", "Art", "art"))
def _nav_links(active: str) -> str:
return "".join(f'<a class="{"on" if k == active else ""}" href="{href}">{label}</a>'
for href, label, k in _TOP_NAV)
def _top_bar_html(active: str = "") -> str:
return (
'<header class="bar">'
'<a class="brand" href="/" aria-label="upbeatBytes home"><img src="/logo.svg" alt="upbeatBytes"></a>'
'<div class="bar-end">'
f'<nav class="nav">{_nav_links(active)}<span class="nav-soon">Entertainment</span></nav>'
'<a class="acct" href="/account" aria-label="Your account">'
'<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#C98A2E" stroke-width="2" aria-hidden="true">'
'<circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/></svg></a>'
'<button class="burger" type="button" aria-label="Menu" aria-expanded="false" data-burger>'
'<span></span><span></span><span></span></button>'
'</div></header>'
f'<div class="menu-wrap" data-menu hidden><nav class="menu">{_nav_links(active)}'
'<span class="menu-soon">Entertainment <em>soon</em></span></nav></div>'
)
def _back_link_html(label: str = "Back") -> str:
return ('<button class="hb-back" type="button" data-back aria-label="Go back">'
'<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" '
'stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
f'<path d="M15 18l-6-6 6-6"/></svg>{label}</button>')
# Ported verbatim from HubBar.svelte's <style> (+ HubShell's .back), scoped to the same
# class names so the bar looks pixel-identical to the SPA regardless of page palette.
_TOP_BAR_CSS = """
/* reserve the scrollbar gutter so the bar doesn't jump left-right between pages */
html { scrollbar-gutter: stable; }
@font-face { font-family:'Hanken Grotesk'; src:url('/fonts/hanken-var.woff2') format('woff2'); font-weight:400 700; font-style:normal; font-display:swap; }
header.bar { display:flex; align-items:center; justify-content:space-between; max-width:1180px; width:100%; margin:0 auto; box-sizing:border-box; padding:26px clamp(18px,5vw,44px) 0; font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; background:none; border:none; }
header.bar .brand { display:block; line-height:0; }
header.bar .brand img { height:48px; width:auto; display:block; }
.bar-end { display:flex; align-items:center; gap:clamp(16px,2.4vw,32px); }
.nav { display:flex; align-items:center; gap:clamp(16px,2.4vw,32px); font-size:16.5px; font-weight:500; }
.nav a { color:#6b6256; text-decoration:none; }
.nav a.on { color:#23201b; }
.nav a:hover { color:#0083ad; }
.nav-soon { color:#b3a890; }
.acct { width:32px; height:32px; border-radius:50%; border:1.5px solid #e6c9a0; background:#FCEFD7; display:flex; align-items:center; justify-content:center; flex:none; text-decoration:none; }
.acct.hasimg { background:none; overflow:hidden; padding:0; }
.acct:hover { background:#fbe6c4; }
.burger { display:none; flex-direction:column; align-items:center; justify-content:center; gap:4px; width:40px; height:40px; border-radius:11px; border:1.5px solid #e6c9a0; background:#FCEFD7; cursor:pointer; padding:0; flex:none; }
.burger:hover { background:#fbe6c4; }
.burger span { width:18px; height:2px; border-radius:2px; background:#7a6a52; transition:transform .2s ease, opacity .15s ease; }
.burger.open span:nth-child(1) { transform:translateY(6px) rotate(45deg); }
.burger.open span:nth-child(2) { opacity:0; }
.burger.open span:nth-child(3) { transform:translateY(-6px) rotate(-45deg); }
.menu-wrap { max-width:1180px; width:100%; margin:10px auto 0; box-sizing:border-box; padding:0 clamp(18px,5vw,44px); font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; }
.menu { display:flex; flex-direction:column; background:#fff; border:1px solid #f2e7d3; border-radius:14px; overflow:hidden; box-shadow:0 14px 34px -20px rgba(60,50,30,.4); }
.menu a, .menu .menu-soon { padding:14px 18px; font-size:16px; font-weight:500; text-decoration:none; color:#6b6256; border-top:1px solid #f3ece0; }
.menu a:first-child { border-top:none; }
.menu a.on { color:#23201b; }
.menu a:hover { background:#FFF9EF; color:#0083ad; }
.menu-soon { display:flex; align-items:center; justify-content:space-between; color:#b3a890; }
.menu-soon em { font-style:normal; font-size:10px; font-weight:700; letter-spacing:.08em; text-transform:uppercase; color:#c3b69c; }
.hb-back { display:inline-flex; align-items:center; gap:6px; margin:0 0 clamp(14px,3vw,24px); background:none; border:none; cursor:pointer; padding:6px 10px 6px 0; font-family:'Hanken Grotesk',ui-sans-serif,system-ui,sans-serif; font-size:14px; font-weight:600; color:#6b6256; transition:color .15s ease; }
.hb-back:hover { color:#0083ad; }
.hb-back svg { transition:transform .15s ease; }
.hb-back:hover svg { transform:translateX(-2px); }
@media (max-width:720px) { .nav { display:none; } .burger { display:flex; } }
@media (min-width:721px) { .menu-wrap { display:none !important; } }
"""
# Burger toggle + signed-in avatar (read from the SPA's localStorage cache, same as HubBar).
# Parity with HubBar: Escape closes the menu, and crossing back to desktop width resets it.
_TOP_BAR_JS = """<script>
(function(){
var b=document.querySelector('[data-burger]'), m=document.querySelector('[data-menu]');
function close(){ if(m) m.setAttribute('hidden',''); if(b){ b.classList.remove('open'); b.setAttribute('aria-expanded','false'); } }
if(b&&m){ b.addEventListener('click',function(){
if(m.hasAttribute('hidden')){ m.removeAttribute('hidden'); b.classList.add('open'); b.setAttribute('aria-expanded','true'); }
else { close(); }
}); }
document.addEventListener('keydown',function(e){ if(e.key==='Escape') close(); });
if(window.matchMedia){ var mq=window.matchMedia('(min-width:721px)'); if(mq.addEventListener) mq.addEventListener('change',function(e){ if(e.matches) close(); }); }
try{
var u=JSON.parse(localStorage.getItem('goodnews:auth_user')||'null');
if(u&&u.avatar_url){
var a=document.querySelector('.acct');
if(a){ var img=document.createElement('img'); img.src=u.avatar_url; img.alt=''; img.referrerPolicy='no-referrer';
img.style.cssText='width:32px;height:32px;border-radius:999px;object-fit:cover';
a.classList.add('hasimg'); a.textContent=''; a.appendChild(img); }
}
}catch(e){}
})();
</script>"""
# Single-history Back (mirrors HubShell): go back ONLY when we arrived from our own origin,
# else go home — never bounce the reader off to an external referrer.
_BACK_JS = """<script>
(function(){
var b=document.querySelector('[data-back]'); if(!b) return;
b.addEventListener('click',function(){
var same=false; try{ same=!!document.referrer && new URL(document.referrer).origin===location.origin; }catch(e){}
if(same && history.length>1){ history.back(); } else { location.href='/'; }
});
})();
</script>"""
def render_share_page(article: dict, base_url: str, summary: str | None = None,
explanation: dict | None = None) -> str:
aid = article["id"]
title = (article.get("title") or "Upbeat Bytes").strip()
title = (article.get("title") or "upbeatBytes").strip()
why = (article.get("reason_text") or article.get("description")
or "A calm, constructive story worth your attention.").strip()
source = (article.get("source_name") or "the source").strip()
source_id = article.get("source_id")
# Link the source name into the app's publication feed for that source.
source_html = (
f'<a class="src srclink" href="/?source={source_id}">{escape(source)}</a>'
f'<a class="src srclink" href="/news?source={source_id}">{escape(source)}</a>'
if source_id else f'<div class="src">{escape(source)}</div>'
)
src_url = article.get("canonical_url") or base_url
image = article.get("image_url")
policy = article.get("image_policy")
# What WE show, honoring the source's image policy (cache → our copy; remote → the
# publisher's URL; none → nothing). og/twitter reference the publisher's own image
# (a link, not re-hosting) whenever we'd show anything; 'none' omits it entirely.
display = display_url(aid, policy, image)
og_image = image if (image and policy != "none") else None
page_url = f"{base_url}/a/{aid}"
# With an image: a large-image card. Without: a clean text unfurl (title +
# why + brand) — tidy, not a broken/muddy preview. (A branded fallback PNG
# can replace this later.)
twitter_card = "summary_large_image" if image else "summary"
twitter_card = "summary_large_image" if display else "summary"
meta = "\n".join(filter(None, [
_tag("og:site_name", "Upbeat Bytes"),
_tag("og:site_name", "upbeatBytes"),
_tag("og:type", "article"),
_tag("og:title", title),
_tag("og:description", why),
_tag("og:url", page_url),
_tag("og:image", image),
_tag("og:image", og_image),
_tag("twitter:card", twitter_card, attr="name"),
_tag("twitter:title", title, attr="name"),
_tag("twitter:description", why, attr="name"),
_tag("twitter:image", image, attr="name"),
_tag("twitter:image", og_image, attr="name"),
]))
# The visible image is whatever the policy resolved to (our cached copy for 'cache'
# sources, else the publisher's URL for 'remote'). If it isn't cached yet / fails to
# load, drop the element so the page degrades to the clean text unfurl, not a broken icon.
media = (
f'<img class="media" src="{escape(image)}" alt="" referrerpolicy="no-referrer">'
if image else ""
f'<img class="media" src="{escape(display)}" alt="" referrerpolicy="no-referrer" '
f'onerror="this.remove()">'
if display else ""
)
raw_tags = (article.get("tags") or "")
@@ -138,7 +267,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{escape(title)} · Upbeat Bytes</title>
<title>{escape(title)} · upbeatBytes</title>
<meta name="description" content="{escape(why)}">
<link rel="canonical" href="{escape(page_url)}">
<link rel="icon" href="/favicon.svg">
@@ -150,17 +279,7 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
body {{ margin:0; background:var(--bg); color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
line-height:1.6; }}
.bar {{ background:var(--surface); border-bottom:1px solid var(--line); }}
.bar .inner {{ max-width:680px; margin:0 auto; padding:12px 20px;
display:flex; align-items:center; justify-content:space-between; gap:12px; }}
.bar img {{ height:40px; display:block; }}
.back {{ display:inline-flex; align-items:center; gap:7px;
background:none; border:1px solid var(--line); color:var(--accent-deep);
border-radius:999px; padding:8px 17px 8px 14px; font-size:.92rem; font-weight:600;
font-family:inherit; cursor:pointer; line-height:1; white-space:nowrap;
transition:border-color .14s ease, background .14s ease; }}
.back svg {{ width:19px; height:19px; display:block; }}
.back:hover {{ border-color:var(--accent); background:var(--bg); }}
{_TOP_BAR_CSS}
.wrap {{ max-width:680px; margin:0 auto; padding:24px 20px 60px; }}
.card {{ background:var(--surface); border:1px solid var(--line); border-radius:16px;
overflow:hidden; box-shadow:0 10px 30px rgba(40,38,28,.06); }}
@@ -194,8 +313,9 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
</style>
</head>
<body>
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="Upbeat Bytes"></a><button class="back" type="button" data-back aria-label="Go back"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>Back</button></div></div>
{_top_bar_html()}
<main class="wrap">
{_back_link_html()}
<article class="card">
{media}
<div class="body">
@@ -207,9 +327,9 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
<div class="actions">
<a class="primary" href="{escape(src_url)}" target="_blank" rel="noopener" data-src-click>Read the full story at {escape(source)}</a>
<button class="secondary" type="button" data-share>Copy link</button>
<a class="secondary" href="/">Explore Upbeat Bytes </a>
<a class="secondary" href="/news">Explore upbeatBytes </a>
</div>
<p class="note">Upbeat Bytes summarizes in its own words and links to the original publisher it doesn't host the article.</p>
<p class="note">upbeatBytes summarizes in its own words and links to the original publisher it doesn't host the article.</p>
</div>
</article>
</main>
@@ -224,6 +344,14 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
// visit isn't recorded for a /a/ landing — count it here, once per day per device.
var t=new Date().toISOString().slice(0,10);
if(localStorage.getItem('goodnews:visitday')!==t){{localStorage.setItem('goodnews:visitday',t);beacon({{kind:'visit',article_id:0,visitor:v}});}}
// Engaged-reader signal (mirrors the SPA's armEngaged): ~8s visible + a real gesture, once/day.
var eng=false,gest=false,secs=0;
function fireEng(){{
if(eng||!gest||secs<8) return; eng=true;
try{{ if(localStorage.getItem('goodnews:engagedday')!==t){{localStorage.setItem('goodnews:engagedday',t);beacon({{kind:'engaged',article_id:0,visitor:v}});}} }}catch(e){{}}
}}
var iv=setInterval(function(){{ if(document.visibilityState==='visible'){{secs++;fireEng();}} if(eng) clearInterval(iv); }},1000);
['scroll','pointerdown','keydown','touchstart'].forEach(function(e){{window.addEventListener(e,function(){{gest=true;fireEng();}},{{passive:true}});}});
}}catch(e){{}}
}})();
</script>
@@ -238,14 +366,8 @@ def render_share_page(article: dict, base_url: str, summary: str | None = None,
}});
}})();
</script>
<script>
(function(){{
var b=document.querySelector('[data-back]'); if(!b) return;
b.addEventListener('click',function(){{
if(document.referrer && history.length>1){{ history.back(); }} else {{ location.href='/'; }}
}});
}})();
</script>
{_BACK_JS}
{_TOP_BAR_JS}
{poll}
</body>
</html>"""
@@ -275,7 +397,7 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
)
meta = "\n".join(filter(None, [
_tag("og:site_name", "Upbeat Bytes"),
_tag("og:site_name", "upbeatBytes"),
_tag("og:type", "website"),
_tag("og:title", "Today's good news, summarized"),
_tag("og:description", intro),
@@ -292,7 +414,7 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Today's good news, summarized · Upbeat Bytes</title>
<title>Today's good news, summarized · upbeatBytes</title>
<meta name="description" content="{escape(intro)}">
<link rel="canonical" href="{page_url}">
<link rel="icon" href="/favicon.svg">
@@ -303,9 +425,7 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
* {{ box-sizing:border-box; }}
body {{ margin:0; background:var(--bg); color:var(--ink);
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; line-height:1.6; }}
.bar {{ background:var(--surface); border-bottom:1px solid var(--line); }}
.bar .inner {{ max-width:720px; margin:0 auto; padding:12px 20px; }}
.bar img {{ height:40px; display:block; }}
{_TOP_BAR_CSS}
.wrap {{ max-width:720px; margin:0 auto; padding:26px 20px 60px; }}
h1 {{ font-family:"Iowan Old Style",Palatino,Georgia,serif; font-weight:600; font-size:2rem; margin:0 0 4px; }}
.lede {{ color:var(--muted); margin:0 0 26px; }}
@@ -324,13 +444,14 @@ def render_digest(items: list[dict], base_url: str, brief_date: str | None) -> s
</style>
</head>
<body>
<div class="bar"><div class="inner"><a href="/"><img src="/logo.svg" alt="Upbeat Bytes"></a></div></div>
{_top_bar_html()}
<main class="wrap">
<h1>Today's good news</h1>
<p class="lede">{escape(intro)}{f' · {escape(brief_date)}' if brief_date else ''}</p>
{cards}
<p class="more"><a href="/">Browse more on Upbeat Bytes </a></p>
<p class="more"><a href="/news">Browse more on upbeatBytes </a></p>
</main>
{_TOP_BAR_JS}
</body>
</html>"""
@@ -339,7 +460,7 @@ def render_not_found(base_url: str) -> str:
return f"""<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Story not found · Upbeat Bytes</title><link rel="icon" href="/favicon.svg">
<title>Story not found · upbeatBytes</title><link rel="icon" href="/favicon.svg">
<style>
body {{ margin:0; min-height:100vh; display:flex; flex-direction:column; align-items:center;
justify-content:center; gap:14px; text-align:center; padding:40px;
@@ -348,8 +469,8 @@ def render_not_found(base_url: str) -> str:
a {{ color:#006b8e; }}
</style></head>
<body>
<img src="/logo.svg" alt="Upbeat Bytes" style="height:44px">
<img src="/logo.svg" alt="upbeatBytes" style="height:44px">
<h1 style="font-family:Georgia,serif;font-weight:600">That story isn't here</h1>
<p style="color:#5d6b78">It may have moved on the good news refreshes often.</p>
<a href="/"> Back to Upbeat Bytes</a>
<a href="/"> Back to upbeatBytes</a>
</body></html>"""
+279
View File
@@ -0,0 +1,279 @@
"""Word of the Day — an uplifting word a day, grounded in a real dictionary.
"LLM proposes, dictionary disposes": the LLM suggests positive/calming words; each is
validated + enriched against the free Dictionary API (dictionaryapi.dev) for the REAL
definition, IPA pronunciation, example sentences, and a human pronunciation clip. That
rules out hallucinated definitions the authoritative data is the dictionary's. The
audio clip (public-domain, usually Wiktionary) is cached to our origin; the page falls
back to the browser's speech synthesis when a word has no clip.
All network/LLM work happens before the brief DB write. Same pick lifecycle as the
other small joys.
"""
from __future__ import annotations
import json
import os
import re
import sqlite3
import urllib.parse
import urllib.request
from pathlib import Path
from . import daily
from .localtime import local_today
DICT_BASE = "https://api.dictionaryapi.dev/api/v2/entries/en"
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
_TARGET_POOL = 30 # keep harvesting (a batch/day) until the pool reaches this
_HARVEST_BATCH = 12
_MIN_AUDIO_BYTES = 500
def cache_dir() -> Path:
override = os.environ.get("GOODNEWS_WOTD_AUDIO")
d = Path(override) if override else Path(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3")).parent / "wotd_audio"
d.mkdir(parents=True, exist_ok=True)
return d
def _http_bytes(url: str, timeout: int = 30) -> tuple[bytes, str]:
req = urllib.request.Request(url, headers=_UA)
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read(), (r.headers.get("Content-Type") or "")
def _propose_words(client, n: int) -> list[dict]:
"""Ask for word + the intended part of speech, so _lookup picks the sense the LLM meant
(e.g. 'serene' the adjective, not the archaic noun)."""
user = (
f"Suggest {n} English vocabulary words for an uplifting 'word of the day' — positive, "
"calming, hopeful, or quietly beautiful in meaning (e.g. serene, kindness, dawn, "
"resilience, wonder). Real, usable words; vary common and slightly elevated. For each, "
"give the part of speech you intend (the everyday modern sense, not an archaic one). "
'Reply with JSON only: {"words": [{"word": "serene", "pos": "adjective"}, ...]}'
)
txt = client.chat_text([{"role": "user", "content": user}])
m = re.search(r"\{.*\}", txt, re.S)
if not m:
return []
out = []
for w in json.loads(m.group(0)).get("words", []):
if isinstance(w, str) and w.strip():
out.append({"word": w.strip().lower(), "pos": None})
elif isinstance(w, dict) and str(w.get("word", "")).strip():
out.append({"word": str(w["word"]).strip().lower(),
"pos": (str(w.get("pos")).strip().lower() or None) if w.get("pos") else None})
return out
def _polish(client, word: str, part_of_speech: str | None, definition: str) -> dict | None:
"""LLM polish for display: rewrite the real dictionary gloss as ONE warm plain sentence,
and write two clear everyday example sentences. Grounded in the real definition (the
dictionary stays the anchor); returns None on any trouble so callers fall back to raw."""
pos = f" ({part_of_speech})" if part_of_speech else ""
user = (
f'The word is "{word}"{pos}. Its dictionary definition is: "{definition}".\n'
"1) Rewrite that definition as ONE warm, plain-language sentence that a general reader "
"instantly understands. Stay faithful to the meaning; do not invent extra facts.\n"
"2) Write TWO short, natural example sentences that clearly show the word used in "
"everyday life — concrete and easy to picture, not abstract, archaic, or a proper-noun "
f'title. Each must actually use the word "{word}".\n'
'Reply with JSON only: {"gloss": "...", "examples": ["...", "..."]}'
)
try:
txt = client.chat_text([{"role": "user", "content": user}])
except Exception: # noqa: BLE001 — polish is best-effort; raw dictionary data stands
return None
m = re.search(r"\{.*\}", txt, re.S)
if not m:
return None
try:
data = json.loads(m.group(0))
except ValueError:
return None
gloss = " ".join(str(data.get("gloss") or "").split()).strip()
examples = [" ".join(str(e).split()).strip() for e in (data.get("examples") or []) if str(e).strip()]
# Enforce the contract: keep only sentences that actually use the word, and require at
# least one. A gloss with no usable examples falls back to the raw dictionary data.
examples = [e for e in examples if word.lower() in e.lower()]
if not gloss or not examples:
return None
return {"gloss": gloss, "examples": examples[:2]}
def _lookup(word: str, prefer_pos: str | None = None) -> dict | None:
"""Validate + enrich a word via the dictionary. Returns None if it's not a real word.
When prefer_pos is given, picks the meaning of that part of speech (the sense the LLM meant)."""
try:
data = daily.http_json(f"{DICT_BASE}/{urllib.parse.quote(word)}")
except Exception: # noqa: BLE001 — unknown word / network → just skip it
return None
if not isinstance(data, list) or not data:
return None
entry = data[0]
meanings = entry.get("meanings") or []
if not meanings or not (meanings[0].get("definitions") or []):
return None
# Prefer the meaning whose part of speech matches the LLM's intent; else the first usable one.
chosen = None
if prefer_pos:
for mn in meanings:
if (mn.get("partOfSpeech") or "").strip().lower() == prefer_pos and (mn.get("definitions") or []):
if (mn["definitions"][0].get("definition") or "").strip():
chosen = mn
break
if chosen is None:
for mn in meanings:
if (mn.get("definitions") or []) and (mn["definitions"][0].get("definition") or "").strip():
chosen = mn
break
if chosen is None:
return None
definition = (chosen["definitions"][0].get("definition") or "").strip()
phonetic = entry.get("phonetic")
audio_url = None
for p in (entry.get("phonetics") or []):
if not phonetic and p.get("text"):
phonetic = p["text"]
if not audio_url and p.get("audio"):
audio_url = p["audio"]
examples = []
for m in [chosen] + [mn for mn in meanings if mn is not chosen]: # chosen sense's examples first
for d in (m.get("definitions") or []):
if d.get("example"):
examples.append(d["example"].strip())
return {
"word": (entry.get("word") or word).strip().lower(),
"part_of_speech": chosen.get("partOfSpeech"),
"phonetic": phonetic,
"audio_url": audio_url,
"definition": definition,
"examples": examples[:3],
}
def _cache_audio(audio_url: str | None, word: str) -> str | None:
"""Download the pronunciation clip to our origin (atomic). Returns filename or None."""
if not audio_url:
return None
if audio_url.startswith("//"):
audio_url = "https:" + audio_url
try:
data, ctype = _http_bytes(audio_url)
except Exception: # noqa: BLE001
return None
if len(data) < _MIN_AUDIO_BYTES:
return None
ext = ".ogg" if ("ogg" in ctype or audio_url.endswith(".ogg")) else ".mp3"
fname = f"{word}{ext}"
cdir = cache_dir()
tmp = cdir / f".{word}.tmp"
try:
tmp.write_bytes(data)
os.replace(tmp, cdir / fname)
except OSError:
try:
tmp.unlink()
except OSError:
pass
return None
return fname
def _pool_count(conn: sqlite3.Connection) -> int:
return conn.execute("SELECT COUNT(*) FROM wotd_pool").fetchone()[0]
def harvest(conn: sqlite3.Connection, client, count: int = _HARVEST_BATCH) -> dict:
"""Propose words → validate/enrich via dictionary → cache audio → add new ones.
All network up front; one brief write at the end."""
try:
words = _propose_words(client, count)
except Exception: # noqa: BLE001
return {"proposed": 0, "added": 0, "pool": _pool_count(conn)}
rows = []
for item in words:
w = item["word"]
if not w.isalpha() or conn.execute("SELECT 1 FROM wotd_pool WHERE word=?", (w,)).fetchone():
continue
info = _lookup(w, item.get("pos"))
if not info:
continue
audio_file = _cache_audio(info["audio_url"], info["word"])
polished = _polish(client, info["word"], info["part_of_speech"], info["definition"])
gloss = polished["gloss"] if polished else None
usage = json.dumps(polished["examples"]) if polished else None
rows.append((info["word"], info["part_of_speech"], info["phonetic"], audio_file,
info["audio_url"], info["definition"], json.dumps(info["examples"]), gloss, usage))
before = _pool_count(conn)
conn.executemany(
"INSERT OR IGNORE INTO wotd_pool (source, word, part_of_speech, phonetic, audio_file, audio_url, definition, examples, gloss, usage) "
"VALUES ('llm', ?, ?, ?, ?, ?, ?, ?, ?, ?)", rows,
)
conn.commit()
after = _pool_count(conn)
return {"proposed": len(words), "added": after - before, "pool": after}
def _candidates(conn: sqlite3.Connection, avoid: int | None = None) -> list[int]:
featured = conn.execute("SELECT id FROM wotd_pool WHERE blocked=0 AND featured=1 ORDER BY id").fetchall()
if featured:
ids = [r[0] for r in featured]
else:
# The freshest cohort only (never-shown, else the oldest-shown group) — picking
# across the whole pool is what re-fed recent words day to day.
rows = conn.execute("SELECT id, shown_at FROM wotd_pool WHERE blocked=0").fetchall()
ids = daily.freshest(rows)
if avoid is not None:
ids = [i for i in ids if i != avoid] or ids
return ids
def pick_daily(conn: sqlite3.Connection, feature_date: str | None = None, force: bool = False,
avoid: int | None = None, client=None) -> dict | None:
feature_date = feature_date or local_today()
existing = conn.execute("SELECT * FROM daily_wotd WHERE feature_date=?", (feature_date,)).fetchone()
if existing and not force:
return dict(existing)
ids = _candidates(conn, avoid)
if not ids:
return None
pick_id = daily.seeded_order(ids, feature_date)[0]
row = conn.execute("SELECT * FROM wotd_pool WHERE id=?", (pick_id,)).fetchone()
gloss, usage = row["gloss"], row["usage"]
if not gloss and client: # lazy polish for older pool words; cached back
polished = _polish(client, row["word"], row["part_of_speech"], row["definition"])
if polished:
gloss, usage = polished["gloss"], json.dumps(polished["examples"])
conn.execute("UPDATE wotd_pool SET gloss=?, usage=? WHERE id=?", (gloss, usage, pick_id))
conn.execute(
"INSERT INTO daily_wotd (feature_date, pool_id, word, part_of_speech, phonetic, audio_file, definition, examples, gloss, usage) "
"VALUES (?,?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(feature_date) DO UPDATE SET pool_id=excluded.pool_id, word=excluded.word, "
"part_of_speech=excluded.part_of_speech, phonetic=excluded.phonetic, audio_file=excluded.audio_file, "
"definition=excluded.definition, examples=excluded.examples, gloss=excluded.gloss, usage=excluded.usage",
(feature_date, row["id"], row["word"], row["part_of_speech"], row["phonetic"],
row["audio_file"], row["definition"], row["examples"], gloss, usage),
)
conn.execute("UPDATE wotd_pool SET shown_at=? WHERE id=?", (feature_date, pick_id))
conn.commit()
return dict(conn.execute("SELECT * FROM daily_wotd WHERE feature_date=?", (feature_date,)).fetchone())
def get_today(conn: sqlite3.Connection, feature_date: str | None = None) -> dict | None:
if feature_date:
row = conn.execute("SELECT * FROM daily_wotd WHERE feature_date=?", (feature_date,)).fetchone()
if row:
return dict(row)
row = conn.execute("SELECT * FROM daily_wotd ORDER BY feature_date DESC LIMIT 1").fetchone()
return dict(row) if row else None
def run_daily(conn: sqlite3.Connection, client=None) -> dict:
"""Top the pool up toward _TARGET_POOL (a batch a day), then pick today's word."""
harvested = None
if client and _pool_count(conn) < _TARGET_POOL:
harvested = harvest(conn, client)
picked = pick_daily(conn, client=client)
return {"pool": _pool_count(conn), "harvested": harvested, "picked": (picked or {}).get("word")}
+2 -1
View File
@@ -16,7 +16,8 @@ $ = informational
- Date showed 6/2/2026 while it was still 6/1/2026 at 10:32pm
- For account-based usage, we should have a thumbs up button that shows up to track the articles the user likes the most. We can then curate a special feed of articles that match the categories the user likes the most. Not social-based, just for seeing news that means the most to you.
- Feasibility of allowing users to add their own custom feeds for news sources
- Joke corner: a curated, clean, non-offensive daily/rotating joke spot. On-brand "escape the grind" — light, professional-but-fun. Curation bar same as the rest of UB (nothing mean or edgy).
- Joke corner: a curated, clean, non-offensive daily/rotating joke spot. On-brand "escape the grind" — light, professional-but-fun. Curation bar same as the rest of UB (nothing mean or edgy). PARTICIPATION LOOP: let people SUBMIT jokes → AI pre-screen (clean/non-insulting/actually-funny, conservative gate) → human batch-approval queue (user is fine doing batches to drive engagement) → approved ones go live. Same "LLM proposes, code disposes" + admin-approval-queue pattern already used for Bloom words, Daily Word pool, and source candidates — known architecture, not net-new. Drivers: submission gives a reason to RETURN ("did mine get approved?"), attribution ("submitted by …") deepens ownership, approved jokes are shareable. Guardrails: jokes are an offense minefield (punching-down/stereotypes) so AI gate stays conservative + human is final say; reuse feedback-form anti-abuse (honeypot + rate-limit) on the submit endpoint.
- Bubble shooter / "bubble blaster" game for /play (casual, calm-satisfying arcade — different fun than the word/brain games). Strategic point: own the destination + widen the funnel, NOT literally steal a clone's community. Make it feed the share loop: DAILY SEEDED board + shareable SCORE ("I scored 14,200 🫧") deep-linked like the other games. Scope flag: bigger than the turn-based grid games — it's a real-time CANVAS game (aim, projectile physics, collision, color-cluster pop, cascade/drop, animation loop). Post-launch build, our own art/calm aesthetic (no cloned name/assets).
- Text adventure that SAVES YOUR SPOT in time (resume where you left off — a reason to come back). Start single-player/choose-your-path; dream stretch goal = broaden to co-op/multiplayer where people work through it together. Theme TBD. Fits "UB isn't just news — it's somewhere between professional and fun, a place to escape." (Would live under /play.)
+1
View File
@@ -15,6 +15,7 @@ dependencies = []
web = [
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
"Pillow>=10", # downscale/re-encode cached article images (goodnews/newsimg.py)
]
test = [
"pytest>=8",
+34
View File
@@ -0,0 +1,34 @@
"""One-off: migrate daily_art (blurb/palette) and backfill the most recent picks in place
re-fetch Met metadata for the guide blurb, extract the palette from the cached image
WITHOUT re-picking (keeps each day's existing piece). Run on the host with LLM env sourced."""
import json
import os
from goodnews import art
from goodnews.db import connect, init_db
from goodnews.llm import LocalModelClient
conn = connect(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
init_db(conn) # idempotent migration: adds blurb/palette
client = LocalModelClient.from_env()
rows = conn.execute(
"SELECT art_date, object_id, image_file, title FROM daily_art "
"WHERE blurb IS NULL OR palette IS NULL ORDER BY art_date DESC LIMIT 8"
).fetchall()
print(f"rows to backfill: {len(rows)}")
for r in rows:
img = art.cache_dir() / r["image_file"] if r["image_file"] else None
palette = json.dumps(art._palette(img)) if (img and img.exists()) else "[]"
blurb = None
try:
blurb = art._blurb(client, art._object(r["object_id"]))
except Exception as exc: # noqa: BLE001
print(f" blurb fetch failed for {r['object_id']}: {exc}")
conn.execute("UPDATE daily_art SET blurb=COALESCE(?, blurb), palette=? WHERE art_date=?",
(blurb, palette, r["art_date"]))
conn.commit()
print(f"\n{r['art_date']} · #{r['object_id']} · {r['title']}")
print(f" palette: {palette}")
print(f" blurb: {blurb}")
conn.close()
+208
View File
@@ -0,0 +1,208 @@
#!/usr/bin/env python3
"""PROTOTYPE geo audit (not production).
Codex/Claude plan: before building any "Closer to Home" UI or touching the
production classify schema, measure what subject-geography the LLM can actually
extract from recent good-news articles, and whether it understands WHERE A STORY
HAPPENED vs. merely spotting place names.
Key taxonomy decision: "local" is relative to the VIEWER, so we do NOT store it.
We store the article's intrinsic geographic BREADTH (locality/regional/national/
multinational/global/unknown) plus the actual place(s). The UI later decides
"Near you" by comparing those places to the visitor's chosen home.
This writes results to a scratch JSON file and prints a coverage report. It does
not migrate the DB, change the classify pipeline, or backfill anything.
Run (host can reach the LAN model):
.venv/bin/python scripts/geo_audit.py --limit 400 --base-url http://127.0.0.1:8080/v1
Resumable: re-running skips article ids already in the out file.
"""
from __future__ import annotations
import argparse
import json
import statistics
from collections import Counter
from pathlib import Path
from goodnews.cli import _default_db
from goodnews.db import connect
from goodnews.llm import LocalModelClient, parse_classifier_json
BREADTHS = {"locality", "regional", "national", "multinational", "global", "unknown"}
SYSTEM = (
"You tag the real-world geography of a news story for a calm good-news site. "
"Identify the place(s) the story is fundamentally ABOUT or where it HAPPENED — "
"NOT places mentioned only in passing. Many good-news stories (general science, "
"space, broad research) have no specific place; those are 'global'. If a location "
"is only incidental or genuinely unclear, use 'unknown'. Do not guess. "
"Reply with ONLY a JSON object, no prose."
)
INSTRUCT = (
"Return JSON exactly like:\n"
'{"breadth": "<locality|regional|national|multinational|global|unknown>", '
'"places": [{"country": "<name or null>", "state_province": "<name or null>", '
'"locality": "<city/town or null>"}], "confidence": "<high|medium|low>", '
'"rationale": "<one short clause: where it happened and why>"}\n'
"breadth guide: locality=a specific city/town/county; regional=a state/province/region; "
"national=about a whole country; multinational=a few specific countries; "
"global=worldwide or no specific country; unknown=incidental/unclear. "
"places may list more than one when a story genuinely spans regions; use null for parts you can't support."
)
def fetch(conn, limit):
return conn.execute(
"""
SELECT a.id, a.title, a.description, a.published_at, a.discovered_at,
sm.summary, sm.what_happened, sm.why_matters
FROM articles a
JOIN article_scores s ON s.article_id = a.id
LEFT JOIN article_summaries sm ON sm.article_id = a.id
WHERE s.accepted = 1 AND a.duplicate_of IS NULL
ORDER BY a.discovered_at DESC
LIMIT ?
""",
(limit,),
).fetchall()
def article_text(r):
parts = [f"TITLE: {r['title']}"]
for label, key in (("SUMMARY", "summary"), ("WHAT HAPPENED", "what_happened"),
("WHY IT MATTERS", "why_matters"), ("PUBLISHER BLURB", "description")):
v = r[key]
if v:
parts.append(f"{label}: {v}")
return "\n".join(parts)
def extract(client, r):
messages = [
{"role": "system", "content": SYSTEM},
{"role": "user", "content": article_text(r) + "\n\n" + INSTRUCT},
]
raw = client.chat_text(messages)
data = parse_classifier_json(raw) # raises on unparseable
breadth = data.get("breadth")
if breadth not in BREADTHS:
breadth = "unknown"
places = data.get("places")
places = [p for p in places if isinstance(p, dict)] if isinstance(places, list) else []
conf = data.get("confidence") if data.get("confidence") in {"high", "medium", "low"} else "low"
return {
"breadth": breadth,
"places": places,
"confidence": conf,
"rationale": (data.get("rationale") or "")[:300],
}
def report(rows, results):
by_id = {r["id"]: r for r in rows}
n = len(results)
print(f"\n===== GEO AUDIT REPORT (n={n}) =====")
if not n:
return
breadth = Counter(v["breadth"] for v in results.values())
conf = Counter(v["confidence"] for v in results.values())
countries = Counter()
states = Counter()
unknown = 0
for v in results.values():
if v["breadth"] == "unknown" or not v["places"]:
unknown += 1
for p in v["places"]:
if p.get("country"):
countries[str(p["country"]).strip()] += 1
if p.get("state_province"):
states[str(p["state_province"]).strip()] += 1
def pct(x):
return f"{100*x/n:.0f}%"
print("\nBreadth:")
for k in ("locality", "regional", "national", "multinational", "global", "unknown"):
print(f" {k:<13} {breadth.get(k,0):>4} {pct(breadth.get(k,0))}")
print(f"\nUnknown/no-place rate: {unknown}/{n} {pct(unknown)}")
print("Confidence:", dict(conf))
print("\nTop countries:")
for name, c in countries.most_common(12):
print(f" {name:<22} {c}")
print("\nTop states/provinces:")
for name, c in states.most_common(12):
print(f" {name:<22} {c}")
# US-local fuel check: how many map to a US state (the "Near you" payload for Americans)
us_local = sum(1 for v in results.values()
if any((p.get("country") or "").strip() in ("United States", "USA", "US") and p.get("state_province")
for p in v["places"]))
print(f"\nArticles with a US state attached (US 'Near you' fuel): {us_local} {pct(us_local)}")
# freshness
days = [by_id[int(i)]["discovered_at"][:10] for i in results if by_id.get(int(i)) and by_id[int(i)]["discovered_at"]]
if days:
print(f"\nFreshness: {min(days)} .. {max(days)} ({len(set(days))} distinct days)")
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--db", default=None)
ap.add_argument("--limit", type=int, default=400)
ap.add_argument("--inspect", type=int, default=8, help="print N samples for manual eyeballing")
ap.add_argument("--out", default="data/geo_audit.json")
ap.add_argument("--base-url", default=None)
ap.add_argument("--model", default=None)
args = ap.parse_args()
conn = connect(args.db or str(_default_db()))
client = LocalModelClient.from_env()
if args.base_url:
client.base_url = args.base_url.rstrip("/")
if args.model:
client.model = args.model
out = Path(args.out)
results = json.loads(out.read_text()) if out.exists() else {}
rows = fetch(conn, args.limit)
print(f"Fetched {len(rows)} accepted non-dup articles; {len(results)} already done.")
done = 0
for r in rows:
rid = str(r["id"])
if rid in results:
continue
try:
results[rid] = extract(client, r)
except Exception as exc: # noqa: BLE001 — prototype; record + continue
results[rid] = {"breadth": "unknown", "places": [], "confidence": "low",
"rationale": f"ERROR: {type(exc).__name__}: {exc}"[:300], "error": True}
done += 1
if done % 25 == 0:
out.write_text(json.dumps(results, indent=1))
print(f" ...{done} new, {len(results)} total")
out.write_text(json.dumps(results, indent=1))
conn.close()
# Manual-inspection sample: the step Codex flagged as essential — eyeball whether
# the model captured WHERE IT HAPPENED, not just place-name recognition.
print(f"\n----- SAMPLE FOR MANUAL INSPECTION (first {args.inspect}) -----")
shown = 0
for r in rows:
rid = str(r["id"])
if rid not in results:
continue
v = results[rid]
print(f"\n[{rid}] {r['title']}")
print(f" breadth={v['breadth']} conf={v['confidence']} places={v['places']}")
print(f" why: {v['rationale']}")
shown += 1
if shown >= args.inspect:
break
report(rows, results)
if __name__ == "__main__":
main()
+103
View File
@@ -0,0 +1,103 @@
#!/usr/bin/env python3
"""Geo coverage report over tagged articles (article_geo / article_places).
Codex's post-backfill checklist: breadth counts, top countries/states, unknown &
global rates, low-confidence + multi-place examples, normalization gaps, and a
random spot-check. Read-only; no LLM. Also the seed for the Stage-6 admin report.
.venv/bin/python scripts/geo_report.py [--spot 20]
"""
from __future__ import annotations
import argparse
import sqlite3
from collections import Counter
from goodnews.cli import _default_db
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--db", default=None)
ap.add_argument("--spot", type=int, default=20, help="random tagged articles to spot-check")
args = ap.parse_args()
c = sqlite3.connect(args.db or str(_default_db()))
c.row_factory = sqlite3.Row
total_accepted = c.execute(
"SELECT COUNT(*) FROM articles a JOIN article_scores s ON s.article_id=a.id "
"WHERE s.accepted=1 AND a.duplicate_of IS NULL"
).fetchone()[0]
tagged = c.execute("SELECT COUNT(*) FROM article_geo").fetchone()[0]
n = tagged or 1
def pct(x):
return f"{100*x/n:.0f}%"
print(f"\n===== GEO COVERAGE ({tagged} tagged / {total_accepted} accepted non-dup = "
f"{100*tagged/(total_accepted or 1):.0f}% covered) =====")
print("\nBreadth:")
br = Counter(r["breadth"] for r in c.execute("SELECT breadth FROM article_geo"))
for k in ("locality", "regional", "national", "multinational", "global", "unknown"):
print(f" {k:<13} {br.get(k,0):>4} {pct(br.get(k,0))}")
unknown = br.get("unknown", 0)
glob = br.get("global", 0)
print(f"\nUnknown rate: {unknown} ({pct(unknown)}) Global/placeless: {glob} ({pct(glob)})")
conf = Counter(r["confidence"] for r in c.execute("SELECT confidence FROM article_geo"))
print("Confidence:", {k: conf.get(k, 0) for k in ("high", "medium", "low")})
print("\nTop countries:")
for r in c.execute("SELECT country_code, COUNT(*) n FROM article_places "
"WHERE country_code IS NOT NULL GROUP BY country_code ORDER BY n DESC LIMIT 15"):
print(f" {r['country_code']} {r['n']}")
print("\nTop US states:")
for r in c.execute("SELECT state_code, COUNT(*) n FROM article_places "
"WHERE state_code IS NOT NULL GROUP BY state_code ORDER BY n DESC LIMIT 15"):
print(f" {r['state_code']} {r['n']}")
# US "Near you" fuel: tagged articles with a US state attached
us_state = c.execute("SELECT COUNT(DISTINCT article_id) FROM article_places WHERE state_code IS NOT NULL").fetchone()[0]
print(f"\nArticles with a US state (US 'Near you' fuel): {us_state} ({pct(us_state)})")
# Normalization gap: breadth implies a place but none stored (name didn't map, or
# the model named no place). A proxy for where normalization/extraction is weak.
gap = c.execute(
"SELECT COUNT(*) FROM article_geo g WHERE g.breadth IN ('locality','regional','national','multinational') "
"AND NOT EXISTS (SELECT 1 FROM article_places p WHERE p.article_id=g.article_id)"
).fetchone()[0]
print(f"Normalization/extraction gaps (place-bearing breadth, 0 places stored): {gap} ({pct(gap)})")
print("\n--- low-confidence examples (up to 8) ---")
for r in c.execute(
"SELECT g.article_id, a.title, g.breadth, g.rationale FROM article_geo g "
"JOIN articles a ON a.id=g.article_id WHERE g.confidence='low' LIMIT 8"):
print(f" [{r['article_id']}] {r['breadth']} | {r['title'][:55]} | {r['rationale'] or ''}")
print("\n--- multi-place examples (up to 8) ---")
for r in c.execute(
"SELECT article_id, COUNT(*) n FROM article_places GROUP BY article_id HAVING n>1 ORDER BY n DESC LIMIT 8"):
a = c.execute("SELECT title FROM articles WHERE id=?", (r["article_id"],)).fetchone()
places = c.execute("SELECT country_code, state_code, locality FROM article_places WHERE article_id=?",
(r["article_id"],)).fetchall()
print(f" [{r['article_id']}] ({r['n']}) {a['title'][:45]} | {[dict(p) for p in places]}")
print(f"\n--- random spot-check ({args.spot}) ---")
for r in c.execute(
"SELECT g.article_id, a.title, g.breadth, g.confidence, g.rationale FROM article_geo g "
"JOIN articles a ON a.id=g.article_id ORDER BY RANDOM() LIMIT ?", (args.spot,)):
pl = c.execute("SELECT country_code, state_code, locality FROM article_places WHERE article_id=?",
(r["article_id"],)).fetchall()
tag = ", ".join(
f"{p['country_code'] or '?'}{('/'+p['state_code']) if p['state_code'] else ''}"
f"{(':'+p['locality']) if p['locality'] else ''}" for p in pl) or "(no place)"
print(f" [{r['article_id']}] {r['breadth']}/{r['confidence']} [{tag}] {r['title'][:50]}")
if r["rationale"]:
print(f" why: {r['rationale']}")
c.close()
if __name__ == "__main__":
main()

Some files were not shown because too many files have changed in this diff Show More