Compare commits

...

72 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
82 changed files with 5449 additions and 2190 deletions
+2
View File
@@ -10,3 +10,5 @@ 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.
Binary file not shown.
+18 -1
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
@@ -60,7 +76,8 @@ upbeatbytes.com {
# 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.
header @hidden X-Robots-Tag "noindex, nofollow"
@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/*
+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.
+13 -1
View File
@@ -11,9 +11,15 @@ 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 · Upbeat Bytes — calm daily games',
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.',
},
@@ -32,6 +38,12 @@ const PAGES = [
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) {
+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">
+28 -13
View File
@@ -1,10 +1,19 @@
<script>
// Shared editorial top bar for the hub (/home3) and its detail pages (/word, /quote,
// /onthisday). Full horizontal nav on wide screens; a hamburger + drop panel on phones
// so the bar stays clean. `active` highlights the current section.
let { active = '' } = $props();
// 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(() => {
@@ -14,18 +23,18 @@
return () => mq.removeEventListener('change', sync);
});
const LINKS = [
{ key: 'home', href: '/home3', label: 'Home' },
{ key: 'news', href: '/', label: 'News' },
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="/home3" aria-label="upbeatBytes home">
<a class="brand" href="/" aria-label="upbeatBytes home">
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
</a>
@@ -37,10 +46,14 @@
<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 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"
@@ -85,6 +98,8 @@
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 */
+3 -8
View File
@@ -3,6 +3,7 @@
// 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
@@ -14,7 +15,7 @@
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('/home3', { replaceState: true });
else goto('/', { replaceState: true });
}
</script>
@@ -33,7 +34,7 @@
{@render children?.()}
</main>
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</footer>
<Footer />
</div>
<style>
@@ -65,10 +66,4 @@
.back:hover { color: var(--teal); }
.back svg { transition: transform 0.15s ease; }
.back:hover svg { transform: translateX(-2px); }
.foot {
text-align: center; max-width: 1180px; width: 100%; margin: 14px auto 0; box-sizing: border-box;
padding: 20px clamp(18px, 5vw, 44px) 30px; font-size: 13px; color: var(--muted);
border-top: 1px solid var(--news-border);
}
</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
+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');
});
});
+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; }
+81 -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) {
@@ -495,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('');
@@ -675,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>
@@ -718,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'}
@@ -995,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)}
@@ -1041,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>
@@ -1879,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. */
+144 -20
View File
@@ -3,6 +3,7 @@
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 = [
@@ -26,9 +27,60 @@
// screens; desktop and portrait art stay upright). Aspect is read off the loaded image.
let landscape = $state(false);
function onKey(e) {
if (e.key === 'Escape' && zoom) zoom = 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(); });
@@ -38,7 +90,7 @@
afterNavigate(({ from }) => { if (from) cameFromApp = true; });
function goBack() {
if (cameFromApp && typeof history !== 'undefined') history.back();
else goto('/home3', { replaceState: true });
else goto('/', { replaceState: true });
}
// Woods are built from four real mitered rails (grain turns at the corners); metals/none aren't.
@@ -213,19 +265,48 @@
{/if}
</main>
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</footer>
<Footer />
</div>
{#if zoom && art}
<button class="lightbox" class:rotate={landscape} bind:this={lightboxEl} onclick={() => (zoom = false)} aria-label="Close artwork">
<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>
<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>
<span class="lb-cap">{art.title}{#if art.artist}<span class="sep">·</span>{art.artist}{/if}</span>
</span>
</button>
{/if}
</div>
{/if}
<style>
@@ -489,26 +570,69 @@
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
.foot {
text-align: center; color: var(--muted); font-size: 0.84rem;
padding: 28px 16px 36px; border-top: 1px solid var(--line); margin-top: 24px;
}
.lightbox {
position: fixed; inset: 0; z-index: 50; border: none; cursor: zoom-out;
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 { display: flex; flex-direction: column; align-items: center; gap: 12px; }
.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 {
cursor: zoom-out; max-width: 92vw;
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));
}
+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>
-143
View File
@@ -1,143 +0,0 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import { ROOMS } from '$lib/rooms.js';
import RoomCard from '$lib/components/RoomCard.svelte';
// Hidden prototype of the new homepage hub (/home2). Iterate the look here, then
// promote to / and remove this clone — same approach we used for /art.
let artImg = $state(null);
let newsHeadline = $state('');
let newsImg = $state(null);
onMount(async () => {
try { artImg = (await getJSON('/api/art/today'))?.image_url ?? null; } catch { /* card falls back to blurb */ }
// Respect the reader's saved Closer-to-Home filter so the headline matches their Brief.
let homeq = '';
try {
const hv = localStorage.getItem('goodnews:home') || '';
const hs = localStorage.getItem('goodnews:homeScope') || 'nearby';
if (hv && hs !== 'world') homeq = `&home=${encodeURIComponent(hv)}&scope=${hs}`;
} catch { /* default global brief */ }
try {
const it = (await getJSON(`/api/brief?limit=1${homeq}`))?.items?.[0];
newsHeadline = it?.title ?? '';
newsImg = it?.image_url ?? null;
} catch { /* card falls back to blurb */ }
});
</script>
<svelte:head>
<title>upbeatBytes — a calmer corner of the internet</title>
<meta name="robots" content="noindex" />
<meta name="description" content="A calmer corner of the internet: good news, daily art, small games, and little resets." />
</svelte:head>
<div class="room">
<header class="bar">
<a class="brand" href="/home2" aria-label="upbeatBytes home">
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
</a>
<nav class="nav">
<a href="/">News</a>
<a href="/play">Games</a>
<a href="/art">Art</a>
<a class="acct" href="/account" aria-label="Your account">
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="8" r="3.3" />
<path d="M5.5 19.2a6.5 6.5 0 0 1 13 0" />
</svg>
</a>
</nav>
</header>
<main class="hub">
<div class="intro">
<h1>A calmer corner of the internet.</h1>
<p>Good news, daily art, small games, and little resets.</p>
</div>
<div class="grid">
{#each ROOMS as r (r.id)}
<RoomCard room={r} {artImg} {newsImg} {newsHeadline} />
{/each}
</div>
</main>
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</footer>
</div>
<style>
/* Self-hosted modern sans (OFL) — privacy-friendly, no Google hotlink. */
@font-face {
font-family: 'Manrope';
src: url('/fonts/manrope-var.woff2') format('woff2');
font-weight: 200 800; font-style: normal; font-display: swap;
}
.room {
--canvas: #faf6ee; --surface: #ffffff; --ink: #232a31; --muted: #707b86;
--line: #ece5d8; --accent: #0a93c0; --accent-deep: #066c8e;
--sans: 'Manrope', ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
min-height: 100vh; background: var(--canvas); color: var(--ink);
font-family: var(--sans);
display: flex; flex-direction: column;
}
/* Bigger, more present top bar — logo + lighter, modern nav labels. */
.bar {
display: flex; align-items: center; justify-content: space-between;
padding: 20px clamp(18px, 5vw, 64px);
max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box;
}
.brand { display: block; line-height: 0; }
.brand img { height: 58px; width: auto; display: block; }
.nav { display: flex; align-items: center; gap: clamp(18px, 3vw, 40px); }
.nav a {
font-family: var(--sans); color: #4a525c; text-decoration: none;
font-weight: 500; font-size: 1.1rem; letter-spacing: 0.01em;
transition: color 0.15s ease;
}
.nav a:hover { color: var(--accent); }
.acct {
display: inline-flex; align-items: center; justify-content: center;
width: 44px; height: 44px; border-radius: 50%; color: var(--muted);
}
.acct:hover { color: var(--accent); background: #eef6f9; }
.hub {
flex: 1; width: 100%; max-width: 1180px; margin: 0 auto; box-sizing: border-box;
padding: clamp(12px, 2.4vw, 30px) clamp(18px, 5vw, 64px) clamp(40px, 6vw, 72px);
}
.intro { text-align: center; margin: clamp(6px, 1.2vw, 14px) 0 clamp(40px, 6vw, 64px); }
.intro h1 {
font-family: Georgia, "Iowan Old Style", "Times New Roman", serif;
font-size: clamp(1.9rem, 4.5vw, 3rem); margin: 0; letter-spacing: -0.015em; line-height: 1.08;
}
.intro p { color: var(--muted); margin: 12px 0 0; font-size: clamp(1rem, 2vw, 1.2rem); }
.grid {
display: grid; gap: clamp(14px, 1.8vw, 22px);
grid-template-columns: repeat(4, 1fr); grid-auto-flow: dense;
}
/* size variants → grid spans (RoomCard owns min-heights) */
:global(.grid .card--large) { grid-column: span 2; grid-row: span 2; }
:global(.grid .card--wide) { grid-column: span 2; }
:global(.grid .card--tall) { grid-row: span 2; }
@media (max-width: 860px) {
.grid { grid-template-columns: repeat(2, 1fr); }
:global(.grid .card--large), :global(.grid .card--wide) { grid-column: span 2; }
:global(.grid .card--large), :global(.grid .card--tall) { grid-row: auto; }
}
@media (max-width: 540px) {
.grid { grid-template-columns: 1fr; }
:global(.grid .card--large), :global(.grid .card--wide), :global(.grid .card--tall) { grid-column: span 1; }
}
.foot {
text-align: center; color: var(--muted); font-size: 0.86rem;
padding: 28px 16px 40px; border-top: 1px solid var(--line); margin-top: auto;
}
</style>
-546
View File
@@ -1,546 +0,0 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import HubBar from '$lib/components/HubBar.svelte';
// /home3 — the Claude Design "Frame A" direction (editorial, with colour), rebuilt in
// our codebase with our real logo + self-hosted fonts, wired to live data. Hidden
// prototype (noindex), alongside /home2 so we can compare.
let news = $state(null); // {id, title, summary, image}
let art = $state(null); // {title, artist, year, image}
let newsFit = $state('cover'); // 'cover' = full-bleed photo; 'contain' = framed-plate figure
let word = $state(null); // /api/word/today
let quote = $state(null); // /api/quote/today
let fact = $state(null); // /api/onthisday/today
// small-joys display helpers
const POS = { noun: 'n.', adjective: 'adj.', verb: 'v.', adverb: 'adv.', pronoun: 'pron.',
preposition: 'prep.', conjunction: 'conj.', interjection: 'interj.' };
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : '');
const clip = (s, n) => {
if (!s || s.length <= n) return s || '';
const cut = s.slice(0, n), i = cut.lastIndexOf(' ');
return (i > 0 ? cut.slice(0, i) : cut).replace(/[\s,;:.]+$/, '') + '…';
};
// truncation handled by CSS (-webkit-line-clamp:2) — breaks on whole words, fills 2 full lines
let headline = $derived(news?.title ?? 'What went right this week: the good news that actually matters');
// Sell the value proposition right on the card: the quick UB brief vs. the full article.
// "1 min brief · ~10 min full story" (both known)
// "1 min brief" (full-story time unknown — never fake it)
// hidden (no brief/summary yet)
const briefMins = (t) => Math.max(1, Math.round((t || '').trim().split(/\s+/).filter(Boolean).length / 200));
let readBadge = $derived.by(() => {
if (!news?.summary) return '';
const brief = `${briefMins(news.summary)} min brief`;
return news.source_read_minutes ? `${brief} · ~${news.source_read_minutes} min full story` : brief;
});
// small-joys shelf: a single-row, swipeable rail (all 3 cards live in the row; the next
// peeks). Arrows scroll it; the counter/dots track the snapped card from scroll position.
// Phones get a swipeable rail (all 3 cards in a scroll row, the next peeking). Wider
// screens keep the original two-up view with the arrows rotating which two show — a
// rail there felt like a broken carousel. isNarrow drives BOTH the DOM and the layout
// so they never disagree (no "3 cards wrapping in a 2-col grid").
let railEl = $state(null);
let joyIdx = $state(0);
let isNarrow = $state(false);
$effect(() => {
const mq = window.matchMedia('(max-width: 520px)');
const sync = (e) => { isNarrow = e.matches; };
sync(mq);
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
});
function railTo(i) {
joyIdx = Math.max(0, Math.min(2, i));
const card = railEl?.children?.[joyIdx];
if (card) railEl.scrollTo({ left: card.offsetLeft, behavior: 'smooth' });
}
let scrollRAF = 0;
function onRailScroll() {
if (!railEl) return;
cancelAnimationFrame(scrollRAF);
scrollRAF = requestAnimationFrame(() => {
const x = railEl.scrollLeft;
let best = 0, bestD = Infinity;
for (let i = 0; i < railEl.children.length; i++) {
const d = Math.abs(railEl.children[i].offsetLeft - x);
if (d < bestD) { bestD = d; best = i; }
}
joyIdx = best;
});
}
// desktop = rotate which two are shown; mobile = scroll the rail
const prevJoy = () => (isNarrow ? railTo(joyIdx - 1) : (joyIdx = (joyIdx + 2) % 3));
const nextJoy = () => (isNarrow ? railTo(joyIdx + 1) : (joyIdx = (joyIdx + 1) % 3));
onMount(async () => {
try {
const a = await getJSON('/api/art/today');
if (a) art = { title: a.title, artist: a.artist, year: a.date_text, image: a.image_url };
} catch { /* fall back to gradient swatch */ }
let homeq = '';
try {
const hv = localStorage.getItem('goodnews:home') || '';
const hs = localStorage.getItem('goodnews:homeScope') || 'nearby';
if (hv && hs !== 'world') homeq = `&home=${encodeURIComponent(hv)}&scope=${hs}`;
} catch { /* global brief */ }
try {
const it = (await getJSON(`/api/brief?limit=1${homeq}`))?.items?.[0];
if (it) news = { id: it.id, title: it.title, summary: it.summary || it.description || '', image: it.image_url || null };
// Photos display full (cover); only wide/tall figures (diagrams) get the framed plate.
if (news?.image) {
const probe = new Image();
probe.onload = () => {
const a = probe.naturalWidth / probe.naturalHeight;
newsFit = a >= 0.85 && a <= 1.9 ? 'cover' : 'contain';
};
probe.src = news.image;
}
} catch { /* fall back to design copy */ }
// small joys (each falls back to its placeholder if the engine has nothing yet)
try { word = await getJSON('/api/word/today'); } catch { /* placeholder */ }
try { quote = await getJSON('/api/quote/today'); } catch { /* placeholder */ }
try { fact = await getJSON('/api/onthisday/today'); } catch { /* placeholder */ }
});
</script>
<svelte:head>
<title>upbeatBytes — a calmer, brighter corner of the internet</title>
<meta name="robots" content="noindex" />
<meta name="description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
</svelte:head>
{#snippet joyCard(i)}
{#if i === 0}
<a class="joy joy-word" href="/word">
<span class="wm" aria-hidden="true">{word ? cap(word.word)[0] : 'S'}</span>
<div class="joy-in">
<div class="tag"><span class="rule"></span><span class="tag-label">Word of the day</span></div>
<p class="word-line"><span class="word">{word ? cap(word.word) : 'Serene'}</span> <span class="word-pos">{word ? (POS[word.part_of_speech] ?? word.part_of_speech ?? '') : 'adj.'}</span></p>
<p class="word-pron">{word?.phonetic ?? '/səˈriːn/'}</p>
<p class="def">{word ? clip(word.definition, 78) : 'Calm, peaceful, and untroubled. The quiet after a storm passes.'}</p>
</div>
</a>
{:else if i === 1}
<a class="joy joy-quote" href="/quote">
<span class="wm wm-q" aria-hidden="true"></span>
<div class="joy-in">
<div class="tag"><span class="rule"></span><span class="tag-label">Quote of the day</span></div>
<p class="quote">{quote ? clip(quote.text, 92) : 'Very little is needed to make a happy life.'}</p>
<div class="attrib"><span class="attrib-rule"></span><span class="attrib-by">{quote?.author ?? 'Marcus Aurelius'}</span></div>
</div>
</a>
{:else}
<a class="joy joy-fact" href="/onthisday">
<div class="joy-in">
<div class="tag"><span class="rule"></span><span class="tag-label">A good thing today</span></div>
<p class="fact-hero"><span class="year">{fact?.year ?? '1928'}</span> <span class="onthis">ON THIS DAY</span></p>
<p class="fact">{fact ? clip(fact.text, 96) : 'Penicillin was discovered by a happy accident.'}</p>
</div>
</a>
{/if}
{/snippet}
<div class="page">
<HubBar active="home" />
<section class="hero">
<h1>A <span class="t">calmer</span>, <span class="b">brighter</span> corner of the internet.</h1>
<p class="sub">Good news, daily art, small games, and little resets.</p>
</section>
<main class="bento">
<!-- Good News (tall) — a card with TWO links, so it's a div, not a single anchor -->
<div class="card news">
<a class="news-photo-a" href={news?.id ? `/a/${news.id}` : '/'} aria-label="Read this article">
<div class="news-photo {newsFit}">
<div class="news-plate" style={news?.image ? `background-image:url(${news.image})` : ''}></div>
</div>
</a>
<div class="news-body">
<span class="label" style="color:#0083ad">GOOD NEWS</span>
<a class="headline-a" href={news?.id ? `/a/${news.id}` : '/'}><h2>{headline}</h2></a>
<a class="summary-a" href={news?.id ? `/a/${news.id}` : '/'}>
<p class="summary">{news?.summary || "We read the week so you don't have to doomscroll it. Five quietly hopeful stories, summarised to the gist."}</p>
</a>
<div class="news-foot">
{#if readBadge}<span class="meta">{readBadge}</span>{/if}
</div>
<hr class="news-div" />
<a class="news-more" href="/">Read more good news →</a>
</div>
</div>
<div class="rightcol">
<!-- Daily Art (wide) -->
<a class="card art" href="/art">
<div class="art-body">
<span class="label" style="color:#8857C2">DAILY ART</span>
<h3>A masterwork a day</h3>
<p class="art-today">
{#if art}Today: <span class="ital">{art.title}</span>{#if art.artist}{art.artist}{/if}{#if art.year}, {art.year}{/if}.
{:else}Today: <span class="ital">Among the Sierra Nevada</span> — Bierstadt, 1868.{/if}
</p>
<span class="link art-link">View today</span>
</div>
<div class="art-swatch" style={art?.image ? `--art:url(${art.image})` : ''}></div>
</a>
<!-- bottom pair — vertically centered in the space beneath the pinned Art card -->
<div class="pair-wrap">
<div class="pair">
<a class="card play" href="/play">
<div class="play-top">
<span class="label" style="color:#A8650F">PLAY</span>
<h3>A little daily puzzle</h3>
</div>
<!-- "bleeding boards": three game motifs clipping at the card edges (decorative) -->
<div class="play-band" aria-hidden="true">
<div class="wb">
<div class="wb-row"><span class="wb-t wb-a">E</span><span class="wb-t wb-n">A</span><span class="wb-t wb-a">T</span></div>
<div class="wb-row"><span class="wb-t wb-g">Y</span><span class="wb-t wb-g">T</span><span class="wb-t wb-g">E</span></div>
<div class="wb-row"><span class="wb-t wb-e"></span><span class="wb-t wb-e"></span><span class="wb-t wb-d"></span></div>
</div>
<div class="ws">
<span>K</span><span>R</span><span>O</span><span>A</span><span>E</span><span>S</span>
<span class="hl">B</span><span class="hl">Y</span><span class="hl">T</span><span class="hl">E</span><span class="hl">S</span><span>W</span>
<span>T</span><span>I</span><span>M</span><span>U</span><span>H</span><span>P</span>
<span>G</span><span>E</span><span>B</span><span>O</span><span>R</span><span>L</span>
<span>F</span><span>I</span><span>N</span><span>D</span><span>C</span><span>A</span>
<span>Z</span><span>O</span><span>S</span><span>E</span><span>K</span><span>Y</span>
</div>
<div class="mm">
<span class="mm-a"></span>
<span class="mm-w"><span class="mm-dot" style="background:#6bbf8c"></span></span>
<span class="mm-a"></span>
<span class="mm-w"><span class="mm-dot" style="background:#6bbf8c"></span></span>
<span class="mm-a"></span>
<span class="mm-a"></span>
<span class="mm-a"></span>
<span class="mm-w"><span class="mm-dot" style="background:#D2861B"></span></span>
<span class="mm-a"></span>
</div>
</div>
<div class="play-foot"><span class="link play-link">Enter</span></div>
</a>
<div class="card moment">
<div class="moment-top">
<span class="label" style="color:#3F9A66">ENTERTAINMENT</span>
<span class="soon">SOON</span>
</div>
<div class="moment-mid">
<div class="ent-icons" aria-hidden="true">
<span class="ent-icon">
<svg viewBox="0 0 24 24"><circle cx="8" cy="9" r="3" fill="#e3a24c" /><circle cx="16" cy="9" r="3" fill="#5aa0c8" /><circle cx="12" cy="15.5" r="3" fill="#5bbf86" /></svg>
</span>
<span class="ent-icon">
<svg viewBox="0 0 24 24" fill="none" stroke="#3F9A66" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="9" /><path d="M8.5 14a4.5 4.5 0 0 0 7 0" /><circle cx="9" cy="10" r="1" fill="#3F9A66" stroke="none" /><circle cx="15" cy="10" r="1" fill="#3F9A66" stroke="none" /></svg>
</span>
<span class="ent-icon">
<svg viewBox="0 0 24 24" fill="#3F9A66"><path d="M12 3l1.6 7.4L21 12l-7.4 1.6L12 21l-1.6-7.4L3 12l7.4-1.6z" /></svg>
</span>
</div>
<p class="moment-line">A little something to enjoy.</p>
</div>
<span class="moment-meta">Coloring, characters, and curiosities. Coming soon.</span>
</div>
</div>
</div>
<!-- "small joys" — a single swipeable row; all three live in the rail, the next peeks -->
<div class="joys-shelf">
<div class="joys-head">
<span class="jt-label">Small joys for today</span>
<span class="jt-line"></span>
<span class="jt-count">{joyIdx + 1} / 3</span>
<div class="joys-arrows">
<button class="arrow" onclick={prevJoy} aria-label="Previous small joy"></button>
<button class="arrow" onclick={nextJoy} aria-label="Next small joy"></button>
</div>
</div>
<div class="joys" class:rail={isNarrow} bind:this={railEl} onscroll={onRailScroll}>
{#if isNarrow}
{@render joyCard(0)}
{@render joyCard(1)}
{@render joyCard(2)}
{:else}
{@render joyCard(joyIdx)}
{@render joyCard((joyIdx + 1) % 3)}
{/if}
</div>
</div>
</div>
</main>
<footer class="foot">upbeatBytes — no ads, no paywalls, no doomscrolling.</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; }
@font-face { font-family: 'Work Sans'; src: url('/fonts/work-sans-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; 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;
overflow-x: clip; /* seatbelt: a wide child (e.g. the joys rail) can never scroll the page */
}
.page :global(*) { box-sizing: border-box; }
/* Hero — spacing tuned per the /home2 pass: pulled up a touch, more air before cards */
.hero { text-align: center; max-width: 1180px; width: 100%; margin: 0 auto; padding: clamp(24px, 4vw, 34px) clamp(18px, 5vw, 44px) clamp(38px, 5vw, 48px); }
.hero h1 {
font-family: 'Newsreader', Georgia, serif; font-weight: 500;
font-size: clamp(2.1rem, 5vw, 50px); line-height: 1.04; letter-spacing: -0.015em; margin: 0; color: var(--ink);
}
.hero h1 .t { color: #0083ad; }
.hero h1 .b { color: #E0852C; }
.hero .sub { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: clamp(1rem, 2vw, 19px); color: #857b6c; margin: 14px 0 0; }
/* Bento grid. The cards use Work Sans (per CD's mockup — a touch bolder/darker than our
body Hanken); headings stay Newsreader (set on h2,h3 below). */
.bento {
max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box;
padding: 0 clamp(18px, 5vw, 44px) 16px;
display: grid; grid-template-columns: minmax(0, 1.18fr) minmax(0, 1.82fr); gap: 16px;
font-family: 'Work Sans', 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
}
/* Containment: the grid items + flex chain must be allowed to shrink below their content,
or the phone joys rail forces the whole page wider than the viewport. (NOT .bento — it
keeps its own max-width:1180px; listing it here would override that and go full-width.) */
.news, .rightcol, .pair-wrap, .joys-shelf, .joys { min-width: 0; max-width: 100%; }
/* right column matches the News height; Art stays pinned to the TOP and the Play/Moment
pair to the BOTTOM, with the extra space distributed BETWEEN them (FIX1). The cards
themselves keep their natural size and never stretch. */
.rightcol { display: flex; flex-direction: column; gap: 16px; }
.rightcol .art { flex: none; } /* Art pinned to the top */
.pair-wrap { flex: 1; display: flex; align-items: center; } /* fill the rest; pair vertically centered */
.card {
border-radius: 18px; overflow: hidden; text-decoration: none; color: inherit;
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
a.card:hover { transform: translateY(-2px); }
/* every card opens with the same "eyelash" — a short dash in the card's accent (via
currentColor, so each label's inline colour drives it) + a tracked uppercase label */
.label {
display: inline-flex; align-items: center; gap: 9px;
font-size: 11px; font-weight: 600; letter-spacing: 0.18em; line-height: 1;
}
.label::before { content: ''; width: 20px; height: 2px; border-radius: 2px; background: currentColor; flex: none; }
.link { font-size: 14px; font-weight: 600; padding-bottom: 2px; align-self: flex-start; }
/* card titles: Newsreader, a calmer medium weight (per CD's mockup) */
h2, h3 { font-family: 'Newsreader', Georgia, serif; font-weight: 500; letter-spacing: -0.01em; color: var(--ink); }
/* Good News — photo on top, equal height to the right column. The gist flex-fills the
card (basis:0 so it never drives the row height → the right column always sets it, never
stretched) and fades softly into a comfortable margin above the read-time. */
.news {
background: #fff; border: 1px solid var(--news-border);
display: flex; flex-direction: column; box-shadow: 0 6px 20px -14px rgba(0, 131, 173, 0.4);
}
/* photo + headline both link to the article (clickable, not just the text links) */
.news-photo-a { display: block; }
.news-photo-a:hover { filter: brightness(0.97); }
.headline-a { display: block; text-decoration: none; color: inherit; }
.headline-a:hover h2 { color: var(--teal); }
/* Photos fill edge-to-edge (cover, no box). Only figures/diagrams (detected by their
wide/tall shape) get the soft tinted matte + white framed plate, so labels stay whole. */
.news-photo { aspect-ratio: 5/4; }
.news-plate { background-position: center; background-repeat: no-repeat; }
.news-photo.cover .news-plate { width: 100%; height: 100%; background-size: cover; }
.news-photo.contain {
/* silvery at top, fading down into the card's white so the matte isn't a hard band */
background: linear-gradient(180deg, #e6edef 0%, #f3f6f5 55%, #ffffff 100%);
display: flex; align-items: center; justify-content: center; padding: 20px;
}
.news-photo.contain .news-plate {
width: 100%; height: 100%; box-sizing: border-box; padding: 12px;
background-color: #fff; border: 1px solid #e7edee; border-radius: 8px;
box-shadow: 0 6px 18px -10px rgba(30, 60, 70, 0.28);
background-size: contain; background-origin: content-box;
}
.news-body { padding: 24px 26px; flex: 1; display: flex; flex-direction: column; }
.news h2 {
font-size: clamp(1.55rem, 2.6vw, 30px); line-height: 1.14; margin: 12px 0 0;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; overflow: hidden;
}
/* Desktop: the gist grows to fill the card (basis:0 so it never inflates the row height),
softly fading out so it never jams against the read-time — a comfortable margin either way. */
.summary-a {
flex: 1 1 0; min-height: 0; overflow: hidden; display: block; text-decoration: none; color: inherit;
-webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5em), transparent);
mask-image: linear-gradient(to bottom, #000 calc(100% - 1.5em), transparent);
}
.summary { font-size: 15px; line-height: 1.5; color: #5a5346; margin: 12px 0 0; }
.news-foot { display: flex; align-items: center; justify-content: flex-end; padding-top: 16px; }
.meta { font-size: 12px; color: var(--muted); line-height: 1.35; overflow-wrap: anywhere; }
/* divider sets the secondary "feed" link apart as its own thing */
.news-div { border: none; border-top: 1px solid #e6d9bf; margin: 14px 0 12px; }
.news-more { display: inline-block; font-size: 13px; font-weight: 600; color: var(--teal); text-decoration: none; }
.news-more:hover { text-decoration: underline; }
.news-more:hover { color: var(--teal); }
/* Daily Art — wide, text left + artwork swatch right */
.art { background: #F3EEF9; border: 1px solid #e4d8f1; display: flex; min-height: 188px; }
.art-body { flex: 1; padding: 24px 26px; display: flex; flex-direction: column; }
.art h3 { font-size: clamp(1.35rem, 2.1vw, 25px); line-height: 1.16; margin: 10px 0 0; color: #2a1c3d; }
.art-today { font-size: 14.5px; line-height: 1.5; color: #6f6280; margin: 11px 0 0; }
.ital { font-style: italic; font-family: 'Newsreader', Georgia, serif; }
/* a little breathing room before the link (per CD), not pinned tight under the caption */
.art-link { margin-top: 20px; color: #8857C2; border-bottom: 2px solid #c9aef0; }
/* swatch crops a few px off every edge (::after inset) so scanned paintings don't show
their ragged/black canvas edge at the top */
.art-swatch {
width: 46%; min-width: 130px; position: relative; overflow: hidden;
background: linear-gradient(170deg, #bfe0f0 0%, #a9cf9a 50%, #d89a4e 100%);
}
.art-swatch::after {
content: ""; position: absolute; inset: -6px;
background-image: var(--art); background-size: cover; background-position: center;
}
/* bottom pair */
.pair { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.play { background: #FFF3DC; border: 1px solid #f6e2b8; display: flex; flex-direction: column; }
.play-top { padding: 22px 24px 0; }
.play h3 { font-size: clamp(1.25rem, 1.9vw, 23px); margin: 14px 0 0; color: #5c3d0c; }
.play-foot { margin-top: auto; padding: 16px 24px 22px; }
.play-link { color: #A8650F; border-bottom: 2px solid #e0a94f; }
/* "bleeding boards" — three game motifs clipping at the card edges (decorative) */
/* Word search is the centred highlight; the two side games are the SAME size and each
bleeds ~half a column off its edge (consistent both sides) to imply "more under the hood". */
.play-band { position: relative; height: 124px; margin-top: 24px; overflow: hidden; }
.wb { position: absolute; top: 50%; left: -12px; transform: translateY(-50%); display: flex; flex-direction: column; gap: 4px; }
.wb-row { display: flex; gap: 4px; }
.wb-t { width: 24px; height: 24px; border-radius: 5px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 11px; color: #fff; }
.wb-g { background: #6bbf8c; } .wb-a { background: #E6A02C; } .wb-n { background: #d9c39a; }
.wb-e { background: #fff; border: 1.5px solid #ecca84; } .wb-d { background: #fff; border: 1.5px dashed #e0bb6f; }
.ws {
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
display: grid; grid-template-columns: repeat(6, 14px); gap: 3px;
font-weight: 600; font-size: 10.5px; line-height: 14px; color: #d4b576; text-align: center;
background: #fff; border: 1.5px solid #f0d597; border-radius: 10px; padding: 9px;
box-shadow: 0 5px 16px -8px rgba(210, 134, 27, 0.55);
}
.ws .hl { color: #B5701A; font-weight: 800; }
.mm { position: absolute; top: 50%; right: -12px; transform: translateY(-50%); display: grid; grid-template-columns: repeat(3, 24px); grid-auto-rows: 24px; gap: 4px; }
.mm > span { border-radius: 6px; }
.mm-a { background: #E6A02C; }
.mm-w { background: #fff; border: 1.5px solid #f0d597; display: flex; align-items: center; justify-content: center; }
.mm-dot { width: 7px; height: 7px; border-radius: 50%; display: block; }
.moment { background: #E6F3E9; border: 1px solid #cee6d3; padding: 22px 24px; display: flex; flex-direction: column; }
.moment-top { display: flex; align-items: center; justify-content: space-between; }
.soon { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; color: #3F9A66; background: #fff; border-radius: 999px; padding: 3px 8px; }
/* centered motif: three small enjoy-icons + tagline fill the middle, caption at the foot */
.moment-mid { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; text-align: center; }
.ent-icons { display: flex; gap: 10px; }
.ent-icon { width: 46px; height: 46px; border-radius: 13px; background: #fff; border: 1px solid #d3e4d8; box-shadow: 0 4px 12px -8px rgba(40, 90, 60, 0.4); display: flex; align-items: center; justify-content: center; }
.ent-icon svg { width: 24px; height: 24px; display: block; }
.moment-line { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 18px; line-height: 1.3; color: #214a35; margin: 0; }
.moment-meta { margin-top: 14px; font-size: 13px; color: #6f9683; text-align: center; }
/* "small joys" rail — little jewels: one big focal point per card, a faint oversized
watermark glyph, an accent-tag label, soft diagonal gradient + long low shadow. */
.joys-shelf { flex: none; }
/* header: italic title · hairline rule · counter · arrows (the gesture is genuinely
horizontal now, so the 1/3 + affordance is honest) */
.joys-head { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
.jt-label { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 18px; color: #3a342b; }
.jt-line { flex: 1; height: 1px; background: #e6dcc8; }
.jt-count { font-size: 12px; color: #b0a690; white-space: nowrap; }
.joys-arrows { display: flex; gap: 8px; }
.arrow {
width: 30px; height: 30px; border-radius: 50%; border: 1px solid #e0d3b8; background: transparent;
color: #b09a6e; font-size: 14px; cursor: pointer; display: flex; align-items: center; justify-content: center;
padding: 0; line-height: 1; transition: background 0.15s ease, color 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.arrow:hover { background: #fff; color: #9a7b3e; }
/* Desktop/tablet: the original two-up view (arrows rotate which two show). */
.joys { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
.joy {
min-width: 0; position: relative; overflow: hidden; border-radius: 20px; padding: 18px 22px;
min-height: 170px; box-sizing: border-box; display: block; text-decoration: none; color: inherit;
transition: transform 0.16s ease, box-shadow 0.16s ease;
}
.joy:hover { transform: translateY(-2px); }
/* Phone: a single swipeable row; the next card peeks. Scrollbar hidden; snap per card. */
.joys.rail {
display: flex; gap: 16px; overflow-x: auto; scroll-snap-type: x mandatory;
padding-bottom: 6px; scrollbar-width: none; -webkit-overflow-scrolling: touch;
}
.joys.rail::-webkit-scrollbar { display: none; }
.joys.rail .joy { flex: 0 0 min(86%, 344px); scroll-snap-align: start; }
.joy-in { position: relative; } /* content sits above the watermark */
.wm { position: absolute; font-family: 'Newsreader', Georgia, serif; line-height: 1; pointer-events: none; }
/* a fresh trio, distinct from the doors above (teal/plum/amber/green): sky · rose · clay */
.joy-word { background: linear-gradient(165deg, #EAF2F9, #DBE8F4); border: 1px solid #d2e1f0; box-shadow: 0 10px 30px -22px rgba(60, 100, 145, 0.55); --accent: #4f7da8; --rule: #4f7da8; }
.joy-word .wm { right: -14px; bottom: -30px; font-size: 150px; font-weight: 400; color: rgba(79, 125, 168, 0.13); }
.joy-quote { background: linear-gradient(165deg, #F9EDF1, #F1DEE6); border: 1px solid #eed6df; box-shadow: 0 10px 30px -22px rgba(150, 85, 115, 0.5); --accent: #b06a86; --rule: #b06a86; }
.joy-quote .wm { left: 14px; top: -26px; font-size: 120px; color: rgba(176, 106, 134, 0.16); }
.joy-fact { background: linear-gradient(165deg, #F7EAE1, #EFDACB); border: 1px solid #ecd5c4; box-shadow: 0 10px 30px -22px rgba(150, 90, 55, 0.5); --accent: #b06a45; --rule: #b06a45; }
.tag { display: flex; align-items: center; gap: 8px; }
.tag .rule { width: 18px; height: 2px; border-radius: 2px; background: var(--rule); }
.tag-label { font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--accent); }
.word-line { margin: 8px 0 0; display: flex; align-items: baseline; gap: 9px; }
.joy .word { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: 32px; line-height: 1; letter-spacing: -0.01em; color: #2c3a48; }
.word-pos { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 13px; color: #7d93a8; }
.word-pron { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 13px; color: #5f7791; margin: 2px 0 0; }
.joy .def { font-size: 13.5px; color: #45535d; margin: 8px 0 0; line-height: 1.45; }
.joy .quote { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 21px; line-height: 1.3; color: #3e2c36; margin: 16px 0 0; }
.attrib { display: flex; align-items: center; gap: 9px; margin-top: 12px; }
.attrib-rule { width: 22px; height: 1px; background: #d8afc1; }
.attrib-by { font-family: 'Newsreader', Georgia, serif; font-size: 13px; color: #97667f; }
.fact-hero { display: flex; align-items: baseline; gap: 8px; margin: 12px 0 0; }
.year { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: 30px; color: #7a4a30; line-height: 0.9; }
.onthis { font-size: 11px; color: #9e7a64; letter-spacing: 0.04em; }
.joy .fact { font-family: 'Newsreader', Georgia, serif; font-size: 16px; color: #5e4636; margin: 8px 0 0; line-height: 1.34; }
.foot {
text-align: center; max-width: 1180px; width: 100%; margin: 14px auto 0; box-sizing: border-box;
padding: 20px clamp(18px, 5vw, 44px) 30px; font-size: 13px; color: var(--muted);
border-top: 1px solid var(--news-border);
}
/* responsive — collapse the bento on narrow screens */
@media (max-width: 860px) {
.bento { grid-template-columns: minmax(0, 1fr); } /* minmax(0,…) so a wide child can't widen the page */
.news { grid-row: auto; }
/* phone: a short, clean 3-line taste (no fill/fade — the card is natural height here) */
.summary-a { flex: none; -webkit-mask-image: none; mask-image: none; }
.summary { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; line-clamp: 3; overflow: hidden; }
.news-foot { padding-top: 12px; } /* snug under the text, not a line's gap */
}
@media (max-width: 520px) {
/* Art becomes an image-first card: the painting on top in a proper landscape frame
(aspect-ratio, not a stubby fixed-height band that crop the work to a sliver),
caption beneath. */
.art { flex-direction: column; min-height: 0; }
.art-swatch { width: 100%; min-width: 0; order: -1; aspect-ratio: 3 / 2; }
.pair { grid-template-columns: 1fr; }
/* Entertainment: when stacked it loses the height it borrowed from Play on desktop,
so the content felt crowded. Give it room to breathe (not as tall as desktop). */
.moment { padding: 30px 24px; gap: 6px; }
.moment-mid { gap: 18px; padding: 12px 0; }
.moment-meta { margin-top: 18px; }
}
</style>
+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" />
+127 -47
View File
@@ -8,10 +8,30 @@
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
function dayLabel(dateStr) {
if (!dateStr) return '';
const [, m, d] = dateStr.split('-').map(Number);
return `${MONTHS[m - 1]} ${d}`;
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 () => {
@@ -32,25 +52,49 @@
<HubShell active="">
<article class="otd-page">
{#if state === 'ready'}
<p class="eyebrow">A good thing today</p>
<h1 class="date">{dayLabel(f.date)}</h1>
<!-- 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="event">
<p class="year-line"><span class="year">{f.year}</span><span class="ago">on this day in history</span></p>
<p class="fact">{f.text}</p>
<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>
{#if f.image_url}
<div class="photo" style="background-image:url({f.image_url})"></div>
{/if}
{#if f.summary}
<p class="summary">{f.summary}</p>
{/if}
{#if f.source_url}
<a class="source" href={f.source_url} target="_blank" rel="noopener">Read more on Wikipedia →</a>
{/if}
{:else if state === 'empty'}
<p class="note">Today's moment in history is on its way. Check back soon.</p>
{:else}
@@ -60,40 +104,76 @@
</HubShell>
<style>
.otd-page { max-width: 680px; margin: 0 auto; text-align: center; }
.eyebrow {
font-size: 12px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
color: #3f9a66; margin: clamp(8px, 3vw, 28px) 0 0;
@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);
}
.date {
font-family: 'Newsreader', Georgia, serif; font-weight: 500; letter-spacing: -0.015em;
font-size: clamp(2.4rem, 7vw, 4rem); line-height: 1; margin: 12px 0 0; color: var(--ink);
}
.event { margin-top: clamp(22px, 4vw, 34px); }
.year-line { display: flex; align-items: baseline; justify-content: center; gap: 12px; margin: 0; }
.year { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: clamp(1.8rem, 4vw, 2.6rem); color: #1e5b3b; line-height: 1; }
.ago { font-size: 0.9rem; letter-spacing: 0.04em; color: #6f9683; text-transform: uppercase; }
.fact {
font-family: 'Newsreader', Georgia, serif; font-size: clamp(1.3rem, 3vw, 1.7rem); line-height: 1.34;
color: #214a35; margin: 14px 0 0;
.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;
}
.photo {
margin: clamp(26px, 5vw, 40px) auto 0; max-width: 460px; aspect-ratio: 3/2;
background-size: cover; background-position: center; border-radius: 16px;
border: 1px solid #d8e6da; box-shadow: 0 10px 30px -16px rgba(40, 120, 75, 0.4);
/* 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;
}
.summary {
margin: clamp(26px, 5vw, 38px) auto 0; max-width: 600px; text-align: left;
font-size: 1.05rem; line-height: 1.65; color: #4a5a50;
.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;
}
.source {
display: inline-block; margin-top: clamp(24px, 4vw, 34px);
font-size: 0.95rem; font-weight: 600; color: #3f9a66; text-decoration: none;
.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;
}
.source:hover { text-decoration: underline; }
.cta:hover { background: #854c2f; }
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
</style>
+79 -38
View File
@@ -4,6 +4,7 @@
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 { initPrefs } from '$lib/prefs.svelte.js';
import { auth } from '$lib/auth.svelte.js';
@@ -41,6 +42,11 @@
let wordStatus = $state({ 5: null, 6: null });
let wsStatus = $state(null);
// 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 {
const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null');
@@ -205,7 +211,7 @@
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('/home3', { replaceState: true });
else goto('/', { replaceState: true });
}
function openGame(g) { appNavDepth++; goto('/play?game=' + g); }
function pick(v) { appNavDepth++; goto('/play?game=' + game + '&v=' + v); }
@@ -265,33 +271,41 @@
<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>
<HubBar active="games" />
<main class="container page" class:gameview={view === 'play'}>
<!-- Back row under HubBar, consistent with the other hub pages. In a game it steps back
(selection / hub); on the games landing it leaves Play (to where you came from). -->
<button class="gameback" onclick={view === 'hub' ? leavePlay : 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 === 'hub' ? 'Back' : (view === 'play' ? 'Game Selection' : 'Play Hub')}
</button>
<!-- 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'}
<div class="arcade-head">
<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 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>
<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>
</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', '', '', '', '', '', ''] as ch}
{#each ['B', 'Y', 'T', 'E', '', 'C', 'A', 'L', 'M', ''] as ch}
<span class="wt" class:on={ch}>{ch}</span>
{/each}
</span>
@@ -303,10 +317,7 @@
<button class="tile tile-ws" onclick={() => openGame('wordsearch')}>
<span class="motif m-ws" aria-hidden="true">
<span class="wrow">G A M E S</span>
<span class="wrow hl">B Y T E S</span>
<span class="wrow">W O R D S</span>
<span class="wrow">S O L V E</span>
{#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>
@@ -317,9 +328,11 @@
{#if !bloomBlocked}
<button class="tile tile-bloom" onclick={() => openGame('bloom')}>
<span class="motif m-bloom" aria-hidden="true">
<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 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>
@@ -345,7 +358,20 @@
<!-- teased, not built yet -->
<div class="tile tile-bubble soon">
<span class="motif m-bubble" aria-hidden="true">
{#each ['a', 'b', 'c', 'd', 'a', 'd', 'c', 'b', 'a', 'c', 'b', 'd'] as k}<span class="bub b-{k}"></span>{/each}
<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">
@@ -439,6 +465,8 @@
{/if}
</main>
<Footer />
<style>
/* 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;
@@ -472,7 +500,9 @@
@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; }
.arcade-head { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin: 2px 0 24px; }
.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; }
@@ -489,6 +519,7 @@
.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); }
@@ -507,26 +538,36 @@
/* tile motifs — decorative; clipped by the tile's overflow:hidden */
.motif { position: absolute; inset: 0; pointer-events: none; }
.m-word { padding: 22px 22px 0; display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; align-content: start; }
.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: 24px 22px 0; display: flex; flex-direction: column; gap: 6px; font-weight: 700; font-size: 15px; letter-spacing: 0.26em; }
.wrow { display: flex; justify-content: center; color: rgba(255, 255, 255, 0.42); padding: 4px 0; }
.wrow.hl { background: rgba(255, 255, 255, 0.93); color: #2f7fb0; border-radius: 9px; box-shadow: 0 2px 9px rgba(0, 0, 0, 0.12); }
.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: 40%; transform: translate(-50%, -50%); }
.m-bloom .p1 { left: 50%; top: 40%; transform: translate(-50%, -50%) translate(0, -56px); }
.m-bloom .p2 { left: 50%; top: 40%; transform: translate(-50%, -50%) translate(48px, -28px); }
.m-bloom .p3 { left: 50%; top: 40%; transform: translate(-50%, -50%) translate(48px, 28px); }
.m-bloom .p4 { left: 50%; top: 40%; transform: translate(-50%, -50%) translate(0, 56px); }
.m-bloom .p5 { left: 50%; top: 40%; transform: translate(-50%, -50%) translate(-48px, 28px); }
.m-bloom .p6 { left: 50%; top: 40%; transform: translate(-50%, -50%) translate(-48px, -28px); }
.m-match { padding: 24px 24px 56px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 9px; align-content: start; }
.mm { aspect-ratio: 1; border-radius: 9px; background: rgba(255, 255, 255, 0.2); display: flex; align-items: center; justify-content: center; }
.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; }
.m-bubble { padding: 20px; display: flex; flex-wrap: wrap; gap: 8px; align-content: flex-start; }
.bub { width: 38px; height: 38px; border-radius: 50%; box-shadow: 0 3px 8px rgba(0, 0, 0, 0.14); }
/* 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%); }
+141 -36
View File
@@ -6,6 +6,34 @@
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');
@@ -24,22 +52,52 @@
<HubShell active="">
<article class="quote-page">
{#if state === 'ready'}
<p class="eyebrow">Quote of the day</p>
<blockquote class="quote">
<span class="glyph" aria-hidden="true"></span>
<p class="text">{q.text}</p>
<footer class="attrib">
<span class="dash"></span>
<span class="by">{q.author}{#if q.work}, <span class="work">{q.work}</span>{/if}</span>
</footer>
</blockquote>
<!-- 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>
{#if q.meaning}
<section class="meaning">
<h2>What it means</h2>
<p>{q.meaning}</p>
</section>
{/if}
<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}
@@ -49,35 +107,82 @@
</HubShell>
<style>
.quote-page { max-width: 720px; margin: 0 auto; }
.eyebrow {
font-size: 12px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase;
color: #b06a86; margin: clamp(8px, 3vw, 28px) 0 0; text-align: center;
@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);
}
.quote { margin: clamp(18px, 4vw, 34px) 0 0; position: relative; text-align: center; }
.glyph {
font-family: 'Newsreader', Georgia, serif; font-size: clamp(4rem, 11vw, 7rem); line-height: 0.6;
color: rgba(176, 106, 134, 0.22); display: block; height: clamp(2rem, 5vw, 3rem);
.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;
}
.quote .text {
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-weight: 500;
font-size: clamp(1.6rem, 4.5vw, 2.6rem); line-height: 1.28; letter-spacing: -0.01em;
color: #2f2240; margin: 8px 0 0;
/* 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;
}
.attrib { display: flex; align-items: center; justify-content: center; gap: 12px; margin-top: clamp(22px, 4vw, 32px); }
.dash { width: 30px; height: 1px; background: #c9a6ba; }
.by { font-family: 'Newsreader', Georgia, serif; font-size: 1.1rem; color: #7c64a0; }
/* 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 {
margin: clamp(36px, 6vw, 56px) auto 0; max-width: 600px;
border-top: 1px solid #ecdce4; padding-top: 26px;
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;
}
.meaning h2 {
font-size: 12px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase;
color: var(--muted); margin: 0 0 12px; text-align: center;
.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;
}
.meaning p { font-size: clamp(1.05rem, 2vw, 1.2rem); line-height: 1.6; color: #4a4255; margin: 0; text-align: center; }
/* 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>
+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; }
+2
View File
@@ -9,3 +9,5 @@ License: SIL Open Font License 1.1. All self-hosted (no Google hotlink) for the
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.
+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.
+109 -23
View File
@@ -36,7 +36,7 @@ from fastapi.responses import FileResponse, HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel
from . import art, auth, bloom, daily, email_send, feeds, games, oauth_google, onthisday, publishing, queries, quote, readtime, share, sources, summarize, wotd
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
@@ -351,7 +351,9 @@ class Article(BaseModel):
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"),
@@ -591,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
@@ -638,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
@@ -767,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."
@@ -776,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)
@@ -1111,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 = ?",
@@ -1160,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) "
@@ -1185,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:
@@ -1389,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:
@@ -1695,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]
@@ -1909,13 +1976,9 @@ def create_app() -> FastAPI:
else:
rows = _fetch(None, limit, offset)
next_offset = offset + len(rows) if len(rows) == limit else None
# Paywalled below readable WITHIN each section (so tiers stay grouped); non-home
# rows all share section rank 0, preserving the original global behavior.
# 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),
is_paywalled_for_source(r["canonical_url"], r["paywall_override"]),
))
rows = sorted(rows, key=lambda r: _SEC.get(r.get("__section"), 0))
return FeedResponse(
topic=topic,
flavor=flavor,
@@ -2235,7 +2298,8 @@ def create_app() -> FastAPI:
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 pool 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
@@ -2300,6 +2364,28 @@ def create_app() -> FastAPI:
"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()
+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()
+12 -1
View File
@@ -13,7 +13,7 @@ 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 .geo import tag_articles as tag_geo
from . import art, onthisday, quote, wotd
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 (
@@ -599,6 +599,17 @@ 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:
+15
View File
@@ -33,6 +33,21 @@ def seeded_order(ids: list, date_str: str) -> list:
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)
+10
View File
@@ -632,6 +632,11 @@ 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)")}
@@ -651,6 +656,11 @@ def _migrate(conn: sqlite3.Connection) -> None:
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:
+10 -5
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:
+9 -9
View File
@@ -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>"
+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}
+13 -1
View File
@@ -31,6 +31,18 @@ _NEG = (
)
# 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("-")
@@ -46,7 +58,7 @@ def _fetch_events(md: str) -> list[dict]:
"year": e.get("year"),
"text": text,
"summary": (page.get("extract") or "").strip() or None,
"image_url": ((page.get("thumbnail") or {}).get("source")) or None,
"image_url": _best_image(page),
"page_url": (((page.get("content_urls") or {}).get("desktop") or {}).get("page")) or None,
})
return out
+56 -2
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,12 +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,
@@ -84,6 +111,7 @@ def feed(
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.
@@ -106,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:
@@ -353,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},
@@ -362,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"],
@@ -502,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
@@ -721,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.
@@ -863,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}",
),
},
}
+4 -7
View File
@@ -14,8 +14,6 @@ import sqlite3
from . import daily
from .localtime import local_today
_NO_REPEAT_POOL = 60
# 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"),
@@ -65,11 +63,10 @@ def _candidates(conn: sqlite3.Connection, avoid: int | None = None) -> list[int]
if featured:
ids = [r[0] for r in featured]
else:
rows = conn.execute(
"SELECT id FROM quote_pool WHERE blocked=0 ORDER BY shown_at IS NOT NULL, shown_at, id LIMIT ?",
(_NO_REPEAT_POOL,),
).fetchall()
ids = [r[0] for r in rows]
# 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
+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>"""
+4 -6
View File
@@ -25,7 +25,6 @@ from .localtime import local_today
DICT_BASE = "https://api.dictionaryapi.dev/api/v2/entries/en"
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
_NO_REPEAT_POOL = 60
_TARGET_POOL = 30 # keep harvesting (a batch/day) until the pool reaches this
_HARVEST_BATCH = 12
_MIN_AUDIO_BYTES = 500
@@ -222,11 +221,10 @@ def _candidates(conn: sqlite3.Connection, avoid: int | None = None) -> list[int]
if featured:
ids = [r[0] for r in featured]
else:
rows = conn.execute(
"SELECT id FROM wotd_pool WHERE blocked=0 ORDER BY shown_at IS NOT NULL, shown_at, id LIMIT ?",
(_NO_REPEAT_POOL,),
).fetchall()
ids = [r[0] for r in rows]
# 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
+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",
+50
View File
@@ -0,0 +1,50 @@
"""One-off: upgrade stored On This Day images from the blurry 330px thumbnail to a sharp,
size-capped URL. Re-fetches the Wikimedia events for each stored MM-DD, matches by page_url,
and rewrites image_url in onthisday_pool + daily_onthisday in place (no re-picking). Idempotent.
Run on the host: python -m scripts.otd_image_upsize_backfill
"""
import os
from goodnews import daily, onthisday
from goodnews.db import connect
conn = connect(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
# distinct dates we've harvested
mds = [r[0] for r in conn.execute(
"SELECT DISTINCT md FROM onthisday_pool WHERE image_url LIKE '%/thumb/%px-%'").fetchall()]
print(f"dates to refresh: {mds}")
# page_url -> sharp image_url, from a fresh feed fetch per date
best: dict[str, str] = {}
for md in mds:
mm, dd = md.split("-")
data = daily.http_json(f"{onthisday.WIKI_BASE}/{mm}/{dd}")
for e in (data.get("events") or []):
page = (e.get("pages") or [{}])[0] or {}
page_url = (((page.get("content_urls") or {}).get("desktop") or {}).get("page")) or None
img = onthisday._best_image(page)
if page_url and img:
best[page_url] = img
print(f" {md}: {len(data.get('events') or [])} events fetched")
updated_pool = updated_daily = 0
for table in ("onthisday_pool", "daily_onthisday"):
for page_url, new in best.items():
cur = conn.execute(
f"UPDATE {table} SET image_url=? "
f"WHERE page_url=? AND image_url LIKE '%/thumb/%px-%' AND image_url<>?",
(new, page_url, new))
if table == "onthisday_pool":
updated_pool += cur.rowcount
else:
updated_daily += cur.rowcount
conn.commit()
print(f"updated: onthisday_pool={updated_pool}, daily_onthisday={updated_daily}")
# show a few results
for r in conn.execute("SELECT md, year, image_url FROM onthisday_pool "
"WHERE image_url IS NOT NULL ORDER BY md LIMIT 6").fetchall():
print(f" {r['md']} {r['year']}: {r['image_url']}")
conn.close()
+60 -7
View File
@@ -484,6 +484,37 @@ def test_client_error_telemetry(tmp_path, monkeypatch):
assert stats["today"] == 1 and stats["window"] == 1 # bot excluded from both
def test_client_error_read_unread(tmp_path, monkeypatch):
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
anon = TestClient(app)
for r in ("boot-timeout", "preloadError", "boot-slow"):
anon.post("/api/client-error", json={"reason": r, "path": "/"})
tc = _signin(app, api, "boss@x.com")
# Default view is unread; all three start unread and drive the headline count.
unread = tc.get("/api/admin/client-errors").json()
assert len(unread) == 3 and all(not e["read"] for e in unread)
assert tc.get("/api/admin/stats").json()["client_errors"]["unread"] == 3
# Mark one read → it leaves the unread view, appears under read, count drops.
eid = unread[0]["id"]
assert tc.post(f"/api/admin/client-errors/{eid}/read", json={"read": True}).json()["read"] is True
assert len(tc.get("/api/admin/client-errors?show=unread").json()) == 2
rd = tc.get("/api/admin/client-errors?show=read").json()
assert len(rd) == 1 and rd[0]["id"] == eid and rd[0]["read"] is True
assert len(tc.get("/api/admin/client-errors?show=all").json()) == 3
assert tc.get("/api/admin/stats").json()["client_errors"]["unread"] == 2
# Toggling back restores it to unread.
assert tc.post(f"/api/admin/client-errors/{eid}/read", json={"read": False}).json()["read"] is False
assert len(tc.get("/api/admin/client-errors?show=unread").json()) == 3
# Mark-all clears the unread view in one go.
assert tc.post("/api/admin/client-errors/read-all").json()["marked"] == 3
assert tc.get("/api/admin/client-errors?show=unread").json() == []
assert tc.get("/api/admin/stats").json()["client_errors"]["unread"] == 0
# Unknown id 404s; both new routes are admin-gated.
assert tc.post("/api/admin/client-errors/99999/read", json={"read": True}).status_code == 404
assert anon.post("/api/admin/client-errors/read-all").status_code == 401
assert anon.post(f"/api/admin/client-errors/{eid}/read", json={"read": True}).status_code == 401
def test_wordsearch_theme_admin(tmp_path, monkeypatch):
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
assert TestClient(app).get("/api/admin/wordsearch/themes").status_code == 401 # gated
@@ -532,22 +563,44 @@ def test_source_paywall_override(tmp_path, monkeypatch):
c.commit(); c.close()
tc = _signin(app, api, "boss@x.com")
def feed_badge():
return next(a for a in tc.get("/api/feed?source_id=2").json()["items"] if a["id"] == 2)["paywalled"]
def in_feed():
return any(a["id"] == 2 for a in tc.get("/api/feed?source_id=2").json()["items"])
# domain rule: nytimes.com → paywalled in table, inspector, AND feed badge (all agree)
# domain rule: nytimes.com → paywalled in the source table + inspector, and HARD-EXCLUDED
# from the public feed (we don't surface stories you can't read for free)
assert _src(tc, 2)["paywalled"] is True
assert tc.get("/api/admin/sources/2/articles").json()["summary"]["paywalled"] is True
assert feed_badge() is True
# override 'free' (the NYT Learning fix) → effective OFF everywhere
assert in_feed() is False
# override 'free' (the NYT Learning fix) → effective OFF: it returns to the feed, no badge
assert tc.post("/api/admin/sources/2/paywall", json={"override": "free"}).json()["override"] == "free"
assert _src(tc, 2)["paywalled"] is False
summ = tc.get("/api/admin/sources/2/articles").json()["summary"]
assert summ["paywalled"] is False and summ["paywall_domain"] is True and summ["paywall_override"] == "free"
assert feed_badge() is False # ranking/badge now agree it's free
# back to domain rule, and the 'paywalled' override
assert in_feed() is True
assert next(a for a in tc.get("/api/feed?source_id=2").json()["items"] if a["id"] == 2)["paywalled"] is False
# back to domain rule → excluded again
assert tc.post("/api/admin/sources/2/paywall", json={"override": None}).json()["override"] is None
assert _src(tc, 2)["paywalled"] is True
assert in_feed() is False
# validation + 404
assert tc.post("/api/admin/sources/2/paywall", json={"override": "bogus"}).status_code == 422
assert tc.post("/api/admin/sources/999/paywall", json={"override": "free"}).status_code == 404
def test_source_image_policy(tmp_path, monkeypatch):
import sqlite3, os
app, api = _make(tmp_path, monkeypatch, admin_email="boss@x.com")
c = sqlite3.connect(os.environ["GOODNEWS_DB"])
c.execute("INSERT INTO sources (id,name,feed_url) VALUES (2,'Gov','http://g/f')")
c.commit(); c.close()
anon = TestClient(app)
assert anon.post("/api/admin/sources/2/image-policy", json={"policy": "cache"}).status_code == 401 # gated
tc = _signin(app, api, "boss@x.com")
assert tc.post("/api/admin/sources/2/image-policy", json={"policy": "cache"}).json()["policy"] == "cache"
c = sqlite3.connect(os.environ["GOODNEWS_DB"])
assert c.execute("SELECT image_policy FROM sources WHERE id=2").fetchone()[0] == "cache"
c.close()
r = tc.post("/api/admin/sources/2/image-policy", json={"policy": "remote"}).json()
assert r["policy"] == "remote" and r["purged"] == 0 # leaving cache purges (no files here)
assert tc.post("/api/admin/sources/2/image-policy", json={"policy": "bogus"}).status_code == 422
assert tc.post("/api/admin/sources/999/image-policy", json={"policy": "cache"}).status_code == 404
+7 -4
View File
@@ -8,9 +8,12 @@ from goodnews.paywall import is_paywalled
def test_brief_prefers_readable_over_higher_scored_paywalled():
c = connect(":memory:"); init_db(c)
today = date.today().isoformat()
# Sources 1-2 are paywalled by their homepage domain (matches production: a paywalled
# source has a paywalled site); 3-7 are free.
homes = {1: "https://www.newscientist.com/", 2: "https://www.nature.com/"}
for sid in range(1, 8):
c.execute("INSERT INTO sources (id,name,feed_url,trust_score) VALUES (?,?,?,5)",
(sid, f"S{sid}", f"http://s{sid}/f"))
c.execute("INSERT INTO sources (id,name,feed_url,homepage_url,trust_score) VALUES (?,?,?,?,5)",
(sid, f"S{sid}", f"http://s{sid}/f", homes.get(sid)))
def add(aid, sid, url, score):
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,published_at,url_hash) "
@@ -19,7 +22,7 @@ def test_brief_prefers_readable_over_higher_scored_paywalled():
"cortisol_score,ragebait_score,pr_risk_score,accepted,topic,flavor) "
"VALUES (?,?,?,?,0,0,2,1,'science','discovery')", (aid, score, score, score))
# Paywalled ones are scored HIGHER — readability must still win for the five.
# Paywalled sources are scored HIGHER — they must still be EXCLUDED, leaving the five readable.
add(1, 1, "https://www.newscientist.com/a", 9)
add(2, 2, "https://www.nature.com/b", 9)
add(3, 3, "https://phys.org/c", 4)
@@ -33,4 +36,4 @@ def test_brief_prefers_readable_over_higher_scored_paywalled():
urls = [r["canonical_url"] for r in show_brief(c, brief_date=today, limit=10)]
c.close()
assert len(urls) == 5
assert not any(is_paywalled(u) for u in urls) # five readable chosen over paywalled
assert not any(is_paywalled(u) for u in urls) # paywalled sources excluded; five readable fill
+2 -2
View File
@@ -43,6 +43,6 @@ def test_reply_to_uses_env_inbox(monkeypatch):
def test_reply_to_falls_back_to_from(monkeypatch):
_arm(monkeypatch)
monkeypatch.delenv("GOODNEWS_REPLY_TO_EMAIL", raising=False)
monkeypatch.setenv("GOODNEWS_SMTP_FROM", "Upbeat Bytes <hello@upbeatbytes.com>")
monkeypatch.setenv("GOODNEWS_SMTP_FROM", "upbeatBytes <hello@upbeatbytes.com>")
es.send_feedback_reply("reader@x.com", "hi", None, "o")
assert _FakeSMTP.last["Reply-To"] == "Upbeat Bytes <hello@upbeatbytes.com>"
assert _FakeSMTP.last["Reply-To"] == "upbeatBytes <hello@upbeatbytes.com>"
+43 -5
View File
@@ -1,6 +1,12 @@
import pytest
from fastapi.testclient import TestClient
# The events beacon now drops known-bot User-Agents (queries.is_bot_ua), and the test
# client's default UA contains "python" → would be filtered. Send a real browser UA so
# these record like a genuine visitor; the bot case is covered explicitly below.
_BROWSER = {"user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0 Safari/537.36"}
@pytest.fixture
def app_db(tmp_path, monkeypatch):
@@ -28,7 +34,7 @@ def _count(db, **where):
def test_event_recorded_and_deduped(app_db):
app, db = app_db
tc = TestClient(app)
tc = TestClient(app, headers=_BROWSER)
for _ in range(3): # same (kind, article, visitor, day) → one row
assert tc.post("/api/events", json={"kind": "open", "article_id": 5, "visitor": "tok"}).json() == {"ok": True}
assert _count(db, kind="open", article_id=5) == 1
@@ -39,7 +45,7 @@ def test_event_recorded_and_deduped(app_db):
def test_visitor_token_is_hashed_not_stored_raw(app_db):
app, db = app_db
TestClient(app).post("/api/events", json={"kind": "visit", "visitor": "secret-token"})
TestClient(app, headers=_BROWSER).post("/api/events", json={"kind": "visit", "visitor": "secret-token"})
from goodnews.db import connect
c = connect(str(db))
vh = c.execute("SELECT visitor_hash FROM events").fetchone()[0]
@@ -49,13 +55,28 @@ def test_visitor_token_is_hashed_not_stored_raw(app_db):
def test_unknown_kind_is_ignored(app_db):
app, db = app_db
assert TestClient(app).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True}
assert TestClient(app, headers=_BROWSER).post("/api/events", json={"kind": "evil", "visitor": "x"}).json() == {"ok": True}
assert _count(db) == 0
def test_bot_user_agents_are_not_counted(app_db):
"""JS-capable crawlers fire this beacon too; honest bot UAs must not inflate counts.
Response stays {ok:true} so a bot can't tell it was dropped."""
app, db = app_db
for bot_ua in ("Mozilla/5.0 (compatible; GPTBot/1.0; +https://openai.com/gptbot)",
"Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)",
"Mozilla/5.0 (X11; Linux x86_64) HeadlessChrome/120.0 Safari/537.36"):
tc = TestClient(app, headers={"user-agent": bot_ua})
assert tc.post("/api/events", json={"kind": "visit", "visitor": "b"}).json() == {"ok": True}
assert _count(db, kind="visit") == 0 # none recorded
# a real browser on the same beacon IS counted
TestClient(app, headers=_BROWSER).post("/api/events", json={"kind": "visit", "visitor": "human"})
assert _count(db, kind="visit") == 1
def test_game_event_kinds_are_allowed(app_db):
app, db = app_db
tc = TestClient(app)
tc = TestClient(app, headers=_BROWSER)
# the per-game funnel kinds (incl. the share-loop arrival) pass the allowlist
for kind in ("word_started", "word_completed", "word_shared", "word_arrival", "match_arrival"):
assert tc.post("/api/events", json={"kind": kind, "article_id": 0, "visitor": "t"}).json() == {"ok": True}
@@ -67,7 +88,7 @@ def test_game_event_kinds_are_allowed(app_db):
def test_admin_stats_games_funnel_aggregates(app_db):
app, db = app_db
tc = TestClient(app)
tc = TestClient(app, headers=_BROWSER)
# two visitors arrive at Daily Word via a shared link; one engages + shares; a Match completes
for v in ("a", "b"):
tc.post("/api/events", json={"kind": "word_arrival", "article_id": 0, "visitor": v})
@@ -82,3 +103,20 @@ def test_admin_stats_games_funnel_aggregates(app_db):
assert games["by_game"]["word"] == {"arrival": 2, "started": 1, "completed": 0, "shared": 1}
assert games["by_game"]["match"]["completed"] == 1
assert games["totals"]["arrival"] == 2 and games["totals"]["shared"] == 1
def test_engaged_readers_metric(app_db):
"""Engaged readers counts the gesture-gated 'engaged' beacon + deliberate actions,
NOT auto-fired visit/summary_viewed or a game-share arrival."""
app, db = app_db
tc = TestClient(app, headers=_BROWSER)
tc.post("/api/events", json={"kind": "engaged", "visitor": "a"}) # gesture beacon
tc.post("/api/events", json={"kind": "source_click", "article_id": 5, "visitor": "b"}) # deliberate
tc.post("/api/events", json={"kind": "visit", "visitor": "c"}) # raw visit only
tc.post("/api/events", json={"kind": "summary_viewed", "article_id": 5, "visitor": "c"}) # auto-fired
tc.post("/api/events", json={"kind": "word_arrival", "visitor": "d"}) # share-loop landing
from goodnews.db import connect
from goodnews import queries
cn = connect(str(db)); v = queries.admin_stats(cn, days=30)["visitors"]; cn.close()
assert v["engaged_today"] == 2 # a (engaged) + b (source_click)
assert v["today"] == 1 # only c fired a raw visit
+255
View File
@@ -0,0 +1,255 @@
"""News image cache (hardened): SSRF-safe cycle-only fetch + downscale to WebP, reject
SVG/non-raster/private-host/redirect-to-private, negative-cache failures, LRU eviction,
and the cache-HITS-ONLY /api/img endpoint (restricted to accepted, canonical articles)."""
import io
import os
import pytest
from PIL import Image
from fastapi.testclient import TestClient
from goodnews import newsimg
from goodnews.db import connect, init_db
# The real SSRF-safe fetch, captured before any fixture stubs it — the SSRF tests below
# restore it so they exercise the genuine host/redirect validation.
_REAL_SAFE_FETCH = newsimg._safe_fetch
class _Resp:
"""Minimal non-redirect (2xx) response for a fake opener."""
def __init__(self, headers, body=b""):
self.status, self.headers, self._b = 200, headers, body
def read(self, n=-1):
return self._b
def close(self):
pass
def _http_error(url, code, location=None):
"""A real urllib HTTPError, as _NoRedirect raises for 3xx (and urllib for 4xx/5xx)."""
import urllib.error
hdrs = {"Location": location} if location else {}
return urllib.error.HTTPError(url, code, "x", hdrs, io.BytesIO(b""))
def _fake_opener(handler):
class _Op:
def open(self, req, timeout=None):
return handler(req.full_url)
return lambda *a, **k: _Op()
def _png(w=1600, h=1000, color=(40, 130, 173)):
out = io.BytesIO()
Image.new("RGB", (w, h), color).save(out, format="PNG")
return out.getvalue()
@pytest.fixture
def cache(tmp_path, monkeypatch):
monkeypatch.setenv("GOODNEWS_IMG_CACHE", str(tmp_path / "img_cache"))
# default: treat hosts as public + a happy PNG fetch; individual tests override.
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: True)
monkeypatch.setattr(newsimg, "_safe_fetch", lambda url, timeout=12: (_png(), "image/png"))
return tmp_path / "img_cache"
def test_fetch_and_cache_downscales_to_webp(cache):
p = newsimg.fetch_and_cache("https://example.com/big.png")
assert p and p.exists() and p.suffix == ".webp"
with Image.open(p) as im:
assert im.width == newsimg.DISPLAY_WIDTH and im.format == "WEBP"
assert newsimg.path_for("https://example.com/big.png") == p # pure cache lookup
def test_bad_scheme_and_empty_are_rejected(cache):
assert newsimg.fetch_and_cache(None) is None
assert newsimg.fetch_and_cache("ftp://example.com/x.png") is None
assert not list(cache.glob("*.webp"))
def test_svg_and_nonimage_rejected_and_negative_cached(cache, monkeypatch):
monkeypatch.setattr(newsimg, "_safe_fetch",
lambda url, timeout=12: (b"<svg xmlns='http://www.w3.org/2000/svg'></svg>" + b" " * 600,
"image/svg+xml"))
url = "https://example.com/evil.svg"
assert newsimg.fetch_and_cache(url) is None # decoded-raster only → SVG refused
assert not list(cache.glob("*.webp")) # nothing stored
assert newsimg._failed_recently(url) # negative-cached (won't refetch)
def test_private_host_refused_and_negative_cached(cache, monkeypatch):
monkeypatch.setattr(newsimg, "_safe_fetch", _REAL_SAFE_FETCH) # exercise real validation
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: False)
url = "http://169.254.169.254/latest/meta-data/"
assert newsimg.fetch_and_cache(url) is None
assert newsimg._failed_recently(url) # SSRF target → permanent fail
def test_redirect_to_private_is_refused(cache, monkeypatch):
monkeypatch.setattr(newsimg, "_safe_fetch", _REAL_SAFE_FETCH)
hosts = {"good.example": True, "evil.internal": False}
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: hosts.get(h, False))
def handle(url): # real 302 (HTTPError) → private dest
if "good.example" in url:
raise _http_error(url, 302, "http://evil.internal/x.png")
return _Resp({"Content-Type": "image/png"}, _png()) # must never be reached
monkeypatch.setattr(newsimg.urllib.request, "build_opener", _fake_opener(handle))
url = "http://good.example/p.png"
assert newsimg.fetch_and_cache(url) is None # redirect hop re-validated → refused
assert newsimg._failed_recently(url)
def test_real_redirect_followed_to_public(cache, monkeypatch):
"""Regression for the _NoRedirect bug: 3xx arrive as HTTPError, must be followed."""
monkeypatch.setattr(newsimg, "_safe_fetch", _REAL_SAFE_FETCH)
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: True)
seen = []
def handle(url):
seen.append(url)
if url == "http://a.example/p.png":
raise _http_error(url, 302, "http://b.example/final.png")
return _Resp({"Content-Type": "image/png"}, _png())
monkeypatch.setattr(newsimg.urllib.request, "build_opener", _fake_opener(handle))
p = newsimg.fetch_and_cache("http://a.example/p.png")
assert p and p.suffix == ".webp" # followed the redirect, cached the dest
assert seen == ["http://a.example/p.png", "http://b.example/final.png"]
def test_404_is_permanent_but_5xx_is_transient(cache, monkeypatch):
monkeypatch.setattr(newsimg, "_safe_fetch", _REAL_SAFE_FETCH)
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: True)
monkeypatch.setattr(newsimg.urllib.request, "build_opener",
_fake_opener(lambda url: (_ for _ in ()).throw(_http_error(url, 404))))
gone = "http://x.example/missing.png"
assert newsimg.fetch_and_cache(gone) is None
assert newsimg._failed_recently(gone) # 404 → permanent, negative-cached
monkeypatch.setattr(newsimg.urllib.request, "build_opener",
_fake_opener(lambda url: (_ for _ in ()).throw(_http_error(url, 503))))
down = "http://x.example/down.png"
assert newsimg.fetch_and_cache(down) is None
assert not newsimg._failed_recently(down) # 5xx → transient, retried next cycle
def test_megapixel_ceiling_enforced_before_decode(cache, monkeypatch):
monkeypatch.setattr(newsimg, "_MAX_PIXELS", 1000) # a 1600x1000 image (1.6M px) exceeds this
monkeypatch.setattr(newsimg, "_safe_fetch", lambda url, timeout=12: (_png(1600, 1000), "image/png"))
url = "https://x.example/huge.png"
assert newsimg.fetch_and_cache(url) is None # rejected by the explicit w*h check
assert not list(cache.glob("*.webp"))
assert newsimg._failed_recently(url)
def test_transient_failure_is_not_negative_cached(cache, monkeypatch):
def boom(url, timeout=12):
raise newsimg._FetchError("network down", permanent=False)
monkeypatch.setattr(newsimg, "_safe_fetch", boom)
url = "https://example.com/x.jpg"
assert newsimg.fetch_and_cache(url) is None
assert not newsimg._failed_recently(url) # transient → retried next cycle
def test_prune_evicts_least_recently_used_over_cap(cache):
paths = [newsimg.fetch_and_cache(f"https://example.com/{i}.png") for i in range(5)]
for i, p in enumerate(paths): # 0 = oldest/LRU, 4 = newest
os.utime(p, (1000 + i, 1000 + i))
sizes = [p.stat().st_size for p in paths]
cap = sum(sizes) - sizes[0] - sizes[1] + 1 # room for the 3 newest only
r = newsimg.prune(cap)
assert r["removed"] == 2 and r["after"] <= cap
assert not paths[0].exists() and not paths[1].exists()
assert paths[2].exists() and paths[4].exists()
def test_display_url_resolves_per_policy():
assert newsimg.display_url(7, "cache", "https://x/p.jpg") == "/api/img/7" # our copy
assert newsimg.display_url(7, "remote", "https://x/p.jpg") == "https://x/p.jpg" # hotlink
assert newsimg.display_url(7, "none", "https://x/p.jpg") is None # typographic cover
assert newsimg.display_url(7, None, "https://x/p.jpg") == "https://x/p.jpg" # default = remote
assert newsimg.display_url(7, "cache", None) is None # no image
def test_warm_only_caches_cache_policy_sources(cache, monkeypatch):
conn = connect(":memory:"); init_db(conn)
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (1,'C','http://c/f','cache')")
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (2,'R','http://r/f','remote')")
# source 1 (cache): two accepted+canonical+image; plus not-accepted + duplicate (skipped)
# source 2 (remote): accepted+image but must NOT be cached (re-hosting not cleared)
rows = ((1, 1, "https://x/1.jpg", 1, None), (2, 1, "https://x/2.jpg", 1, None),
(3, 1, "https://x/3.jpg", 0, None), (4, 1, "https://x/4.jpg", 1, 1),
(5, 2, "https://x/5.jpg", 1, None))
for aid, sid, img, acc, dup in rows:
conn.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url,duplicate_of) "
"VALUES (?,?,?,?,?,?,?)", (aid, sid, f"http://s/{aid}", f"t{aid}", f"h{aid}", img, dup))
conn.execute("INSERT INTO article_scores (article_id, accepted) VALUES (?,?)", (aid, acc))
conn.commit()
assert newsimg.warm(conn) == 2 # only source 1's accepted+canonical+image
assert newsimg.warm(conn) == 0 # idempotent — already cached
def test_purge_source_removes_cached_copies(cache, monkeypatch):
conn = connect(":memory:"); init_db(conn)
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (1,'C','http://c/f','cache')")
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (2,'O','http://o/f','cache')")
for aid, sid, img in ((1, 1, "https://x/1.jpg"), (2, 1, "https://x/2.jpg"), (3, 2, "https://x/3.jpg")):
conn.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url) "
"VALUES (?,?,?,?,?,?)", (aid, sid, f"http://s/{aid}", f"t{aid}", f"h{aid}", img))
conn.execute("INSERT INTO article_scores (article_id, accepted) VALUES (?,1)", (aid,))
conn.commit()
assert newsimg.warm(conn) == 3 # all three cached (both sources 'cache')
removed = newsimg.purge_source(conn, 1) # source 1 leaves cache → its 2 copies go
assert removed == 2
assert newsimg.path_for("https://x/1.jpg") is None and newsimg.path_for("https://x/2.jpg") is None
assert newsimg.path_for("https://x/3.jpg") is not None # source 2 untouched
@pytest.fixture
def client(tmp_path, monkeypatch):
monkeypatch.setenv("GOODNEWS_DB", str(tmp_path / "t.sqlite3"))
monkeypatch.setenv("GOODNEWS_IMG_CACHE", str(tmp_path / "img_cache"))
monkeypatch.setattr(newsimg, "_host_is_public", lambda h: True)
monkeypatch.setattr(newsimg, "_safe_fetch", lambda url, timeout=12: (_png(), "image/png"))
conn = connect(tmp_path / "t.sqlite3"); init_db(conn)
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (1,'C','http://c/f','cache')")
conn.execute("INSERT INTO sources (id,name,feed_url,image_policy) VALUES (2,'R','http://r/f','remote')")
# src1 (cache): 1=accepted+image, 2=no image, 3=NOT accepted, 4=duplicate.
# src2 (remote): 5=accepted+image but NOT cleared to cache → endpoint must 404.
rows = ((1, 1, "https://x/pic.jpg", 1, None), (2, 1, "", 1, None),
(3, 1, "https://x/p3.jpg", 0, None), (4, 1, "https://x/p4.jpg", 1, 1),
(5, 2, "https://x/p5.jpg", 1, None))
for aid, sid, img, acc, dup in rows:
conn.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,image_url,duplicate_of) "
"VALUES (?,?,?,?,?,?,?)", (aid, sid, f"http://s/{aid}", f"t{aid}", f"h{aid}", img, dup))
conn.execute("INSERT INTO article_scores (article_id, accepted) VALUES (?,?)", (aid, acc))
conn.commit()
# the cycle owns fetching — pre-warm so the endpoint has a hit to serve
newsimg.warm(conn)
conn.close()
from goodnews.api import create_app
return TestClient(create_app())
def test_img_endpoint_serves_cached_and_restricts(client):
r = client.get("/api/img/1")
assert r.status_code == 200 and r.headers["content-type"] == "image/webp"
assert "immutable" not in r.headers.get("cache-control", "") # no 1-year immutable
assert client.get("/api/img/2").status_code == 404 # no image
assert client.get("/api/img/3").status_code == 404 # not accepted
assert client.get("/api/img/4").status_code == 404 # duplicate (non-canonical)
assert client.get("/api/img/5").status_code == 404 # remote-policy source (not cleared to cache)
assert client.get("/api/img/999").status_code == 404 # unknown id
def test_img_endpoint_never_fetches(client, monkeypatch):
# An accepted article whose image was never warmed must 404, not trigger a fetch.
called = {"n": 0}
monkeypatch.setattr(newsimg, "fetch_and_cache", lambda *a, **k: called.__setitem__("n", called["n"] + 1))
# /api/img/1 is cached (warmed in fixture) → still served without any fetch
assert client.get("/api/img/1").status_code == 200
assert called["n"] == 0
+12
View File
@@ -76,3 +76,15 @@ def test_tone_filter_llm_narrows(conn):
kept = onthisday._tone_filter([dict(e) for e in FAKE], client=FakeClient())
# keyword floor drops the invasion (3 remain), then the LLM narrows to 1
assert len(kept) == 1 and kept[0]["year"] == 1611
def test_best_image_prefers_original_over_thumbnail():
# the 330px thumbnail upscales (blurry); originalimage is reliably sharp → prefer it
page = {
"thumbnail": {"source": "https://x/thumb/Foo.jpg/330px-Foo.jpg", "width": 330},
"originalimage": {"source": "https://x/thumb/Foo.jpg/3840px-Foo.jpg", "width": 7000},
}
assert onthisday._best_image(page) == "https://x/thumb/Foo.jpg/3840px-Foo.jpg"
# falls back to the thumbnail when there's no originalimage, and to None when neither exists
assert onthisday._best_image({"thumbnail": {"source": "https://x/330px-Foo.jpg"}}) == "https://x/330px-Foo.jpg"
assert onthisday._best_image({}) is None
+85
View File
@@ -0,0 +1,85 @@
"""The no-paywall promise across every public discovery path: paywalled sources are
excluded from brief generation, stored-brief retrieval (/today + /api/brief), and both
digest queries while Saved keeps anything the reader saved, and a 'free' override
restores eligibility."""
from datetime import date
from goodnews.db import connect, init_db
from goodnews import briefs, digest, queries
def _setup(c, pay_override="paywalled"):
"""Source 1 = paywalled (via override, so the test doesn't depend on the domain list),
source 2 = free. One recent accepted article each, both in a stored brief."""
today = date.today().isoformat()
c.execute("INSERT INTO sources (id,name,feed_url,content_visible,paywall_override) VALUES (1,'Pay','http://p/f',1,?)",
(pay_override,))
c.execute("INSERT INTO sources (id,name,feed_url,content_visible) VALUES (2,'Free','http://f/f',1)")
for aid, sid in [(1, 1), (2, 2)]:
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,published_at,url_hash) VALUES (?,?,?,?,?,?)",
(aid, sid, f"http://x/{aid}", f"t{aid}", today + "T12:00:00+00:00", f"h{aid}"))
c.execute("INSERT INTO article_scores (article_id,accepted,topic,flavor) VALUES (?,1,'science','discovery')", (aid,))
c.execute("INSERT INTO article_summaries (article_id,summary) VALUES (?,?)", (aid, f"s{aid}"))
c.commit()
return today
def _store_brief(c, today, ids=(1, 2)):
bid = c.execute("INSERT INTO daily_briefs (brief_date,title) VALUES (?,'t')", (today,)).lastrowid
for rank, aid in enumerate(ids, start=1):
c.execute("INSERT INTO daily_brief_items (brief_id,article_id,rank) VALUES (?,?,?)", (bid, aid, rank))
c.commit()
def test_paywalled_never_stored_in_a_new_brief():
c = connect(":memory:"); init_db(c)
today = _setup(c)
briefs.build_daily_brief(c, brief_date=today, limit=5, replace=True)
stored = [r["article_id"] for r in c.execute("SELECT article_id FROM daily_brief_items")]
assert stored == [2] # paywalled candidate excluded, never written
def test_stored_brief_retrieval_and_digest_omit_paywalled():
c = connect(":memory:"); init_db(c)
today = _setup(c)
_store_brief(c, today) # both stored directly → retrieval/digest must still filter
assert [r["id"] for r in queries.brief(c)["items"]] == [2] # /today + /api/brief
assert [d["id"] for d in digest.digest_items(c, today)] == [2] # morning email
def test_followed_source_email_omits_paywalled():
c = connect(":memory:"); init_db(c)
today = _setup(c)
c.execute("INSERT INTO users (id,email) VALUES (1,'r@x.com')")
c.execute("INSERT INTO user_follows (user_id,kind,value) VALUES (1,'source','1')") # follow the paywalled one
c.execute("INSERT INTO user_follows (user_id,kind,value) VALUES (1,'source','2')")
c.commit()
ids = [d["id"] for d in digest.followed_digest_items(c, 1, exclude_ids=[])]
assert ids == [2] # even a followed paywalled source is omitted from the email
def test_saved_retains_paywalled():
c = connect(":memory:"); init_db(c)
_setup(c)
c.execute("INSERT INTO users (id,email) VALUES (1,'r@x.com')")
c.execute("INSERT INTO saved_articles (user_id,article_id,saved_at) VALUES (1,1,'2026-06-28T00:00:00')")
c.commit()
assert 1 in [r["id"] for r in queries.saved(c, 1)] # you keep what you saved
def test_history_retains_paywalled():
c = connect(":memory:"); init_db(c)
_setup(c)
c.execute("INSERT INTO users (id,email) VALUES (1,'r@x.com')")
c.execute("INSERT INTO user_history (user_id,article_id,at) VALUES (1,1,'2026-06-28T00:00:00')")
c.commit()
assert 1 in [r["id"] for r in queries.history(c, 1)] # a viewed paywalled article stays in history
def test_free_override_restores_eligibility():
c = connect(":memory:"); init_db(c)
today = _setup(c, pay_override="free") # same domain-less source, but marked free
briefs.build_daily_brief(c, brief_date=today, limit=5, replace=True)
stored = [r["article_id"] for r in c.execute("SELECT article_id FROM daily_brief_items")]
assert 1 in stored and 2 in stored
assert {r["id"] for r in queries.brief(c)["items"]} == {1, 2}
+25
View File
@@ -56,3 +56,28 @@ def test_get_today_never_empty(conn):
def test_run_daily_seeds_then_picks(conn):
r = quote.run_daily(conn)
assert r["pool"] == len(quote.SEED) and r["picked"]
def test_no_repeat_until_pool_exhausted(conn):
"""Every quote is featured exactly once before ANY repeat; then the oldest-shown
repeats first. (Regression for the rotate-the-whole-pool selector bug.)"""
import datetime
quote.seed(conn)
n = len(quote.SEED)
d0 = datetime.date(2026, 1, 1)
picks = [quote.pick_daily(conn, feature_date=(d0 + datetime.timedelta(days=i)).isoformat())["pool_id"]
for i in range(n)]
assert len(set(picks)) == n # full coverage, no repeat within the pool
nxt = (d0 + datetime.timedelta(days=n)).isoformat()
assert quote.pick_daily(conn, feature_date=nxt)["pool_id"] == picks[0] # oldest repeats first
def test_freshest_cohort():
from goodnews import daily
# never-shown win outright (the oldest shown item is ignored while any never-shown remain)
assert daily.freshest([{"id": 1, "shown_at": "2026-01-02"},
{"id": 2, "shown_at": None}, {"id": 3, "shown_at": None}]) == [2, 3]
# all shown → only the oldest-shown cohort
assert daily.freshest([{"id": 1, "shown_at": "2026-01-03"},
{"id": 2, "shown_at": "2026-01-01"}, {"id": 3, "shown_at": "2026-01-01"}]) == [2, 3]
assert daily.freshest([]) == []
+26
View File
@@ -43,3 +43,29 @@ def test_sitemap(client):
assert "https://upbeatbytes.com/a/1" in xml
assert "https://upbeatbytes.com/today" in xml
assert "<lastmod>2026-06-05</lastmod>" in xml
def test_sitemap_excludes_paywalled_and_restores_on_free(tmp_path, monkeypatch):
db = tmp_path / "sm.sqlite3"
monkeypatch.setenv("GOODNEWS_DB", str(db))
monkeypatch.setenv("GOODNEWS_PUBLIC_BASE_URL", "https://upbeatbytes.com")
import importlib
import goodnews.api as api
importlib.reload(api)
from goodnews.db import connect, init_db
c = connect(str(db)); init_db(c)
c.execute("INSERT INTO sources (id,name,feed_url,trust_score,content_visible) VALUES (1,'Free','http://f',5,1)")
c.execute("INSERT INTO sources (id,name,feed_url,trust_score,content_visible,paywall_override) "
"VALUES (2,'Pay','http://p',5,1,'paywalled')")
for aid, sid in [(1, 1), (2, 2)]:
c.execute("INSERT INTO articles (id,source_id,canonical_url,title,url_hash,published_at) VALUES (?,?,?,?,?,?)",
(aid, sid, f"http://x/{aid}", f"t{aid}", f"h{aid}", "2026-06-05T08:00:00"))
c.execute("INSERT INTO article_scores (article_id,accepted) VALUES (?,1)", (aid,))
c.execute("INSERT INTO article_summaries (article_id,summary) VALUES (?,?)", (aid, f"s{aid}")) # sitemap requires a summary
c.commit()
tc = TestClient(api.create_app())
xml = tc.get("/sitemap.xml").text
assert "/a/1</loc>" in xml and "/a/2</loc>" not in xml # paywalled source omitted
c.execute("UPDATE sources SET paywall_override='free' WHERE id=2"); c.commit()
assert "/a/2</loc>" in tc.get("/sitemap.xml").text # restored under 'free'
c.close()
+12
View File
@@ -105,3 +105,15 @@ def test_complete_page_is_cached_and_served_from_cache(app_complete):
assert 1 in api._SHARE_CACHE # finished page cached
r2 = tc.get("/a/1") # second hit served from cache
assert r2.status_code == 200 and r2.text == r1.text
def test_share_chrome_parity_and_fallbacks():
"""HubBar-replica parity Codex asked for: Back only follows a SAME-ORIGIN referrer,
the menu honors Escape, and a failed image removes itself (clean text unfurl)."""
from goodnews import share
html = share.render_share_page(
{"id": 7, "title": "T", "image_url": "https://x/p.jpg", "canonical_url": "https://s/x",
"source_name": "S"}, "https://upbeatbytes.com")
assert "origin===location.origin" in html # Back requires same-origin referrer
assert "'Escape'" in html or "Escape" in html # menu closes on Escape (parity)
assert 'onerror="this.remove()"' in html # broken image degrades to text unfurl
+16
View File
@@ -142,3 +142,19 @@ def test_polish_returns_none_with_empty_examples():
def chat_text(self, m):
return '{"gloss": "A warm clear gloss.", "examples": []}'
assert wotd._polish(C(), "serene", "adjective", "x") is None
def test_no_repeat_until_pool_exhausted(conn):
"""Same freshness guarantee as QOTD: every word featured once before any repeat,
then the oldest-shown repeats first. (Regression for 'harmony' repeating after 3 days.)"""
import datetime
for w in ["alpha", "bravo", "charlie", "delta"]:
conn.execute("INSERT INTO wotd_pool (word, definition) VALUES (?, 'a definition')", (w,))
conn.commit()
n = 4
d0 = datetime.date(2026, 2, 1)
picks = [wotd.pick_daily(conn, feature_date=(d0 + datetime.timedelta(days=i)).isoformat())["pool_id"]
for i in range(n)]
assert len(set(picks)) == n
nxt = (d0 + datetime.timedelta(days=n)).isoformat()
assert wotd.pick_daily(conn, feature_date=nxt)["pool_id"] == picks[0]
+315
View File
@@ -0,0 +1,315 @@
{
"note": "Angelfish Animated Pack 10 (CGTrader). One baked 75.67s/2270-frame Take at 30fps in each GLB. GLB frame = Maya frame + 110. Slice with THREE.AnimationUtils.subclip(clip,name,startFrame,endFrame,30).",
"fps": 30,
"total_frames": 2270,
"clips": {
"Swim1_norm": {
"glb_frames": [
110,
185
],
"seconds": [
3.667,
6.167
],
"maya_frames": [
0,
75
]
},
"Turn_R_in": {
"glb_frames": [
186,
255
],
"seconds": [
6.2,
8.5
],
"maya_frames": [
76,
145
]
},
"Turn_R_loop": {
"glb_frames": [
256,
330
],
"seconds": [
8.533,
11.0
],
"maya_frames": [
146,
220
]
},
"Turn_R_out": {
"glb_frames": [
331,
409
],
"seconds": [
11.033,
13.633
],
"maya_frames": [
221,
299
]
},
"Swim2_Fast": {
"glb_frames": [
410,
450
],
"seconds": [
13.667,
15.0
],
"maya_frames": [
300,
340
]
},
"Turn_L_in": {
"glb_frames": [
486,
535
],
"seconds": [
16.2,
17.833
],
"maya_frames": [
376,
425
]
},
"Turn_L_loop": {
"glb_frames": [
536,
610
],
"seconds": [
17.867,
20.333
],
"maya_frames": [
426,
500
]
},
"Turn_L_out": {
"glb_frames": [
611,
710
],
"seconds": [
20.367,
23.667
],
"maya_frames": [
501,
600
]
},
"Attack": {
"glb_frames": [
711,
751
],
"seconds": [
23.7,
25.033
],
"maya_frames": [
601,
641
]
},
"Eat_Swim": {
"glb_frames": [
752,
811
],
"seconds": [
25.067,
27.033
],
"maya_frames": [
642,
701
]
},
"Eat_Ground": {
"glb_frames": [
812,
852
],
"seconds": [
27.067,
28.4
],
"maya_frames": [
702,
742
]
},
"Eat_Wall": {
"glb_frames": [
853,
893
],
"seconds": [
28.433,
29.767
],
"maya_frames": [
743,
783
]
},
"Turn_L_Fast": {
"glb_frames": [
894,
923
],
"seconds": [
29.8,
30.767
],
"maya_frames": [
784,
813
]
},
"Turn_R_Fast": {
"glb_frames": [
924,
953
],
"seconds": [
30.8,
31.767
],
"maya_frames": [
814,
843
]
},
"Swim3_Long_Wide": {
"glb_frames": [
954,
1401
],
"seconds": [
31.8,
46.7
],
"maya_frames": [
844,
1291
]
},
"Swim4_Long_Near": {
"glb_frames": [
1402,
1852
],
"seconds": [
46.733,
61.733
],
"maya_frames": [
1292,
1742
]
},
"Death1 Sink Start": {
"glb_frames": [
1853,
1930
],
"seconds": [
61.767,
64.333
],
"maya_frames": [
1743,
1820
]
},
"Death1 Sink Loop": {
"glb_frames": [
1930,
1987
],
"seconds": [
64.333,
66.233
],
"maya_frames": [
1820,
1877
]
},
"Death2 Float Start": {
"glb_frames": [
1988,
2120
],
"seconds": [
66.267,
70.667
],
"maya_frames": [
1878,
2010
]
},
"Death2 Float Loop": {
"glb_frames": [
2121,
2210
],
"seconds": [
70.7,
73.667
],
"maya_frames": [
2011,
2100
]
},
"Jump": {
"glb_frames": [
2211,
2270
],
"seconds": [
73.7,
75.667
],
"maya_frames": [
2101,
2160
]
},
"Idle": {
"glb_frames": [
0,
109
],
"seconds": [
0.0,
3.633
],
"maya_frames": [
-110,
-1
]
}
}
}
+60
View File
@@ -0,0 +1,60 @@
// Build a multi-clip GLB from the pack's single baked Take: extract a chosen set of
// clips (by frame range from angelfish-clips.json) into SEPARATE named animations, each
// rebased to start at 0, then drop the original 75s Take. This is what the Zen Den's
// behavior engine crossfades between. Usage: node build-clips.mjs in.glb out.glb
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import { prune } from '@gltf-transform/functions';
import fs from 'node:fs';
const [, , inPath, outPath] = process.argv;
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const doc = await io.read(inPath);
const root = doc.getRoot();
const buffer = root.listBuffers()[0];
const meta = JSON.parse(fs.readFileSync(new URL('./angelfish-clips.json', import.meta.url))).clips;
// output-clip-name -> source clip in the pack. Full turn SEQUENCES (in→loop→out) so the
// controller can play a real turn; swim intensities for cadence/zoomies; eats for forage.
const MAP = {
idle: 'Idle',
cruise: 'Swim1_norm',
fast: 'Swim2_Fast',
turnLin: 'Turn_L_in', turnLloop: 'Turn_L_loop', turnLout: 'Turn_L_out',
turnRin: 'Turn_R_in', turnRloop: 'Turn_R_loop', turnRout: 'Turn_R_out',
eatswim: 'Eat_Swim', eatground: 'Eat_Ground',
};
const src = root.listAnimations()[0];
const srcChannels = src.listChannels();
const EPS = 1e-4;
// The original Take's time/value accessors — captured now, disposed after we've copied
// the windows we need (new clips create their own fresh accessors, so these become dead).
const oldAccessors = new Set();
for (const sm of src.listSamplers()) { oldAccessors.add(sm.getInput()); oldAccessors.add(sm.getOutput()); }
for (const [outName, srcName] of Object.entries(MAP)) {
const [s0, s1] = meta[srcName].seconds;
const anim = doc.createAnimation(outName);
for (const ch of srcChannels) {
const sm = ch.getSampler();
const times = sm.getInput().getArray();
let a = 0; while (a < times.length - 1 && times[a] < s0 - EPS) a++;
let b = times.length - 1; while (b > a && times[b] > s1 + EPS) b--;
const base = times[a];
const nTimes = Float32Array.from(times.slice(a, b + 1), (x) => x - base);
const out = sm.getOutput();
const c = out.getElementSize();
const nOut = out.getArray().slice(a * c, (b + 1) * c);
const inAcc = doc.createAccessor().setType('SCALAR').setArray(nTimes).setBuffer(buffer);
const outAcc = doc.createAccessor().setType(out.getType()).setArray(nOut).setBuffer(buffer);
const ns = doc.createAnimationSampler().setInput(inAcc).setOutput(outAcc).setInterpolation(sm.getInterpolation());
anim.addSampler(ns);
anim.addChannel(doc.createAnimationChannel().setTargetNode(ch.getTargetNode()).setTargetPath(ch.getTargetPath()).setSampler(ns));
}
}
src.dispose(); // remove the original 75s Take
for (const acc of oldAccessors) acc.dispose(); // free its ~10MB of keyframe data
await doc.transform(prune());
await io.write(outPath, doc);
console.log('clips:', root.listAnimations().map((a) => `${a.getName()}(${a.listChannels().length}ch)`).join(', '));
+71
View File
@@ -0,0 +1,71 @@
// Generic GLB structural audit (species-agnostic). For each file: mesh/poly counts,
// materials with alphaMode + doubleSided (the koi's failure was one BLEND double-sided
// mesh), textures, skin/joint count, animation clip names (must carry Idle/Swim in the
// GLB, not just the Maya source), bbox proportions, and a crude "two-tail" check —
// histogram of the rear-slice vertices along the thin (left/right) axis: a single peak
// at centre = one tail; two side peaks with a centre gap = a forked / double tail.
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const path = process.argv[2];
const doc = await io.read(path);
const root = doc.getRoot();
const base = path.split('/').pop();
const meshes = root.listMeshes();
let prims = 0, tris = 0, verts = 0;
const bb = { min: [1e9, 1e9, 1e9], max: [-1e9, -1e9, -1e9] };
const allPos = [];
for (const m of meshes) for (const p of m.listPrimitives()) {
prims++;
const pos = p.getAttribute('POSITION');
const idx = p.getIndices();
tris += idx ? idx.getCount() / 3 : pos.getCount() / 3;
verts += pos.getCount();
const v = [0, 0, 0];
for (let i = 0; i < pos.getCount(); i++) {
pos.getElement(i, v);
allPos.push(v[0], v[1], v[2]);
for (let d = 0; d < 3; d++) { bb.min[d] = Math.min(bb.min[d], v[d]); bb.max[d] = Math.max(bb.max[d], v[d]); }
}
}
const size = bb.max.map((m, d) => m - bb.min[d]);
const mats = root.listMaterials();
const texs = root.listTextures();
const skin = root.listSkins()[0];
const joints = skin ? skin.listJoints() : [];
const anims = root.listAnimations();
// axes: width = thinnest (fish are laterally compressed); length = longest of the other two.
const widthAxis = size.indexOf(Math.min(...size));
const other = [0, 1, 2].filter((d) => d !== widthAxis);
const lenAxis = other[0] === undefined ? 0 : (size[other[0]] >= size[other[1]] ? other[0] : other[1]);
// rear slice = last 22% along the length axis (either end — pick the end with fewer verts = the tail, not the body)
const loEnd = bb.min[lenAxis] + 0.22 * size[lenAxis];
const hiEnd = bb.max[lenAxis] - 0.22 * size[lenAxis];
let loN = 0, hiN = 0;
for (let i = 0; i < allPos.length; i += 3) { const L = allPos[i + lenAxis]; if (L <= loEnd) loN++; else if (L >= hiEnd) hiN++; }
const tailIsLo = loN <= hiN;
const wc = (bb.min[widthAxis] + bb.max[widthAxis]) / 2, wHalf = size[widthAxis] / 2 || 1;
const bins = new Array(11).fill(0);
let tailN = 0;
for (let i = 0; i < allPos.length; i += 3) {
const L = allPos[i + lenAxis];
if (tailIsLo ? L > loEnd : L < hiEnd) continue;
tailN++;
const t = (allPos[i + widthAxis] - wc) / wHalf; // -1..1 across width
bins[Math.max(0, Math.min(10, Math.round((t + 1) * 5)))]++;
}
const centreFrac = tailN ? (bins[4] + bins[5] + bins[6]) / tailN : 0; // fraction near centre-plane
const AX = ['X', 'Y', 'Z'];
console.log(`\n== ${base} ==`);
console.log(` meshes ${meshes.length} prims ${prims} tris ${Math.round(tris)} verts ${verts}`);
console.log(` materials ${mats.length}: ${mats.map((m) => `${m.getName() || '—'}[${m.getAlphaMode()}${m.getDoubleSided() ? ',2SIDED' : ''}]`).join(' ')}`);
console.log(` textures ${texs.length} skin joints ${joints.length}`);
console.log(` anims ${anims.length}: ${anims.map((a) => a.getName()).join(', ') || '(none in GLB!)'}`);
console.log(` bbox size ${AX.map((a, d) => `${a}=${size[d].toFixed(2)}`).join(' ')} (len axis ${AX[lenAxis]}, width axis ${AX[widthAxis]})`);
console.log(` tail slice: ${tailN} verts, ${(centreFrac * 100).toFixed(0)}% near centre-plane → ${centreFrac > 0.5 ? 'SINGLE tail (centred)' : 'possible fork/side-lobes — eyeball it'}`);
const tj = joints.filter((j) => /tail|caudal|fork|fin/i.test(j.getName())).map((j) => j.getName());
if (tj.length) console.log(` fin/tail joints: ${tj.join(', ')}`);
+600 -1
View File
@@ -7,7 +7,17 @@
"name": "glb-split",
"dependencies": {
"@gltf-transform/core": "^4.4.0",
"@gltf-transform/extensions": "^4.4.0"
"@gltf-transform/extensions": "^4.4.0",
"@gltf-transform/functions": "^4.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@gltf-transform/core": {
@@ -33,15 +43,604 @@
"url": "https://github.com/sponsors/donmccurdy"
}
},
"node_modules/@gltf-transform/functions": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@gltf-transform/functions/-/functions-4.4.0.tgz",
"integrity": "sha512-CaSTAVAd2NXNWsxdgvq090rKHqy7AQlcNWV4ec7xtQyS8WEv3S3gVN27ikWmdB8nWEsXUbOIDhtPMLbXI6xDJg==",
"dependencies": {
"@gltf-transform/core": "^4.4.0",
"@gltf-transform/extensions": "^4.4.0",
"ktx-parse": "^1.1.0",
"ndarray": "^1.0.19",
"ndarray-lanczos": "^0.3.0",
"ndarray-pixels": "^5.0.1"
},
"funding": {
"url": "https://github.com/sponsors/donmccurdy"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@types/ndarray": {
"version": "1.0.14",
"resolved": "https://registry.npmjs.org/@types/ndarray/-/ndarray-1.0.14.tgz",
"integrity": "sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg=="
},
"node_modules/cwise-compiler": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/cwise-compiler/-/cwise-compiler-1.1.3.tgz",
"integrity": "sha512-WXlK/m+Di8DMMcCjcWr4i+XzcQra9eCdXIJrgh4TUgh0pIS/yJduLxS9JgefsHJ/YVLdgPtXm9r62W92MvanEQ==",
"dependencies": {
"uniq": "^1.0.0"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/iota-array": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/iota-array/-/iota-array-1.0.0.tgz",
"integrity": "sha512-pZ2xT+LOHckCatGQ3DcG/a+QuEqvoxqkiL7tvE8nn3uuu+f6i1TtpB5/FtWFbxUuVr5PZCx8KskuGatbJDXOWA=="
},
"node_modules/is-buffer": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz",
"integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w=="
},
"node_modules/ktx-parse": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/ktx-parse/-/ktx-parse-1.1.0.tgz",
"integrity": "sha512-mKp3y+FaYgR7mXWAbyyzpa/r1zDWeaunH+INJO4fou3hb45XuNSwar+7llrRyvpMWafxSIi99RNFJ05MHedaJQ=="
},
"node_modules/ndarray": {
"version": "1.0.19",
"resolved": "https://registry.npmjs.org/ndarray/-/ndarray-1.0.19.tgz",
"integrity": "sha512-B4JHA4vdyZU30ELBw3g7/p9bZupyew5a7tX1Y/gGeF2hafrPaQZhgrGQfsvgfYbgdFZjYwuEcnaobeM/WMW+HQ==",
"dependencies": {
"iota-array": "^1.0.0",
"is-buffer": "^1.0.2"
}
},
"node_modules/ndarray-lanczos": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/ndarray-lanczos/-/ndarray-lanczos-0.3.0.tgz",
"integrity": "sha512-5kBmmG3Zvyj77qxIAC4QFLKuYdDIBJwCG+DukT6jQHNa1Ft74/hPH1z5mbQXeHBt8yvGPBGVrr3wEOdJPYYZYg==",
"dependencies": {
"@types/ndarray": "^1.0.11",
"ndarray": "^1.0.19"
}
},
"node_modules/ndarray-ops": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/ndarray-ops/-/ndarray-ops-1.2.2.tgz",
"integrity": "sha512-BppWAFRjMYF7N/r6Ie51q6D4fs0iiGmeXIACKY66fLpnwIui3Wc3CXiD/30mgLbDjPpSLrsqcp3Z62+IcHZsDw==",
"dependencies": {
"cwise-compiler": "^1.0.0"
}
},
"node_modules/ndarray-pixels": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ndarray-pixels/-/ndarray-pixels-5.0.1.tgz",
"integrity": "sha512-IBtrpefpqlI8SPDCGjXk4v5NV5z7r3JSuCbfuEEXaM0vrOJtNGgYUa4C3Lt5H+qWdYF4BCPVFsnXhNC7QvZwkw==",
"dependencies": {
"@types/ndarray": "^1.0.14",
"ndarray": "^1.0.19",
"ndarray-ops": "^1.2.2",
"sharp": "^0.34.0"
}
},
"node_modules/property-graph": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/property-graph/-/property-graph-4.1.0.tgz",
"integrity": "sha512-AvPcP7XECNWy4LGmFQ77k7un4lSKM4eS29PTvW4ck95uYeLxXPWJM7hLuBqK91FaHqCcgJvIUCuNJjjxKE7VKQ=="
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"optional": true
},
"node_modules/uniq": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz",
"integrity": "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA=="
}
}
}
+1 -1
View File
@@ -1 +1 @@
{"name":"glb-split","private":true,"type":"module","dependencies":{"@gltf-transform/core":"^4.4.0","@gltf-transform/extensions":"^4.4.0"}}
{"name":"glb-split","private":true,"type":"module","dependencies":{"@gltf-transform/core":"^4.4.0","@gltf-transform/extensions":"^4.4.0","@gltf-transform/functions":"^4.4.0"}}
+56
View File
@@ -0,0 +1,56 @@
// Extract ONE clip [tStart,tEnd] (seconds) from a GLB's baked Take, rebased to start at 0,
// and report the root joint's net translation over the clip (so we know if a swim cycle
// drifts the fish through space vs. swims in place). Usage:
// node trim-clip.mjs in.glb out.glb 3.667 6.167
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import { prune } from '@gltf-transform/functions';
const [, , inPath, outPath, s0, s1] = process.argv;
const tStart = parseFloat(s0), tEnd = parseFloat(s1), EPS = 1e-4;
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const doc = await io.read(inPath);
const root = doc.getRoot();
// Pass 1: per unique input, find the kept index window + rebase times to 0.
const win = new Map(); // input -> {a,b}
for (const anim of root.listAnimations())
for (const sm of anim.listSamplers()) {
const input = sm.getInput();
if (win.has(input)) continue;
const t = input.getArray();
let a = 0; while (a < t.length - 1 && t[a] < tStart - EPS) a++;
let b = t.length - 1; while (b > a && t[b] > tEnd + EPS) b--;
win.set(input, { a, b });
const base = t[a];
input.setArray(t.slice(a, b + 1).map((x) => x - base));
}
// Pass 2: slice each output to its input's window.
for (const anim of root.listAnimations())
for (const sm of anim.listSamplers()) {
const { a, b } = win.get(sm.getInput());
const out = sm.getOutput();
const c = out.getElementSize();
out.setArray(out.getArray().slice(a * c, (b + 1) * c));
}
// Report root-joint translation drift over the clip (first joint's translation track).
const skin = root.listSkins()[0];
const rootJoint = skin?.listJoints()[0];
let drift = 'n/a';
if (rootJoint) {
for (const anim of root.listAnimations())
for (const ch of anim.listChannels()) {
if (ch.getTargetNode() === rootJoint && ch.getTargetPath() === 'translation') {
const o = ch.getSampler().getOutput().getArray();
const n = o.length / 3;
const dx = o[(n - 1) * 3] - o[0], dy = o[(n - 1) * 3 + 1] - o[1], dz = o[(n - 1) * 3 + 2] - o[2];
drift = `Δ[${dx.toFixed(4)}, ${dy.toFixed(4)}, ${dz.toFixed(4)}] (model units)`;
}
}
}
await doc.transform(prune());
await io.write(outPath, doc);
const a = root.listAnimations()[0];
let tmax = 0; for (const ch of a.listChannels()) { const arr = ch.getSampler().getInput().getArray(); tmax = Math.max(tmax, arr[arr.length - 1]); }
console.log(`wrote ${outPath} | clip duration ${tmax.toFixed(2)}s | root drift ${drift}`);
+39
View File
@@ -0,0 +1,39 @@
// Trim a GLB's baked animation down to a [0, tEnd] window (seconds) — for the angelfish
// pack that's the Idle clip (GLB frames 0109 ≈ 3.633s), which drops ~95% of the animation
// data (the full Take is 75.67s). Geometry/skin/textures are untouched. Shared input
// accessors are sliced once; outputs per-sampler. Usage: node trim-idle.mjs in.glb out.glb 3.633
import { NodeIO } from '@gltf-transform/core';
import { ALL_EXTENSIONS } from '@gltf-transform/extensions';
import { prune } from '@gltf-transform/functions';
const [, , inPath, outPath, tEndStr] = process.argv;
const tEnd = parseFloat(tEndStr);
const io = new NodeIO().registerExtensions(ALL_EXTENSIONS);
const doc = await io.read(inPath);
const root = doc.getRoot();
// Pass 1: per UNIQUE input time-track, compute how many keyframes fall in [0, tEnd]
// (BEFORE mutating — all channels share one input), then slice the input once.
const keepByInput = new Map();
for (const anim of root.listAnimations())
for (const s of anim.listSamplers()) {
const input = s.getInput();
if (keepByInput.has(input)) continue;
const times = input.getArray();
let last = 0;
for (let i = 0; i < times.length; i++) { if (times[i] <= tEnd + 1e-6) last = i; else break; }
const keep = last + 1;
keepByInput.set(input, keep);
if (keep >= 2 && keep < times.length) input.setArray(times.slice(0, keep));
}
// Pass 2: slice every sampler's OUTPUT to its input's keep count.
for (const anim of root.listAnimations())
for (const s of anim.listSamplers()) {
const keep = keepByInput.get(s.getInput());
const output = s.getOutput();
const comps = output.getElementSize();
if (keep * comps < output.getArray().length) output.setArray(output.getArray().slice(0, keep * comps));
}
await doc.transform(prune()); // repack buffers + drop the orphaned (trimmed-away) data
await io.write(outPath, doc);
console.log('wrote', outPath);