Compare commits

...

171 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

445 backend + 36 frontend tests pass.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

425 backend tests green.

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

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

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

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

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

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

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

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

32 tests green; build clean.

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

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

32 tests green; build clean.

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

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

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

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

32 frontend tests green; build clean.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:58:55 -04:00
thejayman77 8cf061d214 home3: small-joys rail polished to 'little jewels' (CD spec — focal points, watermarks, accent tags, gradients)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:36:12 -04:00
thejayman77 3c519a5f50 home3: small-joys rail (WOTD/QOTD crafted + 'good thing today' SOON), whole-word headline clamp, 16px gaps
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:18:00 -04:00
thejayman77 2173802c70 home3: rename Daily Moment -> Entertainment; add skinny Word/Quote-of-the-day strip (placeholder)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:39:02 -04:00
thejayman77 de94d179bd home3: Art pinned top, Play/Moment pair vertically centered in the space below (FIX1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:14:37 -04:00
thejayman77 65cb770514 home3: Art pinned top, pair pinned bottom, space distributed between (FIX1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:11:02 -04:00
thejayman77 f4b20bdf79 home3: center the right column in the space (match FIX1), not top-aligned
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:08:08 -04:00
thejayman77 2e3cc5f382 home3: stop card stretch (independent right column, float not stretch); News gets two links (Read article + Read more good news)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 08:59:10 -04:00
thejayman77 5306c65c7b home3: consistent half-column bleed + equal-size side games on Play; FIX1 layout (fade-out gist, read-time meta, trimmed Art headline)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 06:33:57 -04:00
thejayman77 703ea22e0b home3 Play card: center word search, balance gaps, full wordle visible
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 06:06:44 -04:00
thejayman77 c95ca8775a home3 Play card: fit word-search panel + bolder BYTES, EAT/YTE tiles, richer amber
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 05:47:10 -04:00
thejayman77 a8762a1dd8 home3 Play card: fix Wordle tile colours (TYPE→BYTE, accurate green/amber)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 05:27:35 -04:00
thejayman77 79fb9b44fc home3 Play card: 'bleeding boards' graphic (Wordle + BYTES word-search + Memory Match)
Decorative tiles (no live answer = no spoilers); placeholder copy. Built from CD's
Play Card Graphic handoff.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

366 tests green.

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

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

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

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

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

Backend + frontend tests green.

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

364 tests green.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 16:56:49 -04:00
thejayman77 59ff48ae90 Game share-loop: instrument funnel, deep-link shares, /play metadata
Sharpen the existing daily-game share loop into something measurable (per Codex's
"instrument what you have, then feed people into it" plan), ahead of a Show HN launch.

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

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

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

Tests: backend 352, frontend 27.

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 22:10:44 -04:00
thejayman77 7279b18fdc Articles inspector hardening (Codex audit): http(s)-only links + clamp reason
- Article titles render as external links ONLY for http(s) URLs (matches
  ArticleCard's safeHref rule); anything else is plain text — no unsafe schemes.
- reason_text clamped to 2 lines with a full-text title tooltip, so a long
  classifier reason can't make the panel visually noisy.
(Admin gate + source-scoped query confirmed already in place.)
2026-06-12 21:58:13 -04:00
thejayman77 ddcfab3a11 Admin: source Articles inspector (verify metrics against real evidence)
New per-row "Articles" button on the Sources table expands a read-only inline
panel of the source's ACTUAL ingested articles — so the automated metrics
(paywall/image/acceptance/duplicate) can be verified against evidence instead of
trusted blind. Distinct from "Check" (which re-samples the LIVE feed for
would-pass quality); this shows what's already in the DB, which is what the table
metrics are computed from.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 21:37:51 -04:00
thejayman77 64339aafb0 Games: in-progress hub status + distribution-aware word-search placement (Codex)
- Play hub: word cards now surface IN-PROGRESS games too (not just won/lost) so
  "continue on another device" shows at a glance — card reads "5:3…" and the
  selection option says "Continue · 3/6".
- Word Search generator: replace "prefer any crossing" with a SCORED placement —
  score = overlap*4 - local crowding (filled neighbours that aren't crossings) —
  then pick among the best ~20%. Keeps the organic interlocking but spreads words
  across the board instead of clumping around the first-placed (longest) words.
  Every word still placed (tests green). NOTE: changes today's grid layouts, so
  an in-progress word search resets once.

237 pytest + 11 vitest green.

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

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

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

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

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

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

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

235→ tests + 11 vitest green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 13:35:20 -04:00
thejayman77 2ef0efd909 Perf: skip needless dedup re-cluster + interlock word-search grids
Two things found while chasing the recurring ~15min slowness:

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 12:35:01 -04:00
172 changed files with 19006 additions and 1144 deletions
+6
View File
@@ -1,8 +1,14 @@
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
.venv/ .venv/
node_modules/
*.egg-info/ *.egg-info/
data/*.sqlite3 data/*.sqlite3
data/*.sqlite3-* data/*.sqlite3-*
data/*.db
data/geo_audit*.json
logs/ 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. Calm, constructive news — local-first ingestion, scoring, and a daily brief.
(The Python package and CLI are named `goodnews` for historical reasons; the (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. The first milestone is intentionally small: collect public RSS/Atom metadata, dedupe it, store short source-provided snippets, and attach early reason-coded heuristic scores. It does not store full article bodies.
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+177
View File
@@ -0,0 +1,177 @@
# SNAPSHOT (read-only) of the live Caddy config.
# Live source of truth: /home/jay/srv/caddy/caddy-config/Caddyfile (mounted into the 'caddy' container).
# Captured so the upbeatbytes try_files {path} {path}.html change is tracked. Do not edit here expecting it to deploy.
{
email thejayman77@gmail.com
}
tjm77.com, www.tjm77.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
root * /srv/sites/tjm77
file_server
encode gzip zstd
log {
output file /data/access-tjm77.log
}
}
jsj-designs.com, www.jsj-designs.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
root * /srv/sites/jsj
file_server
encode gzip zstd
log {
output file /data/access-jsj.log
}
}
# Canonical host = apex. www redirects to it BEFORE the app, so OAuth always
# starts from the same host its callback uses (the ub_oauth cookie is host-only;
# starting from www then bouncing to the apex callback loses it → error=google).
www.upbeatbytes.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
redir https://upbeatbytes.com{uri} permanent
}
upbeatbytes.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
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
}
# Everything else → the static SvelteKit SPA. try_files falls back to
# index.html so deep client routes (e.g. /auth/verify) boot the app
# instead of 404ing.
handle {
root * /srv/sites/upbeatbytes
# Hidden in-progress prototypes — keep crawlers out at the HTTP level (the JS
# <meta robots> isn't seen by non-JS bots since the static shell is generic).
# Only admin stays out of the index now — news, art, play, and the joy pages are public.
@hidden path /admin /admin.html
header @hidden X-Robots-Tag "noindex, nofollow"
# Content-hashed assets never change for a given URL — cache them forever.
@immutable path /_app/immutable/*
header @immutable Cache-Control "public, max-age=31536000, immutable"
# Static texture + font assets — large/unchanging. Cache them forever like
# immutable assets; rename the file if one ever changes.
@assets path /textures/* /fonts/*
header @assets Cache-Control "public, max-age=31536000, immutable"
# The SPA shell: "/" and extensionless client routes (try_files → index.html).
# Briefly cacheable at the CDN edge (s-maxage) so a first paint never depends
# on this origin's uplink; browsers still revalidate every visit (max-age=0).
# A deploy propagates within ≤2min and old immutable chunks are kept for a
# 14-day grace window, so a briefly-stale shell still boots cleanly.
# (Requires a Cloudflare Cache Rule marking these paths eligible — CF does
# not cache HTML by default.)
@shell {
not path /_app/immutable/*
not path *.*
}
header @shell Cache-Control "public, max-age=0, s-maxage=120, stale-while-revalidate=600"
# Mutable FILES (service worker, version manifest, webmanifest, word lists,
# icons) must revalidate every time — a pinned stale service worker is the
# classic blank-screen cause behind a CDN.
@revalidate {
not path /_app/immutable/*
not path /textures/*
not path /fonts/*
path *.*
}
header @revalidate Cache-Control "no-cache"
# Serve a route's own prerendered HTML when it exists (e.g. /play -> play.html,
# which carries its own canonical/OG metadata), else fall back to the SPA shell.
# Cache-Control matchers above run on the ORIGINAL extensionless path, so /play
# still gets the @shell header before this rewrite.
try_files {path} {path}.html /index.html
file_server
}
log {
output file /data/access-upbeatbytes.log
}
}
git.tjm77.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
reverse_proxy gitea:3000
encode gzip zstd
log {
output file /data/access-gitea.log
}
}
api.tjm77.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
encode gzip zstd
# Recipe finder (What can I make? web tier) — must precede the arbiter catch-all.
@finder path /v1/find-recipes /v1/find-recipes/*
handle @finder {
reverse_proxy finder:8090
}
# Per-device token registration → auth service.
@register path /v1/register
handle @register {
reverse_proxy auth:8070
}
# In-app feedback relay → auth service (validates the device token + SMTP-sends).
# Keeps the arbiter a pure LLM gateway. Must precede the arbiter catch-all.
@feedback path /v1/feedback
handle @feedback {
reverse_proxy auth:8070
}
# App update policy — public static JSON (no secrets). Edit ~/srv/sites/api/app-version.json
# to change what testers are prompted to update to; no redeploy needed. Precedes the catch-all.
@appversion path /v1/app-version
handle @appversion {
root * /srv/sites
rewrite * /api/app-version.json
header Content-Type application/json
file_server
}
# LLM Arbiter (everything else); Bearer auth enforced by the arbiter.
handle {
reverse_proxy arbiter:8080
}
log {
output file /data/access-arbiter.log
}
}
}
+2 -2
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env bash #!/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. # rebuild/restart the API container, and reload Caddy. One command to redeploy.
set -euo pipefail 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 \ https://upbeatbytes.com/logo-email.png \
|| echo " ⚠ logo-email.png is not being served — the digest masthead would break!" || 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 #!/usr/bin/env bash
# Sync the built static site to the live root in an order that avoids deploy-race # 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 # blank screens. rsync isn't atomic, so a naïve `rsync --delete` can briefly serve
# `rsync --delete` can briefly serve a NEW index.html that points at chunks not # a NEW index.html that points at chunks not synced yet (→ failed load), or delete
# synced yet (→ failed load), or delete old chunks an in-flight client still # old chunks an in-flight client still needs. So the order is:
# needs. And even once synced, a new shell can point at chunks that aren't warm # 1. new hashed chunks first, and DON'T prune old ones (14-day grace window)
# at the CDN edge yet, so the unlucky first visitor fetches them cold from the # 2. other static assets (version.json, icons…), pruning removed files
# (residential) origin — the post-deploy slow-load window. So the order is: # 3. the shell HTML — only once its chunks exist
# 1. new hashed chunks first, and DON'T prune old ones (grace window) # 4. the service worker last — a returning client adopts it only after the rest
# 2. WARM those chunks at the edge BEFORE the shell goes live, so a new shell # Old immutable chunks are pruned after the grace window to bound disk growth.
# never references cold chunks #
# 3. other static assets (version.json, icons…), pruning removed files # NOTE: there is intentionally NO edge cache-warming. Cloudflare is DNS-only
# 4. the shell HTML — only once its chunks exist AND are warm # (grey-cloud) for upbeatbytes.com — no proxy/CDN/POP — so warming would just GET
# 5. the service worker last — a returning client adopts it only after the rest # every asset from the residential origin (already µs-fast static): a no-op that
# 6. warm the shell + key routes after publish # generated lots of internal request volume. Removed 2026-06-29.
# 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.
set -euo pipefail set -euo pipefail
src="$1"; site="$2" 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). # 1. New hashed chunks first (old ones kept — 14-day grace for in-flight clients).
rsync -a "$src/_app/immutable/" "$site/_app/immutable/" rsync -a "$src/_app/immutable/" "$site/_app/immutable/"
# 2. Warm the chunks BEFORE publishing the shell, so the new shell never points # 2. Other static assets (prune removed files), then 3. the shell, then 4. the SW.
# 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.
rsync -a --delete \ rsync -a --delete \
--exclude='_app/immutable/***' --exclude='index.html' --exclude='service-worker.js' \ --exclude='_app/immutable/***' --exclude='index.html' --exclude='service-worker.js' \
"$src/" "$site/" "$src/" "$site/"
rsync -a "$src/index.html" "$site/index.html" rsync -a "$src/index.html" "$site/index.html"
rsync -a "$src/service-worker.js" "$site/service-worker.js" 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. # Bound disk growth: prune immutable chunks older than the grace window.
find "$site/_app/immutable" -type f -mtime +14 -delete 2>/dev/null || true 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.
+8
View File
@@ -7,6 +7,9 @@
"": { "": {
"name": "goodnews-web", "name": "goodnews-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": {
"three": "^0.184.0"
},
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
"@sveltejs/kit": "^2.8.0", "@sveltejs/kit": "^2.8.0",
@@ -1540,6 +1543,11 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/three": {
"version": "0.184.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg=="
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+4
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite dev --host", "dev": "vite dev --host",
"build": "vite build", "build": "vite build",
"postbuild": "node scripts/patch-static-heads.mjs",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run" "test": "vitest run"
}, },
@@ -17,5 +18,8 @@
"svelte": "^5.1.0", "svelte": "^5.1.0",
"vite": "^6.0.0", "vite": "^6.0.0",
"vitest": "^4.1.8" "vitest": "^4.1.8"
},
"dependencies": {
"three": "^0.184.0"
} }
} }
+84
View File
@@ -0,0 +1,84 @@
// Post-build: give specific prerendered shells their OWN social/canonical metadata.
//
// The app is a static SPA (ssr=false), so every prerendered shell ships app.html's
// HOMEPAGE <head> — meaning a shared /play or /word link previews as the news homepage,
// and its canonical points at "/". Client svelte:head can't fix that for non-JS scrapers
// (Twitter/Slack/iMessage) or for canonical dedup. So we rewrite each page's static head
// here, at build time. Deep-linked variants (e.g. /play?game=…) inherit the same file.
import { readFile, writeFile } from 'node:fs/promises';
const BASE = 'https://upbeatbytes.com';
// Per-page <head> overrides. Keep titles/descriptions in sync with each page's intent.
const PAGES = [
{
file: 'news.html', path: '/news',
title: 'News · upbeatBytes — calm, constructive news',
desc: 'Calm, constructive news, newest first — and a daily Highlights brief. ' +
'No ads, no paywalls, no doomscrolling.',
},
{
file: 'play.html', path: '/play',
title: 'Play · upbeatBytes — calm daily games',
desc: 'A calm set of daily games — Daily Word, Word Search, Bloom, and Memory Match. ' +
'A friendly little break from the doomscroll.',
},
{
file: 'word.html', path: '/word',
title: 'Word of the Day · upbeatBytes',
desc: 'A new uplifting word every day — its meaning in plain language, how to say it, and how to use it.',
},
{
file: 'quote.html', path: '/quote',
title: 'Quote of the Day · upbeatBytes',
desc: 'A hopeful, hand-picked quote each day, with a short note on what it means.',
},
{
file: 'onthisday.html', path: '/onthisday',
title: 'On This Day · upbeatBytes',
desc: 'One genuinely good thing that happened on this day in history.',
},
{
file: 'art.html', path: '/art',
title: 'Daily Art · upbeatBytes',
desc: "A masterwork a day from the world's open museum collections — beautifully framed, " +
'with a short note on what youre looking at.',
},
];
function subsFor(url, title, desc) {
return [
[/<title>[\s\S]*?<\/title>/, `<title>${title}</title>`],
[/<meta name="description" content="[^"]*"\s*\/>/, `<meta name="description" content="${desc}" />`],
[/<link rel="canonical" href="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<link rel="canonical" href="${url}" />`],
[/<meta property="og:title" content="[^"]*"\s*\/>/, `<meta property="og:title" content="${title}" />`],
[/<meta property="og:description" content="[^"]*"\s*\/>/, `<meta property="og:description" content="${desc}" />`],
[/<meta property="og:url" content="https:\/\/upbeatbytes\.com\/"\s*\/>/, `<meta property="og:url" content="${url}" />`],
[/<meta name="twitter:title" content="[^"]*"\s*\/>/, `<meta name="twitter:title" content="${title}" />`],
[/<meta name="twitter:description" content="[^"]*"\s*\/>/, `<meta name="twitter:description" content="${desc}" />`],
];
}
for (const { file, path, title, desc } of PAGES) {
const fileUrl = new URL(`../build/${file}`, import.meta.url);
let html;
try {
html = await readFile(fileUrl, 'utf8');
} catch {
console.error(`patch-static-heads: build/${file} is missing (route renamed/removed?)`);
process.exit(1);
}
const missed = [];
for (const [re, repl] of subsFor(BASE + path, title, desc)) {
if (!re.test(html)) { missed.push(re.source.slice(0, 40)); continue; }
html = html.replace(re, repl);
}
// Fail loudly if the homepage head drifted — better a broken build than silently
// shipping the wrong preview/canonical again.
if (missed.length) {
console.error(`patch-static-heads: ${file} — these head tags were not found (app.html changed?):\n ` + missed.join('\n '));
process.exit(1);
}
await writeFile(fileUrl, html);
console.log(`patch-static-heads: rewrote build/${file} head → ${path}`);
}
+6 -3
View File
@@ -8,7 +8,7 @@
font-display: swap; 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, Sand, sea, and sun: warm paper surfaces, a vivid-azure accent, gold highlight,
a serif voice for headlines, strong readable contrast, generous space. a serif voice for headlines, strong readable contrast, generous space.
No urgency colors (no red). Built around the logo's #0083ad azure. */ No urgency colors (no red). Built around the logo's #0083ad azure. */
@@ -34,7 +34,10 @@
} }
* { box-sizing: border-box; } * { 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 { body {
margin: 0; margin: 0;
background: var(--bg); background: var(--bg);
@@ -72,5 +75,5 @@ button { font-family: inherit; cursor: pointer; }
/play and always removed on navigation via effect cleanup. Mobile only. */ /play and always removed on navigation via effect cleanup. Mobile only. */
@media (max-width: 720px) { @media (max-width: 720px) {
html.playing-game, html.playing-game body { overflow: hidden; } 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" /> <link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#0083ad" /> <meta name="theme-color" content="#0083ad" />
<meta name="description" content="Calm, constructive news worth your attention — and nothing that isn't." /> <meta name="description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
<title>Upbeat Bytes — calm, constructive news</title> <title>upbeatBytes — a calmer, brighter corner of the internet</title>
<link rel="canonical" href="https://upbeatbytes.com/" /> <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:type" content="website" />
<meta property="og:title" content="Upbeat Bytes — calm, constructive news" /> <meta property="og:title" content="upbeatBytes — a calmer, brighter corner of the internet" />
<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: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 property="og:url" content="https://upbeatbytes.com/" />
<meta name="twitter:card" content="summary" /> <meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="Upbeat Bytes — calm, constructive news" /> <meta name="twitter:title" content="upbeatBytes — a calmer, brighter corner of the internet" />
<meta name="twitter:description" content="Calm, constructive news, summarized — get the gist, go deeper only if you want." /> <meta name="twitter:description" content="A calmer, brighter corner of the internet: good news, daily art, small games, and little resets." />
%sveltekit.head% %sveltekit.head%
<style> <style>
#boot-fallback { #boot-fallback {
@@ -128,9 +128,9 @@
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
<div id="boot-fallback" role="alert" aria-live="polite"> <div id="boot-fallback" role="alert" aria-live="polite">
<div class="bf"> <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> <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>
</div> </div>
</body> </body>
+98
View File
@@ -0,0 +1,98 @@
<script>
// Full searchable emoji picker for the Publishing Desk. Data is bundled locally
// (emoji-data.js) — no runtime/CDN fetch. Calls onpick(char) for each selection;
// stays open so several can be inserted in a row. Recents persist in localStorage.
import { EMOJI_GROUPS } from './emoji-data.js';
let { onpick } = $props();
let q = $state('');
let recent = $state([]);
const RECENT_KEY = 'ub.pub.emojiRecent';
$effect(() => {
try { recent = JSON.parse(localStorage.getItem(RECENT_KEY) || '[]'); } catch { recent = []; }
});
function pick(c) {
onpick(c);
const next = [c, ...recent.filter((x) => x !== c)].slice(0, 24);
recent = next;
try { localStorage.setItem(RECENT_KEY, JSON.stringify(next)); } catch { /* private mode */ }
}
// search across name + keyword slug; null when the box is empty (→ show grouped view)
const matches = $derived.by(() => {
const term = q.trim().toLowerCase();
if (!term) return null;
const res = [];
for (const g of EMOJI_GROUPS)
for (const e of g.emojis)
if (e.n.includes(term) || e.k.includes(term)) res.push(e);
return res;
});
let sections = {}; // group name -> section element, for the jump tabs
function jump(name) { sections[name]?.scrollIntoView({ block: 'start', behavior: 'smooth' }); }
</script>
<div class="ep">
<input class="ep-search" placeholder="Search emoji…" bind:value={q} autocomplete="off" />
{#if !q.trim()}
<div class="ep-tabs">
{#if recent.length}<button type="button" title="Recent" onclick={() => jump('__recent')}>🕘</button>{/if}
{#each EMOJI_GROUPS as g (g.name)}
<button type="button" title={g.name} onclick={() => jump(g.name)}>{g.emojis[0].c}</button>
{/each}
</div>
{/if}
<div class="ep-scroll">
{#if matches}
{#if matches.length}
<div class="ep-grid">
{#each matches as e (e.c)}<button type="button" class="ep-em" title={e.n} onclick={() => pick(e.c)}>{e.c}</button>{/each}
</div>
{:else}
<p class="ep-none">No emoji match “{q}”.</p>
{/if}
{:else}
{#if recent.length}
<div class="ep-sec" bind:this={sections['__recent']}>
<div class="ep-h">Recent</div>
<div class="ep-grid">
{#each recent as c (c)}<button type="button" class="ep-em" onclick={() => pick(c)}>{c}</button>{/each}
</div>
</div>
{/if}
{#each EMOJI_GROUPS as g (g.name)}
<div class="ep-sec" bind:this={sections[g.name]}>
<div class="ep-h">{g.name}</div>
<div class="ep-grid">
{#each g.emojis as e (e.c)}<button type="button" class="ep-em" title={e.n} onclick={() => pick(e.c)}>{e.c}</button>{/each}
</div>
</div>
{/each}
{/if}
</div>
</div>
<style>
.ep { width: 300px; }
.ep-search { width: 100%; box-sizing: border-box; font: inherit; font-size: 0.85rem; padding: 6px 9px;
border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); }
.ep-tabs { display: flex; flex-wrap: wrap; gap: 1px; margin: 6px 0; }
.ep-tabs button { font-size: 1.05rem; line-height: 1; padding: 3px; border: 0; border-radius: 6px;
background: transparent; cursor: pointer; }
.ep-tabs button:hover { background: var(--accent-soft); }
.ep-scroll { max-height: 230px; overflow-y: auto; margin-top: 4px; }
.ep-sec { scroll-margin-top: 2px; }
.ep-h { position: sticky; top: 0; background: var(--card, var(--bg)); font-size: 0.72rem; font-weight: 700;
color: var(--muted); text-transform: uppercase; letter-spacing: .04em; padding: 4px 2px; }
.ep-grid { display: grid; grid-template-columns: repeat(8, 1fr); }
.ep-em { font-size: 1.25rem; line-height: 1; padding: 4px 0; border: 0; border-radius: 7px;
background: transparent; cursor: pointer; }
.ep-em:hover { background: var(--accent-soft); }
.ep-none { font-size: 0.82rem; color: var(--muted); padding: 8px 2px; }
</style>
+52
View File
@@ -3,6 +3,7 @@
// admin dashboard tell "new vs returning" in aggregate. Server hashes it. // admin dashboard tell "new vs returning" in aggregate. Server hashes it.
const VISITOR_KEY = 'goodnews:visitor'; const VISITOR_KEY = 'goodnews:visitor';
const VISITDAY_KEY = 'goodnews:visitday'; const VISITDAY_KEY = 'goodnews:visitday';
const ENGAGEDDAY_KEY = 'goodnews:engagedday';
export function visitorId() { export function visitorId() {
try { try {
@@ -35,6 +36,22 @@ export function track(kind, articleId = 0) {
} }
} }
// Deep link for a shared game result: full https:// URL straight to the exact game +
// variant (not generic /play), tagged so we can attribute arrivals to the share loop.
const SITE = 'https://upbeatbytes.com';
export function gameShareUrl(game, variant) {
const v = variant ? `&v=${encodeURIComponent(variant)}` : '';
return `${SITE}/play?game=${game}${v}&utm_source=game_share`;
}
// Per-game funnel: trackGame('word', 'started'|'completed'|'shared'). Kinds must mirror
// the backend allowlist (_GAME_EVENT_KINDS in api.py). Deduped per game/kind/day server-side.
const GAME_NAMES = ['word', 'wordsearch', 'bloom', 'match'];
const GAME_EVENTS = ['started', 'completed', 'shared', 'arrival'];
export function trackGame(game, event) {
if (GAME_NAMES.includes(game) && GAME_EVENTS.includes(event)) track(`${game}_${event}`);
}
// Count a visit at most once per day per device. // Count a visit at most once per day per device.
export function trackVisit() { export function trackVisit() {
try { try {
@@ -47,3 +64,38 @@ export function trackVisit() {
/* ignore */ /* 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); 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 shareOpen = $state(false);
let copied = $state(''); let copied = $state('');
const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share; const canNativeShare = typeof navigator !== 'undefined' && !!navigator.share;
@@ -100,12 +100,13 @@
> >
{#if showImage} {#if showImage}
<a class="media" href={summaryHref} onclick={opened}> <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" <img src={article.image_url} alt="" loading="lazy" referrerpolicy="no-referrer"
onerror={() => { failed = true; onimageerror?.(); }} /> onerror={() => { failed = true; onimageerror?.(); }} />
</a> </a>
{:else if usePlaceholder} {:else if usePlaceholder}
<a class="media placeholder" href={summaryHref} onclick={opened} style="--c:{accentColor}" tabindex="-1" aria-hidden="true"> <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> </a>
{/if} {/if}
@@ -0,0 +1,484 @@
<script>
import { getJSON, postJSON } from '$lib/api.js';
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
import { trackGame, gameShareUrl } from '$lib/analytics.js';
// mode: 'daily' (shared, synced, ritual) | 'free' (local-only, infinite wheels)
// format: 'center' (center letter required) | 'wild' (any word from the 7)
let { mode = 'daily', format = 'center', onstatus } = $props();
// $derived (not const) so a prop change can't leave the mode "stuck".
let isFree = $derived(mode === 'free');
let isWild = $derived(format === 'wild');
let center = $state('');
let outer = $state([]); // Center Circle: 6 petals (order shuffles)
let wildRing = $state([]); // Wild Bloom: all 7 letters, equal (order shuffles)
let accepted = $state(new Set()); // sha256(salt:word) hex — no plaintext answers
let maxScore = $state(0);
let tiers = $state([]);
let date = $state(''); // daily mode
let seed = $state(''); // free mode (lets us resume the same wheel)
let found = $state([]); // plaintext words this device has found
let current = $state(''); // the word being built
let loading = $state(true);
let ready = $state(false);
let message = $state('');
let shake = $state(false);
let pulse = $state(false); // bloom flourish on a pangram
let fullShown = $state(false); // Full Bloom celebration latch
let reportWord = $state(''); // a rejected real-looking word offered to flag
let reported = $state(false); // "thanks" after flagging
let salt = $derived(isFree ? seed : date); // the hash salt + storage discriminator
const stateKey = $derived(isFree ? `goodnews:bloom:free:${format}` : `goodnews:bloom:${date}`);
// The active letter set matches what's actually on screen (Wild ring vs center+petals).
let activeLetters = $derived(isWild ? wildRing : [center, ...outer]);
let wheel = $derived(new Set(activeLetters));
// The flower's center: azure (Center Circle = "required") vs slate (Wild =
// "just a letter"); both bloom to jewel hues as you find words.
let centerColor = $derived(found.length ? bloomColor(found.length) : (isWild ? '#5b6b78' : 'var(--accent)'));
function scoreWord(w) {
let s = w.length === 4 ? 1 : w.length;
if (isPangram(w)) s += 7;
return s;
}
function isPangram(w) {
return w.length >= 7 && [...wheel].every((l) => w.includes(l));
}
// Curated calm jewel hues (all readable under the white letter). The flower's
// center blooms a fresh hue with each found word; each found chip keeps its own.
const PALETTE = ['#0083ad', '#117a8b', '#2e8b57', '#b8732e', '#c0563f', '#bb4a63',
'#c2569b', '#8e5bb0', '#6a5fc4', '#4f6fc6', '#b14a8a', '#2f8f8f'];
function bloomColor(n) { return n ? PALETTE[(n * 5) % PALETTE.length] : 'var(--accent)'; }
function hueFor(w) {
let h = 0;
for (const c of w) h = (h * 31 + c.charCodeAt(0)) >>> 0;
return PALETTE[h % PALETTE.length];
}
let score = $derived(found.reduce((s, w) => s + scoreWord(w), 0));
let tierIdx = $derived.by(() => { let idx = 0; tiers.forEach((t, i) => { if (score >= t.score) idx = i; }); return idx; });
let tier = $derived(tiers[tierIdx] || { name: '', score: 0 });
let nextTier = $derived(tiers[tierIdx + 1] || null);
// The ring fills toward the NEXT goal — the next tier, or (at the top tier)
// Full Bloom — so a full ring never falsely implies "every word found."
let progress = $derived.by(() => {
const start = tier.score;
const target = nextTier ? nextTier.score : maxScore;
return target > start ? Math.min(1, (score - start) / (target - start)) : 1;
});
let fullBloom = $derived(maxScore > 0 && score >= maxScore);
// Reached the top tier (Flourishing) — the daily's "saw it through" point;
// persisted so the calm-set ritual can read it without the puzzle payload.
let reachedTop = $derived(score >= (tiers.find((t) => t.name === 'Flourishing')?.score ?? Infinity));
async function sha256hex(str) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, '0')).join('');
}
function readStored() {
try { return JSON.parse(localStorage.getItem(stateKey) || 'null'); } catch { return null; }
}
async function load(forceNew = false) {
loading = true; ready = false;
found = []; current = ''; message = ''; fullShown = false;
try {
let p, savedFound = [];
if (isFree) {
const stored = forceNew ? null : readStored(); // resume the same wheel by its seed
const q = stored?.seed ? `&seed=${encodeURIComponent(stored.seed)}` : '';
p = await getJSON(`/api/puzzle/bloom/free?format=${format}${q}`);
seed = p.seed;
if (stored && stored.seed === p.seed && Array.isArray(stored.found)) savedFound = stored.found;
} else {
p = await getJSON('/api/puzzle/bloom'); // holds NO plaintext words
date = p.date;
const stored = readStored();
if (stored && Array.isArray(stored.found)) savedFound = stored.found;
}
center = p.center; outer = p.outer;
if (isWild) wildRing = [center, ...outer]; // 7 equal petals — no required center
accepted = new Set(p.accepted); maxScore = p.max_score; tiers = p.tiers;
await restore(savedFound);
} catch {
message = isFree ? 'Could not load a wheel.' : 'Could not load todays Bloom.';
}
loading = false;
requestAnimationFrame(() => (ready = true));
if (!isFree) syncNow(); // free play is local-only — never syncs
}
function newWheel() { if (!loading) load(true); } // free: deal a fresh wheel
async function restore(savedFound) {
// Keep only finds valid for THIS wheel (drops stale/junk / wrong-seed words).
const valid = [];
for (const w of (savedFound || [])) {
if (typeof w === 'string' && accepted.has(await sha256hex(`${salt}:${w}`))) valid.push(w);
}
found = valid;
persist();
if (!isFree) onstatus?.(summary());
}
function persist() {
// `full` lets the hub card show "Full Bloom" after reload; `seed` lets free
// play resume the same wheel.
const data = isFree ? { seed, found, score, full: fullBloom, top: reachedTop }
: { found, score, full: fullBloom, top: reachedTop };
try { localStorage.setItem(stateKey, JSON.stringify(data)); } catch { /* ignore */ }
if (!isFree) onstatus?.(summary()); // only the daily feeds the hub status
}
function summary() {
return { date, count: found.length, tier: tier.name, full: fullBloom };
}
// --- cross-device sync (signed-in; union of found words merged server-side) ---
let serverStats = $state(null);
let syncTimer;
async function adopt(merged) {
if (!merged || !Array.isArray(merged.found)) return;
// The server state is authoritative: it has already merged this device's push
// with any other device AND sanitized against today's accept set, so we adopt
// it wholesale — which also removes any local words the server dropped.
const valid = [];
for (const w of merged.found) {
if (typeof w === 'string' && accepted.has(await sha256hex(`${date}:${w}`))) valid.push(w);
}
found = valid;
persist();
}
async function syncNow() {
if (isFree) return; // free play is local-only
const d = date;
const merged = await pushGameState('bloom', '', d, { found, score });
if (d === date) await adopt(merged);
serverStats = await fetchGameStats('bloom', '');
}
function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1000); }
function flash(m) { message = m; setTimeout(() => (message = ''), 1300); }
function shakeIt() { shake = true; setTimeout(() => (shake = false), 400); }
function bloomPulse() { pulse = true; setTimeout(() => (pulse = false), 700); }
function tap(l) { if (!loading) current += l; }
// Touch/mouse act on pointerdown (instant, and preventDefault stops focus-scroll).
// There is deliberately NO onclick — a click handler alongside pointerdown
// double-enters. Keyboard users type directly (global handler) or can Tab to a
// petal and press Enter/Space (onkeydown) — neither collides with pointer events.
function petalDown(e, l) { e.preventDefault(); tap(l); }
function petalKey(e, l) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); tap(l); } }
function del() { current = current.slice(0, -1); }
// Fisher-Yates, retried until it actually feels shuffled (≥minMoved positions
// change) — sort(()=>Math.random()-0.5) is biased and often leaves letters put.
function shuffled(arr, minMoved) {
let out = arr;
for (let t = 0; t < 12; t++) {
const a = [...arr];
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
out = a;
if (a.filter((l, i) => l !== arr[i]).length >= minMoved) break;
}
return out;
}
function shuffle() {
if (isWild) wildRing = shuffled(wildRing, 4);
else outer = shuffled(outer, 4);
}
async function submit() {
const w = current.trim().toLowerCase();
current = '';
reportWord = ''; reported = false;
if (!w) return;
if (w.length < 4) { flash('Too short'); shakeIt(); return; }
if (!isWild && !w.includes(center)) { flash('Needs the center letter'); shakeIt(); return; }
if ([...w].some((l) => !wheel.has(l))) { flash('Letters not in the bloom'); shakeIt(); return; }
if (found.includes(w)) { flash('Already found'); shakeIt(); return; }
const h = await sha256hex(`${salt}:${w}`);
if (!accepted.has(h)) {
flash('Not in the word list'); shakeIt();
reportWord = w; // it's a well-formed wheel word — offer to flag it
setTimeout(() => { if (reportWord === w) reportWord = ''; }, 7000);
return;
}
const pan = isPangram(w);
if (found.length === 0) trackGame('bloom', 'started'); // first word = started
found = [w, ...found];
flash(pan ? 'Pangram! 🌸 +' + scoreWord(w) : '+' + scoreWord(w));
if (pan) bloomPulse();
persist();
if (!isFree) syncSoon();
if (found.reduce((s, x) => s + scoreWord(x), 0) >= maxScore && !fullShown) {
fullShown = true; bloomPulse(); trackGame('bloom', 'completed'); // Full Bloom
}
}
// Spoiler-safe share: achievement + a word-length breakdown, never the words.
let copied = $state(false);
function share() {
const byLen = {};
found.forEach((w) => (byLen[w.length] = (byLen[w.length] || 0) + 1));
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 = `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); });
}
// Quiet "this should count?" — flags a rejected word for the admin queue.
async function reportMissing() {
const w = reportWord;
if (!w) return;
reported = true;
try {
await postJSON('/api/bloom/report', {
word: w, date: isFree ? null : date, mode, format,
letters: [center, ...outer].join(''), reason: 'not in the word list',
});
} catch { /* best-effort */ }
setTimeout(() => { if (reportWord === w) { reportWord = ''; reported = false; } }, 2600);
}
function onKeydown(e) {
if (e.metaKey || e.ctrlKey || e.altKey || loading) return;
const k = e.key.toLowerCase();
if (k === 'enter') { e.preventDefault(); submit(); }
else if (k === 'backspace') { e.preventDefault(); del(); }
else if (/^[a-z]$/.test(k) && wheel.has(k)) { e.preventDefault(); tap(k); }
}
$effect(() => { load(); });
</script>
<svelte:window onkeydown={onKeydown} />
<div class="bloomgame" class:ready>
{#if loading}
<p class="muted">Loading todays Bloom…</p>
{:else}
{#if isFree}<p class="freecap">Free Play · {isWild ? 'Wild Bloom' : 'Center Circle'}</p>{/if}
<!-- progress: tier name + ring toward the next goal (total stays hidden) -->
<div class="meter">
<div class="ring" style="--p:{progress}">
<span class="rscore">{found.length}</span>
</div>
<div class="tierline">
<span class="tname" class:full={fullBloom}>{fullBloom ? 'Full Bloom 🌸' : tier.name}</span>
<span class="tsub">{found.length} {found.length === 1 ? 'word' : 'words'}</span>
</div>
{#if isFree}
<button class="share top" onclick={newWheel}>New wheel</button>
{:else}
<button class="share top" onclick={share}>{copied ? 'Copied!' : 'Share'}</button>
{/if}
</div>
<!-- Fixed-height entry + feedback rows so the bloom never reflows. -->
<div class="entry" class:shake>
{#if current}
{#each current.split('') as ch, i (i)}
<span class="ec" class:cen={!isWild && ch === center}>{ch.toUpperCase()}</span>
{/each}
{:else}
<span class="ph">Type or tap letters</span>
{/if}
</div>
<!-- One fixed-height feedback slot (flash OR the "should count?" flag) so the
bloom never shifts whether or not a message/report is showing. -->
<div class="feedback">
{#if reportWord}
{#if reported}
<span class="thanks">Thanks — flagged for review 🌱</span>
{:else}
<button class="reportbtn" onclick={reportMissing}>{reportWord} should count?</button>
{/if}
{:else}
<span class="flash" class:show={!!message} class:pan={message.includes('Pangram')}>{message || ' '}</span>
{/if}
</div>
<!-- Petals: pointerdown for instant touch + no focus-scroll. Center Circle =
1 azure center letter + 6 petals. Wild = 7 equal petals ringing a small
decorative bloom dot (letterless, non-tappable — never a required letter). -->
<div class="bloom" class:pulse class:wild={isWild}>
{#if isWild}
<span class="bloomcenter" style="background: {bloomColor(found.length)}" aria-hidden="true"></span>
{#each wildRing as letter, i (letter)}
<button class="petal" style="--a:{i * (360 / 7)}deg"
onpointerdown={(e) => petalDown(e, letter)} onkeydown={(e) => petalKey(e, letter)}>{letter.toUpperCase()}</button>
{/each}
{:else}
<button class="petal center" style="background: {centerColor}"
onpointerdown={(e) => petalDown(e, center)} onkeydown={(e) => petalKey(e, center)}>{center.toUpperCase()}</button>
{#each outer as letter, i (letter)}
<button class="petal" style="--a:{i * 60}deg"
onpointerdown={(e) => petalDown(e, letter)} onkeydown={(e) => petalKey(e, letter)}>{letter.toUpperCase()}</button>
{/each}
{/if}
</div>
<!-- Controls use click so they stay keyboard-activatable (Shuffle especially);
touch-action keeps them snappy on phones. -->
<div class="controls">
<button class="ctl" onclick={del} aria-label="Delete">Delete</button>
<button class="ctl round" onclick={shuffle} aria-label="Shuffle letters">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 3h5v5"/><path d="M4 20 21 3"/><path d="M21 16v5h-5"/><path d="M15 15l6 6"/><path d="M4 4l5 5"/></svg>
</button>
<button class="ctl enter" onclick={submit}>Enter</button>
</div>
{#if fullShown}
<p class="fullmsg rise">🌸 Full Bloom — you found today's whole bloom. Lovely.</p>
{/if}
{#if found.length}
<div class="found">
{#each found.slice().sort() as w (w)}
<span class="chip" class:pan={isPangram(w)} style="background: {hueFor(w)}">{w}</span>
{/each}
</div>
{/if}
{/if}
</div>
<style>
.bloomgame { max-width: 480px; margin: 0 auto; opacity: 0; transform: translateY(6px);
display: flex; flex-direction: column; align-items: center; }
.bloomgame.ready { opacity: 1; transform: none; transition: opacity 0.3s ease, transform 0.3s ease; }
.muted { color: var(--muted); text-align: center; }
/* Everything above the found-list holds its height (flex-shrink:0) so the
height-constrained mobile column can't compress/redistribute it — only the
found-list flexes. This is what was nudging the bloom on mobile. */
.meter, .entry, .feedback, .controls, .fullmsg, .freecap { flex-shrink: 0; }
.freecap { margin: 0 0 6px; font-family: var(--label); font-size: 0.72rem; letter-spacing: 0.1em;
text-transform: uppercase; color: var(--muted); font-weight: 600; }
.meter { display: flex; align-items: center; gap: 12px; width: 100%; max-width: 360px; margin: 2px 0 14px; }
.ring {
width: 46px; height: 46px; border-radius: 50%; flex-shrink: 0;
background: conic-gradient(var(--accent) calc(var(--p) * 360deg), var(--line) 0);
display: grid; place-items: center; transition: background 0.4s ease;
}
.ring .rscore { width: 36px; height: 36px; border-radius: 50%; background: var(--surface);
display: grid; place-items: center; font-family: var(--label); font-weight: 700;
font-size: 0.95rem; color: var(--accent-deep); }
.tierline { display: flex; flex-direction: column; line-height: 1.15; margin-right: auto; }
.tname { font-family: var(--serif); font-size: 1.2rem; color: var(--accent-deep); }
.tname.full { color: #c2569b; }
.tsub { color: var(--muted); font-size: 0.8rem; }
.share.top { background: none; border: 1px solid var(--line); color: var(--accent-deep);
border-radius: 999px; padding: 6px 16px; font: inherit; font-size: 0.85rem; cursor: pointer; }
.share.top:hover { border-color: var(--accent); }
/* Fixed heights → the bloom below never shifts as you type or messages appear. */
.entry { height: 46px; display: flex; align-items: center; justify-content: center; gap: 3px; }
.entry.shake { animation: shake 0.4s ease; }
.ec { font-family: var(--label); font-weight: 700; font-size: 1.8rem; letter-spacing: 0.04em; color: var(--ink); }
.ec.cen { color: var(--accent); }
.ph { color: var(--muted); font-style: italic; font-size: 1rem; }
.feedback { height: 26px; margin: 2px 0 4px; display: flex; align-items: center; justify-content: center; }
.flash { font-family: var(--label); font-size: 0.86rem; color: var(--accent-deep);
white-space: nowrap; opacity: 0; transition: opacity 0.15s ease; }
.flash.show { opacity: 1; }
.flash.pan { color: #c2569b; font-weight: 700; }
.reportbtn { background: none; border: none; cursor: pointer; font-family: var(--label);
font-size: 0.84rem; color: var(--accent-deep); text-decoration: underline;
text-underline-offset: 3px; padding: 0; }
.reportbtn:hover { color: var(--accent); }
.thanks { font-family: var(--label); font-size: 0.84rem; color: #2e8b57; }
/* The bloom: a flower — a larger azure center surrounded by 6 evenly-spaced
circular petals. Generous gaps, no overlap (our identity, not a honeycomb). */
.bloom { position: relative; width: 300px; height: 300px; margin: 2px 0 10px; --r: 100px; flex-shrink: 0; }
.bloom.pulse { animation: bloompulse 0.7s ease; }
.petal {
position: absolute; left: 50%; top: 50%; --a: 0deg;
transform: translate(-50%, -50%) rotate(var(--a)) translateY(calc(-1 * var(--r))) rotate(calc(-1 * var(--a)));
width: 84px; height: 84px; border-radius: 50%; border: none; cursor: pointer; z-index: 1;
background: var(--surface); color: var(--accent-deep);
font-family: var(--label); font-weight: 700; font-size: 1.7rem; text-transform: uppercase;
box-shadow: inset 0 0 0 1px var(--line), 0 1px 5px rgba(60, 50, 30, 0.07);
transition: transform 0.45s cubic-bezier(.2, .8, .2, 1), background 0.12s ease, filter 0.1s ease;
touch-action: manipulation; -webkit-tap-highlight-color: transparent; user-select: none;
}
.petal.center {
z-index: 2; width: 96px; height: 96px; transform: translate(-50%, -50%);
background: var(--accent); color: #fff; box-shadow: 0 4px 14px rgba(60, 50, 30, 0.24);
/* gentle hue morph as the flower blooms (background set inline per word) */
transition: background 0.6s ease, transform 0.18s ease;
}
.petal:hover { filter: brightness(0.97); }
.petal:not(.center):active {
transform: translate(-50%, -50%) rotate(var(--a)) translateY(calc(-1 * var(--r))) rotate(calc(-1 * var(--a))) scale(0.92);
}
.petal.center:active { transform: translate(-50%, -50%) scale(0.93); }
/* Wild Bloom: 7 equal petals in an open ring around a small decorative bloom
dot (letterless, non-tappable — never reads as a required letter). */
.bloom.wild { --r: 106px; }
.bloom.wild .petal { width: 76px; height: 76px; font-size: 1.6rem; }
/* The whole center circle IS the bloom — a petal-sized disc that shifts hue
with each found word (letterless + non-tappable, so never a required letter). */
.bloomcenter {
position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
width: 76px; height: 76px; border-radius: 50%; pointer-events: none; z-index: 1;
/* Ring sits OUTSIDE the fill (box-shadow, not a border) so the color reaches
the very edge — no white gap. No inset highlight (that was the white sliver). */
box-shadow: 0 0 0 2px rgba(20, 20, 20, 0.65), 0 1px 6px rgba(60, 50, 30, 0.12);
transition: background 0.6s ease;
}
.controls { display: flex; align-items: center; gap: 12px; margin: 0 0 22px; }
.ctl {
border: 1px solid var(--line); background: var(--surface); color: var(--ink);
border-radius: 999px; padding: 11px 22px; font: inherit; font-weight: 600; cursor: pointer;
box-shadow: 0 2px 0 rgba(120, 108, 84, 0.18);
touch-action: manipulation; -webkit-tap-highlight-color: transparent; user-select: none;
}
.ctl:active { transform: translateY(2px); box-shadow: none; }
.ctl.round { padding: 0; width: 46px; height: 46px; display: grid; place-items: center; }
.ctl.round svg { width: 22px; height: 22px; }
.ctl.enter { background: var(--accent); border-color: var(--accent); color: #fff;
box-shadow: 0 2px 0 var(--accent-deep); }
.ctl.enter:hover { background: var(--accent-deep); }
.fullmsg { text-align: center; color: #c2569b; font-family: var(--serif); font-style: italic;
font-size: 1.05rem; margin: 0 0 12px; }
.found { display: flex; flex-wrap: wrap; gap: 7px; justify-content: center; max-width: 440px; }
/* Each found word keeps its own hue — a little garden of finds (bg set inline). */
.chip { border: none; border-radius: 999px; padding: 5px 13px; font-size: 0.9rem; color: #fff;
text-transform: capitalize; box-shadow: 0 1px 3px rgba(40, 38, 28, 0.12);
transition: background 0.5s ease; }
.chip.pan { font-weight: 700; box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.75), 0 1px 4px rgba(40, 38, 28, 0.18); }
@keyframes shake { 0%, 100% { transform: translateX(0); } 25% { transform: translateX(-6px); } 75% { transform: translateX(6px); } }
@keyframes bloompulse { 0% { transform: scale(1); } 40% { transform: scale(1.04); } 100% { transform: scale(1); } }
@media (prefers-reduced-motion: reduce) { .entry, .bloom, .petal { animation: none !important; transition: none !important; } }
@media (max-width: 720px) {
/* /play locks the viewport (overflow:hidden), so the found list gets its own
scroll region — the bloom + controls stay put, words scroll under them. */
.bloomgame { height: 100%; max-width: 100%; }
.bloom { width: 280px; height: 280px; --r: 92px; }
.petal { width: 76px; height: 76px; font-size: 1.6rem; }
.petal.center { width: 88px; height: 88px; }
.bloom.wild { --r: 90px; }
.bloom.wild .petal { width: 64px; height: 64px; font-size: 1.45rem; }
.bloom.wild .bloomcenter { width: 64px; height: 64px; }
.found { flex: 1 1 auto; min-height: 0; width: 100%; overflow-y: auto;
align-content: flex-start; padding-bottom: calc(env(safe-area-inset-bottom) + 8px);
scrollbar-width: none; }
.found::-webkit-scrollbar { display: none; }
}
</style>
+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"> <header class="appbar">
<div class="container bar"> <div class="container bar">
<a class="brand" href="/" aria-label="Upbeat Bytes — home"> <a class="brand" href="/" aria-label="upbeatBytes — home">
<img class="logo" src="/logo.svg" alt="Upbeat Bytes" width="586" height="196" /> <img class="logo" src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
</a> </a>
<nav class="utils" aria-label="Your controls"> <nav class="utils" aria-label="Your controls">
+140
View File
@@ -0,0 +1,140 @@
<script>
// Shared editorial top bar for the hub (`/`) + its detail pages (/news, /word, /quote,
// /onthisday, /art, /play). Full horizontal nav on wide screens; a hamburger + drop panel
// on phones so the bar stays clean. `active` highlights the current section.
// News now lives at /news (the hub is `/`). `newsHref` stays overridable for safety.
import { onMount } from 'svelte';
import { auth, refresh as refreshAuth } from '$lib/auth.svelte.js';
import Avatar from './Avatar.svelte';
let { active = '', newsHref = '/news' } = $props();
let open = $state(false);
// auth.user paints immediately from its localStorage cache; revalidate once if no
// page has yet (e.g. the hub is the entry point) so the avatar + signed-in state are
// correct everywhere the bar shows, not just on the feed/account pages.
onMount(() => { if (!auth.ready) refreshAuth(); });
// Close the menu when we cross into desktop width, so it can't linger open and reappear
// if the viewport shrinks back to mobile (the CSS hide alone left `open` stale).
$effect(() => {
const mq = window.matchMedia('(min-width: 721px)');
const sync = (e) => { if (e.matches) open = false; };
mq.addEventListener('change', sync);
return () => mq.removeEventListener('change', sync);
});
let LINKS = $derived([
{ key: 'home', href: '/', label: 'Home' },
{ key: 'news', href: newsHref, label: 'News' },
{ key: 'games', href: '/play', label: 'Games' },
{ key: 'art', href: '/art', label: 'Art' },
]);
</script>
<svelte:window on:keydown={(e) => { if (e.key === 'Escape') open = false; }} />
<header class="bar">
<a class="brand" href="/" aria-label="upbeatBytes home">
<img src="/logo.svg" alt="upbeatBytes" width="586" height="196" />
</a>
<div class="bar-end">
<nav class="nav">
{#each LINKS as l}
<a class:on={active === l.key} href={l.href}>{l.label}</a>
{/each}
<span class="nav-soon">Entertainment</span>
</nav>
<a class="acct" class:hasimg={auth.user?.avatar_url} href="/account" aria-label="Your account">
{#if auth.user?.avatar_url}
<Avatar user={auth.user} size={32} />
{:else}
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="#C98A2E" stroke-width="2" aria-hidden="true">
<circle cx="12" cy="8" r="4" /><path d="M4 21c0-4 4-6 8-6s8 2 8 6" />
</svg>
{/if}
</a>
<button class="burger" class:open aria-label="Menu" aria-expanded={open} aria-controls="hub-menu"
onclick={() => (open = !open)}>
<span></span><span></span><span></span>
</button>
</div>
</header>
{#if open}
<div class="menu-wrap">
<nav id="hub-menu" class="menu">
{#each LINKS as l}
<a class:on={active === l.key} href={l.href} onclick={() => (open = false)}>{l.label}</a>
{/each}
<span class="menu-soon">Entertainment <em>soon</em></span>
</nav>
</div>
{/if}
<style>
/* Self-contained so the bar looks identical on EVERY page, regardless of the host
page's own fonts/tokens — one cohesive top bar across the whole site. */
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
.bar {
display: flex; align-items: center; justify-content: space-between;
max-width: 1180px; width: 100%; margin: 0 auto; box-sizing: border-box;
padding: 26px clamp(18px, 5vw, 44px) 0;
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
}
.brand { display: block; line-height: 0; }
.brand img { height: 48px; width: auto; display: block; }
.bar-end { display: flex; align-items: center; gap: clamp(16px, 2.4vw, 32px); }
.nav { display: flex; align-items: center; gap: clamp(16px, 2.4vw, 32px); font-size: 16.5px; font-weight: 500; }
.nav a { color: #6b6256; text-decoration: none; }
.nav a.on { color: #23201b; }
.nav a:hover { color: #0083ad; }
.nav-soon { color: #b3a890; }
.acct {
width: 32px; height: 32px; border-radius: 50%; border: 1.5px solid #e6c9a0; background: #FCEFD7;
display: flex; align-items: center; justify-content: center; flex: none;
}
/* when showing the profile picture, let it fill the circle cleanly (no tint ring/peek) */
.acct.hasimg { background: none; overflow: hidden; padding: 0; }
.acct:hover { background: #fbe6c4; }
/* hamburger — phones only */
.burger {
display: none; flex-direction: column; align-items: center; justify-content: center; gap: 4px;
width: 40px; height: 40px; border-radius: 11px; border: 1.5px solid #e6c9a0; background: #FCEFD7;
cursor: pointer; padding: 0; flex: none;
}
.burger:hover { background: #fbe6c4; }
.burger span { width: 18px; height: 2px; border-radius: 2px; background: #7a6a52; transition: transform 0.2s ease, opacity 0.15s ease; }
.burger.open span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.burger.open span:nth-child(2) { opacity: 0; }
.burger.open span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
/* drop panel */
.menu-wrap { max-width: 1180px; width: 100%; margin: 10px auto 0; box-sizing: border-box; padding: 0 clamp(18px, 5vw, 44px); font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif; }
.menu {
display: flex; flex-direction: column; background: #fff; border: 1px solid #f2e7d3;
border-radius: 14px; overflow: hidden; box-shadow: 0 14px 34px -20px rgba(60, 50, 30, 0.4);
}
.menu a, .menu .menu-soon {
padding: 14px 18px; font-size: 16px; font-weight: 500; text-decoration: none;
color: #6b6256; border-top: 1px solid #f3ece0;
}
.menu a:first-child { border-top: none; }
.menu a.on { color: #23201b; }
.menu a:hover { background: #FFF9EF; color: #0083ad; }
.menu-soon { display: flex; align-items: center; justify-content: space-between; color: #b3a890; }
.menu-soon em { font-style: normal; font-size: 10px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #c3b69c; }
@media (max-width: 720px) {
.nav { display: none; }
.burger { display: flex; }
}
@media (min-width: 721px) {
.menu-wrap { display: none; } /* safety: never show the panel once back on desktop */
}
</style>
@@ -0,0 +1,69 @@
<script>
// Shared shell for the hub + its detail pages (/word, /quote, /onthisday): the editorial
// top bar, footer, fonts, and design tokens. Content goes in the default slot.
import { afterNavigate, goto } from '$app/navigation';
import HubBar from './HubBar.svelte';
import Footer from './Footer.svelte';
let { active = '', back = true, backLabel = 'Back', children } = $props();
// Same single-history rule as the News reader's in-page Back: if we arrived here by an
// in-app navigation, history.back() returns to wherever (the hub) with state intact; a
// direct deep-link has no app history, so fall back to the hub.
let cameFromApp = $state(false);
afterNavigate(({ from }) => { if (from) cameFromApp = true; });
function goBack() {
if (cameFromApp && typeof history !== 'undefined') history.back();
// Cold deep-link: no in-app origin. REPLACE this entry rather than pushing one, so the
// browser Back from the hub doesn't bounce the reader straight back into the detail page.
else goto('/', { replaceState: true });
}
</script>
<div class="page">
<HubBar {active} />
<main class="shell-main">
{#if back}
<button class="back" onclick={goBack} aria-label="Go back">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 18l-6-6 6-6" />
</svg>
{backLabel}
</button>
{/if}
{@render children?.()}
</main>
<Footer />
</div>
<style>
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-italic-var.woff2') format('woff2'); font-weight: 400 500; font-style: italic; font-display: swap; }
.page {
--ink: #1c1916; --body: #6b6256; --muted: #a89e8c; --teal: #0083ad;
--canvas: #FFF9EF; --news-border: #f2e7d3;
min-height: 100vh; background: var(--canvas); color: #23201b;
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, sans-serif;
display: flex; flex-direction: column;
}
.page :global(*) { box-sizing: border-box; }
.shell-main {
flex: 1; width: 100%; max-width: 1180px; margin: 0 auto; box-sizing: border-box;
padding: clamp(20px, 4vw, 40px) clamp(18px, 5vw, 44px) clamp(40px, 6vw, 72px);
}
/* top-left Back — returns to the hub (or wherever you came from), preserving history */
.back {
display: inline-flex; align-items: center; gap: 6px; margin: 0 0 clamp(14px, 3vw, 24px);
background: none; border: none; cursor: pointer; padding: 6px 10px 6px 0;
font-family: inherit; font-size: 14px; font-weight: 600; color: var(--body);
transition: color 0.15s ease;
}
.back:hover { color: var(--teal); }
.back svg { transition: transform 0.15s ease; }
.back:hover svg { transform: translateX(-2px); }
</style>
@@ -0,0 +1,272 @@
<script>
import { untrack } from 'svelte';
import { pushGameState } from '$lib/gamesync.js';
import { trackGame, gameShareUrl } from '$lib/analytics.js';
import { COLOR_BY_KEY } from '$lib/games/match/palette.js';
import { buildBoard, freeSeed } from '$lib/games/match/board.js';
import MatchIcon from './MatchIcon.svelte';
// mode: 'daily' (shared, synced, feeds the ritual) | 'free' (local-only, replayable)
// format: 'icons' | 'colors' · tier: 'gentle' | 'standard' | 'expert'
let { mode = 'daily', format = 'icons', tier = 'standard', date = '', onstatus } = $props();
let isFree = $derived(mode === 'free');
let variant = $derived(`${tier}-${format}`);
// Storage: daily keyed by the server date (cross-device synced); free keeps one
// resumable scratch board per variant.
let stateKey = $derived(isFree ? `goodnews:match:free:${variant}` : `goodnews:match:${variant}:${date}`);
const ASSIST_KEY = 'goodnews:match:assist';
let board = $state(null);
let seed = $state('');
let flipped = $state([]); // card ids face-up, not yet resolved
let matchedKeys = $state(new Set());
let moves = $state(0);
let locked = $state(false); // brief hold while a mismatch is shown
let ready = $state(false);
let assist = $state(false); // colorblind assist: show a tiny letter on swatches
let celebrated = $state(false);
let matchedIds = $derived(board ? new Set(board.cards.filter((c) => matchedKeys.has(c.key)).map((c) => c.id)) : new Set());
let done = $derived(!!board && matchedKeys.size >= board.faces.length);
function read(key) { try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; } }
function write(key, v) { try { localStorage.setItem(key, JSON.stringify(v)); } catch { /* ignore */ } }
function emitStatus() {
onstatus?.({ count: matchedKeys.size, total: board?.faces.length ?? 0, moves, done });
}
function persist() {
const snap = { matched: [...matchedKeys], moves, done, seed: isFree ? seed : undefined };
write(stateKey, snap);
emitStatus();
// Daily resumes across devices; free play stays local.
if (!isFree && date) pushGameState('match', variant, date, { matched: snap.matched, moves, done });
}
let loadSeq = 0;
async function load(forceNew = false) {
const myLoad = ++loadSeq;
ready = false;
// A pending mismatch flip-back must not fire onto a freshly switched board.
if (flipTimer) { clearTimeout(flipTimer); flipTimer = null; }
try { assist = localStorage.getItem(ASSIST_KEY) === '1'; } catch { /* ignore */ }
const useFree = isFree;
let saved, theSeed;
if (useFree) {
saved = forceNew ? null : read(stateKey);
// a free board derives a stable seed so a reload resumes the same layout
theSeed = saved?.seed || freeSeed((Date.now() ^ Math.floor(Math.random() * 1e9)) >>> 0);
} else {
theSeed = date;
saved = read(stateKey);
// pull/merge the server's copy first (cross-device resume)
if (date) {
const merged = await pushGameState('match', variant, date, {
matched: saved?.matched || [], moves: saved?.moves || 0, done: !!saved?.done,
});
if (myLoad !== loadSeq) return; // a newer board load started — drop this stale one
if (merged) saved = merged;
}
}
if (myLoad !== loadSeq) return;
// Commit the new board + restored progress atomically AFTER any await, so a rapid
// daily/free, icon/color, or tier switch can't hydrate an old response onto the
// newer board.
const b = buildBoard({ format, tier, seed: theSeed });
const valid = new Set(b.faces);
board = b;
seed = theSeed;
flipped = []; locked = false;
matchedKeys = new Set((saved?.matched || []).filter((k) => valid.has(k)));
moves = Number.isFinite(saved?.moves) ? saved.moves : 0;
celebrated = matchedKeys.size >= b.faces.length; // don't replay the celebration on resume
// Persist immediately so a freshly-generated free board's seed survives a reload
// even before the first move.
write(stateKey, { matched: [...matchedKeys], moves, done: matchedKeys.size >= b.faces.length,
seed: useFree ? theSeed : undefined });
ready = true;
emitStatus();
}
function reset() { if (isFree) load(true); }
function toggleAssist() {
assist = !assist;
try { localStorage.setItem(ASSIST_KEY, assist ? '1' : '0'); } catch { /* ignore */ }
}
let flipTimer = null;
function flip(card) {
if (!ready || locked || done) return;
if (matchedKeys.has(card.key) || flipped.includes(card.id)) return;
if (moves === 0 && flipped.length === 0 && matchedKeys.size === 0) trackGame('match', 'started');
// Match off the LOCAL `next`, not the just-assigned reactive `flipped`, so the
// evaluation never runs a click behind.
const next = [...flipped, card.id];
flipped = next;
if (next.length < board.matchN) return; // Expert needs all 3 before judging
moves += 1;
const keys = next.map((id) => board.cards[id].key);
const allSame = keys.every((k) => k === keys[0]);
if (allSame) {
matchedKeys = new Set([...matchedKeys, keys[0]]);
flipped = [];
persist();
if (matchedKeys.size >= board.faces.length && !celebrated) { celebrated = true; trackGame('match', 'completed'); }
} else {
locked = true; // show the mismatch briefly, then flip back
persist();
flipTimer = setTimeout(() => { flipped = []; locked = false; flipTimer = null; }, 850);
}
}
const TIER_LABEL = { gentle: 'Gentle', standard: 'Standard', expert: 'Expert' };
let copied = $state(false);
function share() {
const label = `${TIER_LABEL[tier] || tier} · ${format === 'colors' ? 'colors' : 'icons'}`;
const when = isFree ? 'Free play' : date;
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); });
}
function faceLabel(card) {
if (format === 'colors') return COLOR_BY_KEY[card.key]?.name ?? 'color';
return card.key;
}
// Reload whenever the board identity (mode/format/tier/date, all encoded in
// stateKey) changes. untrack() keeps the effect from tracking load()'s internal
// state reads (board/matchedKeys/moves/done via emitStatus) — which load() also
// writes — so a synchronous free-play load can't self-retrigger into a loop.
$effect(() => {
stateKey;
untrack(() => load());
return () => { if (flipTimer) clearTimeout(flipTimer); };
});
</script>
<div class="match">
<div class="topbar">
<div class="meta">
{#if board}
<span class="tier">{board.label}{format === 'colors' ? ' · Color' : ''}</span>
<span class="prog">{matchedKeys.size}/{board.faces.length}{moves ? ` · ${moves} moves` : ''}</span>
<span class="rule" class:tri={board.matchN === 3}>{#if board.matchN === 3 && flipped.length > 0 && flipped.length < 3}Pick 3 · {flipped.length}/3{:else}{board.matchN === 3 ? 'Match 3 of a kind' : 'Match pairs'}{/if}</span>
{/if}
</div>
<div class="controls">
{#if format === 'colors'}
<button class="ctl" aria-pressed={assist} onclick={toggleAssist}>Assist {assist ? 'on' : 'off'}</button>
{/if}
{#if isFree}
<button class="ctl" onclick={reset}>New board</button>
{/if}
</div>
</div>
{#if board}
<div class="grid" class:locked
style="grid-template-columns: repeat({board.cols}, 1fr); max-width: {board.cols * 76}px;">
{#each board.cards as card (card.id)}
{@const up = matchedIds.has(card.id) || flipped.includes(card.id)}
{@const matched = matchedIds.has(card.id)}
<button class="card" class:up class:matched
aria-label={up ? faceLabel(card) : 'card, face down'}
disabled={matched}
onclick={() => flip(card)}>
<span class="inner">
<span class="back" aria-hidden="true"></span>
<span class="front" class:colored={format === 'colors'} aria-hidden="true">
{#if format === 'colors'}
<span class="colorfill" style="--c: {COLOR_BY_KEY[card.key]?.hex}">
{#if assist}<span class="assist">{COLOR_BY_KEY[card.key]?.assist}</span>{/if}
</span>
{:else}
<MatchIcon name={card.key} />
{/if}
{#if matched}<span class="check" aria-hidden="true"></span>{/if}
</span>
</span>
</button>
{/each}
</div>
{#if done && celebrated}
<p class="done">Lovely — you cleared {isFree ? 'the board' : "today's set"}.{isFree ? '' : ' Fresh one tomorrow.'}</p>
<button class="share" onclick={share}>{copied ? 'Copied!' : 'Share result'}</button>
{/if}
{/if}
</div>
<style>
.match { display: flex; flex-direction: column; align-items: center; gap: 14px; }
.topbar { width: 100%; max-width: 460px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.meta { display: flex; flex-direction: column; gap: 1px; }
.tier { font-family: var(--label); font-weight: 600; }
.prog { color: var(--muted); font-size: 0.85rem; font-variant-numeric: tabular-nums; }
.rule { font-size: 0.8rem; color: var(--muted); margin-top: 2px; }
/* Expert's 3-of-a-kind rule is the easy-to-forget one — give it a clear chip. */
.rule.tri { align-self: flex-start; color: var(--accent-deep); background: var(--accent-soft);
padding: 1px 9px; border-radius: 999px; font-weight: 600; }
.controls { display: flex; gap: 8px; }
.ctl { font-size: 0.8rem; padding: 5px 11px; border: 1px solid var(--line); border-radius: 9px;
background: var(--surface); color: var(--accent-deep); cursor: pointer; }
.grid { display: grid; gap: 8px; width: 100%; }
.card { aspect-ratio: 1; min-width: 0; padding: 0; border: none; background: none; cursor: pointer;
perspective: 600px; -webkit-tap-highlight-color: transparent; touch-action: manipulation; }
.card[disabled] { cursor: default; }
.inner { position: relative; display: block; width: 100%; height: 100%;
transition: transform 0.32s ease; transform-style: preserve-3d; }
.card.up .inner { transform: rotateY(180deg); }
.back, .front { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
border-radius: 13px; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
/* Face-down: a branded card back — azure gradient under a faint dot motif. */
.back { background:
radial-gradient(circle at 50% 50%, rgba(255,255,255,0.18) 1.2px, transparent 1.6px) 0 0 / 11px 11px,
linear-gradient(150deg, var(--accent) 0%, var(--accent-deep) 100%);
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.18); }
.front { background: var(--surface); border: 1px solid var(--line); color: var(--accent-deep);
transform: rotateY(180deg); }
.front.colored { background: none; border: none; } /* the colorfill is the whole face */
/* Icon front-and-center: ~70% of the card (≈50% area) so the symbol is memorable. */
.front :global(svg) { width: 70%; height: 70%; }
.colorfill { position: absolute; inset: 0; border-radius: 13px; background: var(--c);
border: 2px solid #3a3a3a; }
.assist { position: absolute; top: 5px; right: 6px; font-family: var(--label); font-weight: 700;
font-size: 0.72rem; color: #fff; background: rgba(0, 0, 0, 0.45); padding: 1px 5px; border-radius: 6px; }
/* State hierarchy ----------------------------------------------------------
Inspecting (flipped, unmatched): vivid + lifted, so the unknown cards pop. */
.card.up:not(.matched) .front:not(.colored) { box-shadow: 0 2px 8px rgba(0,0,0,0.12); }
.card.up:not(.matched) .colorfill { box-shadow: 0 2px 8px rgba(0,0,0,0.20); }
/* Matched: a settled "paint-chip" tile — receded, desaturated, with a quiet check,
so the last few unknown cards stay obvious instead of blending in. */
.card.matched .inner { transform: rotateY(180deg) scale(0.9); }
.card.matched .front { box-shadow: none; }
.card.matched .front.colored { background: var(--surface); border: 1px solid var(--line); }
.card.matched .colorfill { inset: 5px; border-radius: 8px; border: 1px solid rgba(0,0,0,0.22);
filter: saturate(0.6) brightness(1.06); box-shadow: none; }
.card.matched :global(svg) { opacity: 0.42; }
.card.matched .assist { display: none; }
.check { position: absolute; bottom: 4px; right: 6px; font-size: 0.82rem; line-height: 1;
color: var(--accent-deep); font-weight: 700; }
.done { margin: 4px 0 0; color: var(--accent-deep); font-family: var(--label); text-align: center; }
.share { margin-top: 10px; background: var(--accent); color: #fff; border: none; border-radius: 999px;
padding: 9px 22px; font: inherit; font-weight: 600; cursor: pointer; }
.share:hover { background: var(--accent-deep); }
@media (prefers-reduced-motion: reduce) {
.inner { transition: none; }
}
</style>
@@ -0,0 +1,9 @@
<script>
import { ICONS } from '$lib/games/match/icons.js';
let { name, size = 34 } = $props();
</script>
<svg viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
{@html ICONS[name] ?? ''}
</svg>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,88 @@
<script>
// A single hub "room" card. Presentational — the page passes live preview data in.
// Every card is tinted with a darker top strip; layout varies by preview type:
// news = inset photo on top, art = inset photo on the right, static = centered header.
let { room, artImg = null, newsImg = null, newsHeadline = '' } = $props();
</script>
<a class="card card--{room.size} card--{room.preview}" href={room.href}
style={room.tint ? `--tint:${room.tint}` : ''}>
{#if room.preview === 'news'}
{#if newsImg}<div class="photo photo--top" style="background-image:url({newsImg})"></div>{/if}
<div class="content">
<div class="head"><span class="ic">{room.icon}</span><span class="title">{room.title}</span></div>
{#if newsHeadline}<p class="headline">{newsHeadline}</p>{:else}<p class="blurb">{room.blurb}</p>{/if}
<span class="cta">{room.cta} <span class="arr"></span></span>
</div>
{:else if room.preview === 'art'}
<div class="content">
<div class="head"><span class="ic">{room.icon}</span><span class="title">{room.title}</span></div>
<p class="blurb">{room.blurb}</p>
<span class="cta">{room.cta} <span class="arr"></span></span>
</div>
{#if artImg}<div class="photo photo--right" style="background-image:url({artImg})"></div>{/if}
{:else}
<div class="content">
<div class="head"><span class="ic">{room.icon}</span><span class="title">{room.title}</span></div>
<p class="blurb">{room.blurb}</p>
<span class="cta">{room.cta} <span class="arr"></span></span>
</div>
{/if}
</a>
<style>
.card {
--pad: clamp(13px, 1.7vw, 18px);
position: relative; display: flex; overflow: hidden; text-decoration: none; color: var(--ink);
background: var(--tint, var(--surface)); border: 1px solid rgba(20, 30, 45, 0.07);
border-radius: 18px; box-shadow: 0 10px 30px rgba(20, 30, 45, 0.06);
transition: transform 0.18s ease, box-shadow 0.18s ease;
padding: var(--pad);
}
.card:hover { transform: translateY(-4px); box-shadow: 0 20px 46px rgba(20, 30, 45, 0.12); }
/* size variants — grid spans set by the page; these set the heights */
.card--large { min-height: 340px; }
.card--wide { min-height: 200px; }
.card--small { min-height: 168px; }
.content { display: flex; flex-direction: column; flex: 1; }
/* shared side-by-side header (icon + title), clean modern sans title */
.head { display: flex; align-items: center; gap: 10px; }
.ic { font-size: clamp(1.7rem, 2.4vw, 2.1rem); line-height: 1; }
.title {
font-family: var(--sans); font-weight: 700; letter-spacing: -0.015em;
font-size: clamp(1.25rem, 2vw, 1.5rem);
}
.blurb { margin: 11px 0 14px; color: var(--muted); font-size: 1rem; line-height: 1.45; max-width: 42ch; }
.cta { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; color: var(--accent); font-size: 0.98rem; }
.arr { transition: transform 0.18s ease; }
.card:hover .arr { transform: translateX(4px); }
/* photos sit inset (rounded) so the card colour shows all around them */
.photo { background-size: cover; background-position: center; border-radius: 11px; }
/* News — inset photo on top, headline below; larger hero header */
.card--news { flex-direction: column; }
.card--news .photo--top { width: 100%; flex: 1 1 auto; min-height: 150px; }
.card--news .content { flex: 0 0 auto; padding-top: 14px; }
.card--news .ic { font-size: clamp(2rem, 2.8vw, 2.5rem); }
.card--news .title { font-size: clamp(1.5rem, 2.6vw, 2rem); }
.card--news .headline {
margin: 12px 0 4px; font-family: Georgia, serif; font-style: italic;
font-size: clamp(1.1rem, 1.9vw, 1.4rem); line-height: 1.34; max-width: 40ch;
}
/* Art — inset rectangular photo on the RIGHT (cover-cropped to hide ragged edges) */
.card--art { flex-direction: row; align-items: stretch; gap: var(--pad); }
.card--art .content { justify-content: center; }
.card--art .photo--right { flex: 0 0 40%; min-width: 120px; align-self: stretch; }
/* Static — centered header, body left, link pinned to the bottom (cards line up) */
.card--static .head { justify-content: center; }
.card--static .blurb { margin: 12px 0 14px; }
.card--static .cta { margin-top: auto; }
</style>
+1 -1
View File
@@ -41,7 +41,7 @@
</p> </p>
<button class="primary" onclick={onclose}>Done</button> <button class="primary" onclick={onclose}>Done</button>
{:else} {:else}
<h2>Sign in to Upbeat Bytes</h2> <h2>Sign in to upbeatBytes</h2>
<p class="sub"> <p class="sub">
Save articles and keep your history across devices. Save articles and keep your history across devices.
</p> </p>
+75 -12
View File
@@ -1,5 +1,7 @@
<script> <script>
import { getJSON, postJSON } from '$lib/api.js'; import { getJSON, postJSON } from '$lib/api.js';
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
import { trackGame, gameShareUrl } from '$lib/analytics.js';
let { variant = '5', onstatus } = $props(); let { variant = '5', onstatus } = $props();
@@ -40,6 +42,7 @@
} }
loading = false; loading = false;
requestAnimationFrame(() => (ready = true)); requestAnimationFrame(() => (ready = true));
syncNow(); // reconcile with the server in the background (signed-in only)
} }
function restore() { function restore() {
@@ -59,6 +62,30 @@
try { localStorage.setItem(stateKey, JSON.stringify({ guesses, cols, status, answer, why })); } catch { /* ignore */ } try { localStorage.setItem(stateKey, JSON.stringify({ guesses, cols, status, answer, why })); } catch { /* ignore */ }
onstatus?.(summary()); onstatus?.(summary());
} }
// --- cross-device sync (signed-in only; "furthest progress" merged server-side) ---
let serverStats = $state(null);
let syncTimer;
function adopt(merged) {
if (!merged || !Array.isArray(merged.guesses)) return;
const lr = [status !== 'playing' ? 1 : 0, guesses.length];
const mr = [merged.status && merged.status !== 'playing' ? 1 : 0, merged.guesses.length];
if (mr[0] > lr[0] || (mr[0] === lr[0] && mr[1] > lr[1])) { // server is further along
guesses = merged.guesses;
cols = Array.isArray(merged.cols) ? merged.cols : cols;
status = merged.status || 'playing';
answer = merged.answer ?? answer;
why = merged.why ?? why;
persist();
}
}
async function syncNow() {
const d = date, v = variant;
const merged = await pushGameState('word', v, d, { guesses, cols, status, answer, why });
if (d === date && v === variant) adopt(merged);
serverStats = await fetchGameStats('word', variant); // streak/dist follow you across devices
}
function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1000); }
function summary() { function summary() {
return { variant, date, status, tries: guesses.length, max: maxGuesses }; return { variant, date, status, tries: guesses.length, max: maxGuesses };
} }
@@ -95,12 +122,14 @@
submitting = true; submitting = true;
try { try {
const res = await postJSON('/api/puzzle/word/guess', { variant, guess: current, n: guesses.length + 1 }); const res = await postJSON('/api/puzzle/word/guess', { variant, guess: current, n: guesses.length + 1 });
if (guesses.length === 0) trackGame('word', 'started'); // first real guess = started
guesses = [...guesses, current]; guesses = [...guesses, current];
cols = [...cols, res.colors]; cols = [...cols, res.colors];
current = ''; current = '';
if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); } if (res.solved) { status = 'won'; answer = res.answer; why = res.why; recordStat(true); trackGame('word', 'completed'); }
else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); } else if (guesses.length >= maxGuesses) { status = 'lost'; answer = res.answer; why = res.why; recordStat(false); trackGame('word', 'completed'); }
persist(); persist();
syncSoon(); // push this guess (and any completion) to the server, debounced
} catch { } catch {
flash('Hmm — couldnt check that. Try again.'); flash('Hmm — couldnt check that. Try again.');
} finally { } finally {
@@ -119,6 +148,7 @@
} }
let stats = $derived.by(() => { let stats = $derived.by(() => {
if (status === 'playing') return null; if (status === 'playing') return null;
if (serverStats) return serverStats; // signed-in: consistent across devices
try { return JSON.parse(localStorage.getItem(statsKey) || 'null'); } catch { return null; } try { return JSON.parse(localStorage.getItem(statsKey) || 'null'); } catch { return null; }
}); });
@@ -137,9 +167,11 @@
const label = variant === '6' ? 'Long Word' : 'Daily Word'; const label = variant === '6' ? 'Long Word' : 'Daily Word';
const score = status === 'won' ? guesses.length : 'X'; const score = status === 'won' ? guesses.length : 'X';
const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n'); const grid = cols.map((cs) => cs.map((c) => EMOJI[c]).join('')).join('\n');
const text = `Upbeat Bytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\nupbeatbytes.com/play`; const text = `upbeatBytes · ${label} ${date}\n${score}/${maxGuesses}\n${grid}\n${gameShareUrl('word', variant)}`;
if (navigator.share) navigator.share({ text }).catch(() => {}); // Count a share only once it actually happens (sheet completed / clipboard wrote),
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); }); // never on a cancelled share sheet or denied clipboard.
if (navigator.share) navigator.share({ text }).then(() => trackGame('word', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { trackGame('word', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
} }
// Load on mount and whenever the variant toggles. Tracks ONLY `variant` — it // Load on mount and whenever the variant toggles. Tracks ONLY `variant` — it
@@ -165,7 +197,7 @@
{#each Array(length) as _, c (c)} {#each Array(length) as _, c (c)}
{@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')} {@const ch = g ? g[c] : (r === guesses.length ? current[c] : '')}
<div class="tile {cs ? cs[c] : ''}" class:filled={!!ch} <div class="tile {cs ? cs[c] : ''}" class:filled={!!ch}
style={cs ? `animation-delay:${c * 0.08}s` : ''}>{(ch || '').toUpperCase()}</div> style={cs ? `--d:${c * 0.08}s` : ''}>{(ch || '').toUpperCase()}</div>
{/each} {/each}
</div> </div>
{/each} {/each}
@@ -225,22 +257,53 @@
aspect-ratio: 1; display: flex; align-items: center; justify-content: center; aspect-ratio: 1; display: flex; align-items: center; justify-content: center;
border: 2px solid var(--line); border-radius: 8px; font-family: var(--label); border: 2px solid var(--line); border-radius: 8px; font-family: var(--label);
font-weight: 700; font-size: 1.5rem; color: var(--ink); text-transform: uppercase; font-weight: 700; font-size: 1.5rem; color: var(--ink); text-transform: uppercase;
background: var(--surface); background: var(--surface); position: relative; overflow: hidden;
} }
.tile.filled { border-color: #b7c0cb; } .tile.filled { border-color: #b7c0cb; }
.tile.correct { background: #4a9d6e; border-color: #4a9d6e; color: #fff; } /* Judged tiles set like glazed enamel: a soft top-light gradient over the
.tile.present { background: #d8b24a; border-color: #d8b24a; color: #fff; } colour, an inner bevel, and a little lift off the board. Pending tiles stay
.tile.absent { background: #9aa6b2; border-color: #9aa6b2; color: #fff; } flat on purpose — depth marks the moment a letter is settled. */
.tile.correct, .tile.present, .tile.absent { color: #fff; text-shadow: 0 1px 2px rgba(20, 30, 25, 0.22); }
.tile.correct {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0) 45%),
linear-gradient(165deg, #56ac7c, #4a9d6e 55%, #3e8a5e);
border-color: #3e8a5e;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.32), inset 0 -3px 5px rgba(22, 64, 42, 0.2),
0 2px 5px rgba(58, 125, 86, 0.28);
}
.tile.present {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0) 45%),
linear-gradient(165deg, #e2c163, #d8b24a 55%, #c29c38);
border-color: #c29c38;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.35), inset 0 -3px 5px rgba(122, 92, 22, 0.2),
0 2px 5px rgba(184, 148, 58, 0.28);
}
.tile.absent {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0) 45%),
linear-gradient(165deg, #a7b2bd, #9aa6b2 55%, #87939f);
border-color: #87939f;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.26), inset 0 -3px 5px rgba(50, 60, 70, 0.16),
0 2px 4px rgba(110, 122, 134, 0.24);
}
/* One quiet glint sweeps each tile just after its flip lands — once, not a loop. */
.tile.correct::after, .tile.present::after, .tile.absent::after {
content: ''; position: absolute; inset: 0; pointer-events: none;
background: linear-gradient(115deg, transparent 38%, rgba(255, 255, 255, 0.38) 50%, transparent 62%);
transform: translateX(-130%);
animation: sheen 0.65s ease-out both;
animation-delay: calc(var(--d, 0s) + 0.32s);
}
/* Juice: a tile pops as you type; the row reveals with a staggered bounce when /* Juice: a tile pops as you type; the row reveals with a staggered bounce when
you submit; the row shakes on an invalid word. */ you submit; the row shakes on an invalid word. */
.tile.filled:not(.correct):not(.present):not(.absent) { animation: pop 0.13s ease; } .tile.filled:not(.correct):not(.present):not(.absent) { animation: pop 0.13s ease; }
.tile.correct, .tile.present, .tile.absent { animation: reveal 0.34s ease both; } .tile.correct, .tile.present, .tile.absent { animation: reveal 0.34s ease both; animation-delay: var(--d, 0s); }
.row.shake { animation: shake 0.4s ease; } .row.shake { animation: shake 0.4s ease; }
@keyframes pop { 0% { transform: scale(1); } 45% { transform: scale(1.09); } 100% { transform: scale(1); } } @keyframes pop { 0% { transform: scale(1); } 45% { transform: scale(1.09); } 100% { transform: scale(1); } }
@keyframes reveal { 0% { transform: scale(0.5); opacity: 0.3; } 55% { transform: scale(1.12); } 100% { transform: scale(1); opacity: 1; } } @keyframes reveal { 0% { transform: scale(0.5); opacity: 0.3; } 55% { transform: scale(1.12); } 100% { transform: scale(1); opacity: 1; } }
@keyframes sheen { to { transform: translateX(130%); } }
@keyframes shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-7px); } 40% { transform: translateX(7px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(5px); } } @keyframes shake { 0%, 100% { transform: translateX(0); } 20% { transform: translateX(-7px); } 40% { transform: translateX(7px); } 60% { transform: translateX(-5px); } 80% { transform: translateX(5px); } }
@media (prefers-reduced-motion: reduce) { .tile, .row { animation: none !important; } } @media (prefers-reduced-motion: reduce) { .tile, .tile::after, .row { animation: none !important; } }
.flash { .flash {
text-align: center; background: var(--ink); color: #fff; border-radius: 8px; text-align: center; background: var(--ink); color: #fff; border-radius: 8px;
padding: 7px 14px; width: fit-content; margin: 0 auto 12px; font-size: 0.86rem; padding: 7px 14px; width: fit-content; margin: 0 auto 12px; font-size: 0.86rem;
+111 -20
View File
@@ -1,6 +1,8 @@
<script> <script>
import { getJSON } from '$lib/api.js'; import { getJSON } from '$lib/api.js';
import { lineFrom, matchWord, cellFromPoint } from '$lib/wordsearch.js'; import { lineFrom, matchWord, cellFromPoint } from '$lib/wordsearch.js';
import { pushGameState, fetchGameStats } from '$lib/gamesync.js';
import { trackGame, gameShareUrl } from '$lib/analytics.js';
let { size = 'med', onstatus } = $props(); let { size = 'med', onstatus } = $props();
@@ -15,7 +17,8 @@
let foundWords = $state([]); // {word, cells:[[r,c]], ci} let foundWords = $state([]); // {word, cells:[[r,c]], ci}
let sel = $state([]); // current selection cells let sel = $state([]); // current selection cells
let selecting = false; let selecting = false;
let startTime = 0; let playedMs = 0; // accumulated ACTIVE play time (closed segments)
let segStart = 0; // wall-clock start of the open segment (0 = paused)
let resultMs = $state(0); let resultMs = $state(0);
let best = $state(0); let best = $state(0);
let loading = $state(true); let loading = $state(true);
@@ -32,26 +35,74 @@
const status = $derived(words.length && foundWords.length === words.length ? 'done' : 'playing'); const status = $derived(words.length && foundWords.length === words.length ? 'done' : 'playing');
const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c))); const selSet = $derived(new Set(sel.map(([r, c]) => r + ',' + c)));
const cellColor = $derived.by(() => { const cellColor = $derived.by(() => {
const m = new Map(); const m = new Map(); // "r,c" -> [colour index, ...] (all words covering it)
for (const w of foundWords) for (const [r, c] of w.cells) m.set(r + ',' + c, w.ci); for (const w of foundWords) for (const [r, c] of w.cells) {
const k = r + ',' + c;
if (!m.has(k)) m.set(k, []);
m.get(k).push(w.ci);
}
return m; return m;
}); });
// Multiply-blend the palette colours of every word covering a cell, so where
// words cross the cell DEEPENS into a darker shade instead of one colour
// stomping the others. Single-word cells keep their plain pastel.
function cellStyle(indices) {
let r = 255, g = 255, b = 255;
for (const ci of indices) {
const h = PALETTE[ci];
r = (r * parseInt(h.slice(1, 3), 16)) / 255;
g = (g * parseInt(h.slice(3, 5), 16)) / 255;
b = (b * parseInt(h.slice(5, 7), 16)) / 255;
}
r = Math.round(r); g = Math.round(g); b = Math.round(b);
const lum = 0.299 * r + 0.587 * g + 0.114 * b; // keep text readable as cells darken
return `background:rgb(${r},${g},${b});color:${lum < 140 ? '#fff' : '#2a2f36'}`;
}
const wordColor = $derived.by(() => { const wordColor = $derived.by(() => {
const m = new Map(); const m = new Map();
for (const w of foundWords) m.set(w.word, w.ci); for (const w of foundWords) m.set(w.word, w.ci);
return m; return m;
}); });
// --- the clock counts ACTIVE play only -----------------------------------
// Wall-clock timing made "finish in one sitting" feel mandatory — the
// opposite of calm. The clock runs only while the puzzle is on screen
// (tab visible, window focused, game unfinished); stepping away pauses it,
// coming back resumes it, and several sittings simply add up.
function playedNow() { return playedMs + (segStart ? Date.now() - segStart : 0); }
function pauseClock(save = true) {
if (!segStart) return;
playedMs += Date.now() - segStart; segStart = 0;
if (save) persist(); // don't lose the segment if the tab dies
}
function resumeClock() {
if (!segStart && !loading && status === 'playing' && !document.hidden) segStart = Date.now();
}
$effect(() => {
const onVis = () => (document.hidden ? pauseClock() : resumeClock());
const onAway = () => pauseClock();
document.addEventListener('visibilitychange', onVis);
window.addEventListener('pagehide', onAway);
window.addEventListener('blur', onAway);
window.addEventListener('focus', onVis);
return () => {
document.removeEventListener('visibilitychange', onVis);
window.removeEventListener('pagehide', onAway);
window.removeEventListener('blur', onAway);
window.removeEventListener('focus', onVis);
};
});
async function load() { async function load() {
const seq = ++loadSeq; // stale-load guard for rapid size switches const seq = ++loadSeq; // stale-load guard for rapid size switches
loading = true; ready = false; loading = true; ready = false;
foundWords = []; sel = []; resultMs = 0; startTime = 0; foundWords = []; sel = []; resultMs = 0; playedMs = 0; segStart = 0;
try { try {
const p = await getJSON('/api/puzzle/wordsearch?variant=' + size); const p = await getJSON('/api/puzzle/wordsearch?variant=' + size);
if (seq !== loadSeq) return; // a newer size was selected — abandon if (seq !== loadSeq) return; // a newer size was selected — abandon
theme = p.theme; words = p.words; grid = p.grid; date = p.date; theme = p.theme; words = p.words; grid = p.grid; date = p.date;
restore(); restore();
if (!startTime) startTime = Date.now();
try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; } try { best = JSON.parse(localStorage.getItem(bestKey) || '0'); } catch { best = 0; }
} catch { } catch {
if (seq !== loadSeq) return; if (seq !== loadSeq) return;
@@ -59,30 +110,66 @@
} }
if (seq !== loadSeq) return; if (seq !== loadSeq) return;
loading = false; loading = false;
resumeClock();
requestAnimationFrame(() => (ready = true)); requestAnimationFrame(() => (ready = true));
// Reconcile with the server in the background (signed-in only): pull any
// progress from another device, and pull the cross-device best time.
syncNow();
fetchGameStats('wordsearch', size).then((st) => {
if (st && st.best && (!best || st.best < best)) best = st.best;
});
}
// Keep only finds whose cells still spell their word in the CURRENT grid —
// guards stale highlights if the puzzle/layout changed (and validates finds
// arriving from another device, which is the same date+size grid).
function validFinds(list) {
return (list || []).filter((fw) =>
fw && Array.isArray(fw.cells) && words.includes(fw.word) &&
fw.cells.map(([r, c]) => (grid[r] && grid[r][c]) || '').join('') === fw.word);
} }
function restore() { function restore() {
try { try {
const s = JSON.parse(localStorage.getItem(stateKey) || 'null'); const s = JSON.parse(localStorage.getItem(stateKey) || 'null');
if (s && Array.isArray(s.foundWords)) { if (s && Array.isArray(s.foundWords)) {
// Keep only finds whose stored cells still spell their word in the CURRENT foundWords = validFinds(s.foundWords);
// grid — guards against stale highlights if the day's puzzle changed. playedMs = s.played || 0; // pre-"active clock" saves restart at 0:00 — the kind direction
const valid = s.foundWords.filter((fw) => resultMs = foundWords.length === words.length ? (s.ms || 0) : 0;
fw && Array.isArray(fw.cells) && words.includes(fw.word) &&
fw.cells.map(([r, c]) => (grid[r] && grid[r][c]) || '').join('') === fw.word);
foundWords = valid;
startTime = s.startTime || 0;
resultMs = valid.length === words.length ? (s.ms || 0) : 0;
} }
} catch { /* ignore */ } } catch { /* ignore */ }
onstatus?.(summary()); onstatus?.(summary());
} }
function persist() { function persist() {
try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, startTime, ms: resultMs, status })); } try { localStorage.setItem(stateKey, JSON.stringify({ foundWords, played: playedNow(), ms: resultMs, status })); }
catch { /* ignore */ } catch { /* ignore */ }
onstatus?.(summary()); onstatus?.(summary());
} }
// --- cross-device sync (signed-in only; merged server-side) ---
let syncTimer;
function adopt(merged) {
if (!merged) return;
const have = new Set(foundWords.map((w) => w.word));
for (const fw of validFinds(merged.foundWords)) {
if (!have.has(fw.word)) { foundWords = [...foundWords, fw]; have.add(fw.word); }
}
// renumber colours by find order so overlap blends stay consistent
foundWords = foundWords.map((fw, i) => ({ ...fw, ci: i % PALETTE.length }));
// another device may have accumulated more active time — credit the larger
if ((merged.played || 0) > playedNow()) {
playedMs = merged.played;
if (segStart) segStart = Date.now();
}
if (foundWords.length === words.length && merged.ms) resultMs = Math.min(resultMs || merged.ms, merged.ms);
persist();
}
async function syncNow() {
const d = date, sz = size; // pin against a size switch mid-flight
const merged = await pushGameState('wordsearch', sz, d, { foundWords, played: playedNow(), ms: resultMs });
if (d === date && sz === size) adopt(merged); // ignore if the user switched away
}
function syncSoon() { clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, 1200); }
function summary() { return { game: 'wordsearch', size, date, status, found: foundWords.length, total: words.length, ms: resultMs }; } function summary() { return { game: 'wordsearch', size, date, status, found: foundWords.length, total: words.length, ms: resultMs }; }
function cellAt(e) { function cellAt(e) {
@@ -92,7 +179,7 @@
function down(e) { function down(e) {
if (status === 'done') return; if (status === 'done') return;
selecting = true; selecting = true;
if (!startTime) startTime = Date.now(); resumeClock(); // safety net if a focus event was missed
sel = [cellAt(e)]; sel = [cellAt(e)];
gridEl.setPointerCapture?.(e.pointerId); gridEl.setPointerCapture?.(e.pointerId);
e.preventDefault(); e.preventDefault();
@@ -111,14 +198,18 @@
function evaluate(cells) { function evaluate(cells) {
const hit = matchWord(cells, grid, words, found); const hit = matchWord(cells, grid, words, found);
if (!hit) return; if (!hit) return;
if (foundWords.length === 0) trackGame('wordsearch', 'started'); // first word found = started
foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }]; foundWords = [...foundWords, { word: hit, cells: cells.map((c) => [...c]), ci: foundWords.length % PALETTE.length }];
okFlash = true; setTimeout(() => (okFlash = false), 500); okFlash = true; setTimeout(() => (okFlash = false), 500);
if (foundWords.length === words.length) finish(); if (foundWords.length === words.length) finish();
persist(); persist();
syncSoon(); // push this find (and completion) to the server, debounced
} }
function finish() { function finish() {
resultMs = startTime ? Date.now() - startTime : 0; pauseClock(false); // close the open segment; persist follows in evaluate()
trackGame('wordsearch', 'completed');
resultMs = playedMs;
if (resultMs && (!best || resultMs < best)) { if (resultMs && (!best || resultMs < best)) {
best = resultMs; best = resultMs;
try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ } try { localStorage.setItem(bestKey, JSON.stringify(best)); } catch { /* ignore */ }
@@ -132,9 +223,9 @@
function share() { function share() {
const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || ''; const label = { small: 'Small', med: 'Medium', large: 'Large' }[size] || '';
const text = `Upbeat Bytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\nupbeatbytes.com/play`; const text = `upbeatBytes · Word Search (${label}) ${date}\n${theme} — cleared in ${fmt(resultMs)}\n${gameShareUrl('wordsearch', size)}`;
if (navigator.share) navigator.share({ text }).catch(() => {}); if (navigator.share) navigator.share({ text }).then(() => trackGame('wordsearch', 'shared')).catch(() => {});
else navigator.clipboard?.writeText(text).then(() => { copied = true; setTimeout(() => (copied = false), 1500); }); else navigator.clipboard?.writeText(text).then(() => { trackGame('wordsearch', 'shared'); copied = true; setTimeout(() => (copied = false), 1500); });
} }
// Load on mount and whenever the size changes. Tracks ONLY `size`. // Load on mount and whenever the size changes. Tracks ONLY `size`.
@@ -157,7 +248,7 @@
{#each rowStr.split('') as ch, c (c)} {#each rowStr.split('') as ch, c (c)}
{@const key = r + ',' + c} {@const key = r + ',' + c}
<div class="cell" class:sel={selSet.has(key)} <div class="cell" class:sel={selSet.has(key)}
style={cellColor.has(key) && !selSet.has(key) ? `background:${PALETTE[cellColor.get(key)]};color:#2a2f36` : ''}>{ch}</div> style={cellColor.has(key) && !selSet.has(key) ? cellStyle(cellColor.get(key)) : ''}>{ch}</div>
{/each} {/each}
{/each} {/each}
</div> </div>
+36
View File
@@ -0,0 +1,36 @@
// Reusable in-development gate. Lets us ship a half-built game LIVE (so we can
// test the real, fully-wired experience) without any public visitor stumbling
// onto it:
// • its card shows on /play only for admins (or a preview link)
// • its route bounces non-admins back to the hub
// • search engines never index it (the route guard + noindex)
// Launch = remove the game key from IN_DEV. Future dev games just join the set.
//
// This is casual concealment, not security — the API is harmless on its own.
// 'bloom' launched 2026-06-15 — public. 'match' (Memory/Color Match) launched
// 2026-06-17 — public. 'zen' (UB/Zen Den) gated while UB is ironed out (model
// tabled). Add a key here to gate any in-dev game/section.
export const IN_DEV = new Set(['zen']);
// A non-obvious bypass so we can test logged-out / incognito: /play?...&preview=KEY
const PREVIEW_KEY = 'sunflower';
export function isDevGated(gameKey) {
return IN_DEV.has(gameKey);
}
// Can THIS viewer see in-dev games? Admins always; anyone with the preview token.
export function canSeeDev(user, url) {
if (user?.is_admin) return true;
try {
return url?.searchParams?.get('preview') === PREVIEW_KEY;
} catch {
return false;
}
}
// True when this game should be hidden/blocked for this viewer.
export function blockedForViewer(gameKey, user, url) {
return isDevGated(gameKey) && !canSeeDev(user, url);
}
File diff suppressed because one or more lines are too long
+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');
});
});
+83
View File
@@ -0,0 +1,83 @@
// Memory Match board engine — fully deterministic ("code disposes"): a given seed
// always yields the same board, so the daily is shared across devices with no
// server round-trip. Faces are stored as KEYS (icon name or color key), never raw
// indices, so saved progress survives layout tweaks (Codex).
import { ICON_KEYS } from './icons.js';
import { COLORS, deltaE } from './palette.js';
// Tiers — Gentle/Standard match pairs; Expert matches THREE of a kind and uses a
// bigger grid so it reads as a real step up (Codex's sizing).
// gentle 4×3 = 12 cards = 6 pairs
// standard 4×4 = 16 cards = 8 pairs
// expert 6×4 = 24 cards = 8 triples
export const TIERS = {
gentle: { label: 'Gentle', cols: 4, rows: 3, faces: 6, matchN: 2 },
standard: { label: 'Standard', cols: 4, rows: 4, faces: 8, matchN: 2 },
expert: { label: 'Expert', cols: 6, rows: 4, faces: 8, matchN: 3 },
};
export const TIER_KEYS = Object.keys(TIERS);
export const FORMAT_KEYS = ['icons', 'colors'];
// Minimum perceptual gap (ΔE) we try to keep between a board's colors so two
// near-identical shades never appear together; relaxed only if a board can't fill.
const MIN_DELTA_E = 26;
function hashStr(s) {
let h = 2166136261 >>> 0;
for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619); }
return h >>> 0;
}
function mulberry32(a) {
return function () {
a |= 0; a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
function shuffle(arr, rng) {
const a = arr.slice();
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng() * (i + 1));
[a[i], a[j]] = [a[j], a[i]];
}
return a;
}
// Pick `n` colors, seed-varied but spaced: walk a seeded shuffle keeping only
// colors at least `threshold` ΔE from those already chosen; relax if we can't fill.
function pickColors(n, rng) {
const order = shuffle(COLORS, rng);
for (let threshold = MIN_DELTA_E; threshold >= 0; threshold -= 3) {
const chosen = [];
for (const c of order) {
if (chosen.every((p) => deltaE(p.lab, c.lab) >= threshold)) chosen.push(c);
if (chosen.length === n) break;
}
if (chosen.length === n) return chosen.map((c) => c.key);
}
return order.slice(0, n).map((c) => c.key); // pool too small (shouldn't happen)
}
function pickFaces(format, n, rng) {
if (format === 'colors') return pickColors(n, rng);
return shuffle(ICON_KEYS, rng).slice(0, n);
}
// Build a board. `seed` is the day's date (daily) or a random token (free play).
export function buildBoard({ format = 'icons', tier = 'standard', seed = '' } = {}) {
const t = TIERS[tier] || TIERS.standard;
const fmt = FORMAT_KEYS.includes(format) ? format : 'icons';
const rng = mulberry32(hashStr(`match:v1:${fmt}:${tier}:${seed}`));
const faces = pickFaces(fmt, t.faces, rng);
const multiset = [];
for (const key of faces) for (let c = 0; c < t.matchN; c++) multiset.push(key);
const cards = shuffle(multiset, rng).map((key, id) => ({ id, key }));
return { cards, faces, matchN: t.matchN, cols: t.cols, rows: t.rows, format: fmt, tier, label: t.label };
}
// A short token for a fresh free-play board (deterministic given the inputs so a
// reload resumes the same board); callers persist it.
export function freeSeed(n) {
return 'f' + (n >>> 0).toString(36);
}
@@ -0,0 +1,51 @@
import { describe, it, expect } from 'vitest';
import { buildBoard, TIERS, TIER_KEYS } from './board.js';
import { COLOR_BY_KEY, deltaE } from './palette.js';
import { ICON_KEYS } from './icons.js';
describe('match board', () => {
it('is deterministic for a given seed', () => {
const a = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-16' });
const b = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-16' });
expect(a.cards).toEqual(b.cards);
});
it('different days give different boards', () => {
const a = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-16' });
const b = buildBoard({ format: 'icons', tier: 'standard', seed: '2026-06-17' });
expect(a.cards).not.toEqual(b.cards);
});
for (const tier of TIER_KEYS) {
it(`${tier}: correct card count and exactly matchN of each face`, () => {
const t = TIERS[tier];
const { cards, faces, matchN } = buildBoard({ tier, seed: 's' });
expect(cards.length).toBe(t.cols * t.rows);
expect(faces.length).toBe(t.faces);
expect(matchN).toBe(t.matchN);
for (const f of faces) {
expect(cards.filter((c) => c.key === f).length).toBe(matchN);
}
// every card is one of the chosen faces
expect(cards.every((c) => faces.includes(c.key))).toBe(true);
});
}
it('icon boards draw from the icon set', () => {
const { faces } = buildBoard({ format: 'icons', tier: 'expert', seed: 'x' });
expect(faces.every((k) => ICON_KEYS.includes(k))).toBe(true);
});
it('color boards are perceptually spaced (no near-identical pair)', () => {
// sweep many seeds; every board's colors must be comfortably distinct
for (let i = 0; i < 80; i++) {
const { faces } = buildBoard({ format: 'colors', tier: 'standard', seed: 'd' + i });
const labs = faces.map((k) => COLOR_BY_KEY[k].lab);
let min = Infinity;
for (let a = 0; a < labs.length; a++)
for (let b = a + 1; b < labs.length; b++)
min = Math.min(min, deltaE(labs[a], labs[b]));
expect(min).toBeGreaterThan(15); // never confusably close
}
});
});
+41
View File
@@ -0,0 +1,41 @@
// Memory Match icon set — in-repo, owned, no dependency. Each value is the inner
// SVG markup on a 0 0 24 24 viewBox; MatchIcon.svelte supplies the shared styling
// (stroke=currentColor, fill=none, round caps) so every icon reads as one calm
// line family. Keep additions simple and recognizable; quality over count.
export const ICONS = {
sun: '<circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.4M12 19.1v2.4M2.5 12h2.4M19.1 12h2.4M5.2 5.2l1.7 1.7M17.1 17.1l1.7 1.7M18.8 5.2l-1.7 1.7M6.9 17.1l-1.7 1.7"/>',
moon: '<path d="M20 14.5A8 8 0 1 1 11 4a6.5 6.5 0 0 0 9 10.5z"/>',
star: '<path d="M12 3.5l2.6 5.3 5.9.9-4.25 4.15 1 5.85L12 17l-5.25 2.75 1-5.85L3.5 9.7l5.9-.9z"/>',
cloud: '<path d="M7 18h9.5a3.5 3.5 0 0 0 .3-7 5 5 0 0 0-9.6-1.3A3.8 3.8 0 0 0 7 18z"/>',
raindrop: '<path d="M12 3.5c3.5 4.2 5.5 7 5.5 9.8a5.5 5.5 0 0 1-11 0C6.5 10.5 8.5 7.7 12 3.5z"/>',
wave: '<path d="M3 9c2 0 2.5 2 4.5 2S10 9 12 9s2.5 2 4.5 2S19 9 21 9M3 15c2 0 2.5 2 4.5 2S10 15 12 15s2.5 2 4.5 2S19 15 21 15"/>',
leaf: '<path d="M5 19C5 11 11 5 19 5c0 8-6 14-14 14z"/><path d="M5 19C9 15 12 12 16 9"/>',
flower: '<circle cx="12" cy="12" r="2.3"/><circle cx="12" cy="6.6" r="2.3"/><circle cx="12" cy="17.4" r="2.3"/><circle cx="7.3" cy="9.3" r="2.3"/><circle cx="16.7" cy="9.3" r="2.3"/><circle cx="7.3" cy="14.7" r="2.3"/><circle cx="16.7" cy="14.7" r="2.3"/>',
seedling: '<path d="M12 20v-7M12 13c0-3 2.5-5 6-5 0 3.5-2.5 5-6 5zM12 13c0-2.5-2-4.5-5-4.5 0 3 2 4.5 5 4.5z"/>',
tree: '<path d="M12 3 6 11h3l-4 6h14l-4-6h3z"/><path d="M12 17v4"/>',
mountain: '<path d="M3 19l6-10 4 6 2.5-4L21 19z"/>',
shell: '<path d="M4 18C4 10 7.5 5 12 5s8 5 8 13z"/><path d="M12 18V6M8 18 9.5 7M16 18 14.5 7"/>',
feather: '<path d="M5 19C7 9 12 5 19 5c0 7-4 12-12 13z"/><path d="M5 19 13 11"/>',
acorn: '<path d="M6 8c0-2 2.7-3.5 6-3.5S18 6 18 8zM7 8a5 5 0 0 0 10 0M12 18v2"/>',
butterfly: '<path d="M12 6v12M12 9C8 4 4 6 4 10s4 5 8 2M12 9c4-5 8-3 8 1s-4 5-8 2"/>',
rainbow: '<path d="M3 18a9 9 0 0 1 18 0M6 18a6 6 0 0 1 12 0M9 18a3 3 0 0 1 6 0"/>',
heart: '<path d="M12 20S4 14.5 4 9a4 4 0 0 1 8-1 4 4 0 0 1 8 1c0 5.5-8 11-8 11z"/>',
sparkle: '<path d="M12 3c.6 4.5 1.5 5.4 6 6-4.5.6-5.4 1.5-6 6-.6-4.5-1.5-5.4-6-6 4.5-.6 5.4-1.5 6-6z"/>',
home: '<path d="M4 11 12 4l8 7M6 10v9h12v-9M10 19v-5h4v5"/>',
book: '<path d="M12 6C10 4.5 7 4.5 4 5v13c3-.5 6-.5 8 1 2-1.5 5-1.5 8-1V5c-3-.5-6-.5-8 1zM12 6v13"/>',
teacup: '<path d="M5 9h12v3a6 6 0 0 1-12 0zM17 10h2a2 2 0 0 1 0 4h-2M5 19h12"/>',
candle: '<path d="M9 11h6v9H9zM12 11V8M12 8c0-1.6-2-2-2-3.5C10 3.4 11 3 12 3s2 .4 2 1.5C14 6 12 6.4 12 8z"/>',
lantern: '<path d="M10 3h4M8 6h8l-1 12H9zM9 6V4.5h6V6M8 18h8M12 9v6"/>',
compass: '<circle cx="12" cy="12" r="9"/><path d="M15 9l-2.5 5.5L9 15l2.5-5.5z"/>',
kite: '<path d="M12 3 19 10 12 17 5 10zM12 3v14M5 10h14M12 17l-2 4M12 17l2 4"/>',
note: '<path d="M9 18V6l9-2v12"/><circle cx="7" cy="18" r="2"/><circle cx="16" cy="16" r="2"/>',
boat: '<path d="M4 16h16l-2.5 4H6.5zM12 14V3l6 8zM12 14H6l6-7"/>',
fish: '<path d="M3 12c2.5-3.5 6-5 9-5s5.5 1.5 7 5c-1.5 3.5-4 5-7 5s-6.5-1.5-9-5z"/><path d="M19 12l3-2.5v5z"/><circle cx="8" cy="11" r="0.9"/>',
bird: '<path d="M3 9c3-2 6 0 9 0s6-2 9 0c-3 3-5 4-9 4S6 12 3 9z"/>',
mushroom: '<path d="M4 11a8 8 0 0 1 16 0zM9 11v5a3 3 0 0 0 6 0v-5"/>',
bell: '<path d="M6.5 17c1.3-1 1.8-3 1.8-6a3.7 3.7 0 0 1 7.4 0c0 3 .5 5 1.8 6zM10 17.5a2 2 0 0 0 4 0M12 4.2V2.6"/>',
snowflake: '<path d="M12 3v18M4.2 7.5l15.6 9M19.8 7.5l-15.6 9M9 5l3 2 3-2M9 19l3-2 3 2"/>',
clover: '<circle cx="9" cy="10" r="3"/><circle cx="15" cy="10" r="3"/><circle cx="12" cy="14.5" r="3"/><path d="M12 16.5V21"/>',
};
export const ICON_KEYS = Object.keys(ICONS);
+50
View File
@@ -0,0 +1,50 @@
// Color Match palette — a hand-curated set of calm-but-distinct colors. The board
// builder picks a perceptually-spaced subset (see board.js) so two near-identical
// shades never land on the same board. Each color is NAMED (drives the aria-label
// and the optional colorblind "assist" glyph), and varies in LIGHTNESS as well as
// hue so confusable hue pairs (e.g. green/teal) still differ by brightness.
//
// `assist` is a short letter shown in the corner when the player turns assist on —
// kept to 12 chars, unique enough to disambiguate the swatch without reading as
// a full label. Default play is pure color; assist is opt-in.
const RAW = [
{ key: 'color-rose', name: 'Rose', hex: '#d76a86', assist: 'Ro' },
{ key: 'color-coral', name: 'Coral', hex: '#e07a52', assist: 'Co' },
{ key: 'color-amber', name: 'Amber', hex: '#e3a32f', assist: 'Am' },
{ key: 'color-gold', name: 'Gold', hex: '#cdb63c', assist: 'Go' },
{ key: 'color-lime', name: 'Lime', hex: '#8fb24a', assist: 'Li' },
{ key: 'color-green', name: 'Green', hex: '#4a9b5c', assist: 'Gr' },
{ key: 'color-teal', name: 'Teal', hex: '#2f9c8e', assist: 'Te' },
{ key: 'color-cyan', name: 'Cyan', hex: '#3aa6c4', assist: 'Cy' },
{ key: 'color-sky', name: 'Sky', hex: '#5b8fd4', assist: 'Sk' },
{ key: 'color-blue', name: 'Blue', hex: '#4f63c6', assist: 'Bl' },
{ key: 'color-indigo', name: 'Indigo', hex: '#6a59b2', assist: 'In' },
{ key: 'color-violet', name: 'Violet', hex: '#9460ba', assist: 'Vi' },
{ key: 'color-plum', name: 'Plum', hex: '#b25a95', assist: 'Pl' },
{ key: 'color-brown', name: 'Brown', hex: '#9c6b45', assist: 'Br' },
{ key: 'color-sand', name: 'Sand', hex: '#cdb38c', assist: 'Sa' },
{ key: 'color-slate', name: 'Slate', hex: '#5d6b78', assist: 'Sl' },
{ key: 'color-charcoal', name: 'Charcoal', hex: '#3a4250', assist: 'Ch' },
{ key: 'color-cream', name: 'Cream', hex: '#e6dfca', assist: 'Cm' },
];
function srgbToLin(c) { c /= 255; return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); }
function hexToLab(hex) {
const r = srgbToLin(parseInt(hex.slice(1, 3), 16));
const g = srgbToLin(parseInt(hex.slice(3, 5), 16));
const b = srgbToLin(parseInt(hex.slice(5, 7), 16));
const X = r * 0.4124 + g * 0.3576 + b * 0.1805;
const Y = r * 0.2126 + g * 0.7152 + b * 0.0722;
const Z = r * 0.0193 + g * 0.1192 + b * 0.9505;
const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
const fx = f(X / 0.95047), fy = f(Y / 1), fz = f(Z / 1.08883);
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
}
// CIE76 ΔE — adequate for "are these two swatches obviously different".
export function deltaE(a, b) { return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]); }
export const COLORS = RAW.map((c) => ({ ...c, lab: hexToLab(c.hex) }));
export const COLOR_BY_KEY = Object.fromEntries(COLORS.map((c) => [c.key, c]));
export const COLOR_KEYS = COLORS.map((c) => c.key);
+43
View File
@@ -0,0 +1,43 @@
// Cross-device game-state sync. Local-first: games always paint from
// localStorage instantly; these calls reconcile with the server in the
// background. Signed-out players stay purely local (no-ops here).
import { getJSON, putJSON } from './api.js';
import { auth } from './auth.svelte.js';
// Push this device's state → the server merges it with any other device's
// progress → returns the merged state to adopt. null when signed out / on error
// (the game just stays on its local copy).
export async function pushGameState(game, variant, date, local) {
if (!auth.user) return null;
try {
const r = await putJSON('/api/games/state', { game, variant, date, state: local || {} }, { timeout: 8000 });
return r?.state ?? null;
} catch {
return null;
}
}
// Reconcile MANY (game, variant) states for one date in a single request — used by
// the hub so a /play load doesn't fan out a dozen calls. items: [{game, variant,
// state}]. Returns the merged states array to adopt, or null when signed out / on error.
export async function pushGameStatesBatch(date, items) {
if (!auth.user) return null;
try {
const r = await putJSON('/api/games/state/batch', { date, items }, { timeout: 8000 });
return r?.states ?? null;
} catch {
return null;
}
}
// Derived record (streak / distribution / best) computed server-side from the
// player's synced states, so it's consistent across devices. null when signed out.
export async function fetchGameStats(game, variant) {
if (!auth.user) return null;
try {
const q = `game=${encodeURIComponent(game)}&variant=${encodeURIComponent(variant)}`;
return (await getJSON('/api/games/stats?' + q))?.stats ?? null;
} catch {
return null;
}
}
+14 -4
View File
@@ -11,6 +11,9 @@ export function blank() {
// [] means "not customized" — the feed falls back to the default lane set. // [] means "not customized" — the feed falls back to the default lane set.
// The backend filter parser ignores unknown keys, so this rides along safely. // The backend filter parser ignores unknown keys, so this rides along safely.
lanes: [], lanes: [],
// UI-only: which daily items fill the reader's "calm set" (ritual item keys).
// null = not customized → all eligible items. Rides along like `lanes`.
ritual: null,
}; };
} }
@@ -77,9 +80,16 @@ export function saveJSON(key, value) {
// "" or "prefs=<encoded json>" for a query string. // "" or "prefs=<encoded json>" for a query string.
export function param(prefs) { export function param(prefs) {
// Serialize ONLY real filter fields — UI-only keys (lanes, ritual) must not
// ride in ?prefs= or they churn the feed URL / edge-cache key for no reason.
const f = {
include_topics: prefs.include_topics, include_flavors: prefs.include_flavors,
mute_topics: prefs.mute_topics, mute_flavors: prefs.mute_flavors,
avoid_terms: prefs.avoid_terms, pauses: prefs.pauses, max_cortisol: prefs.max_cortisol,
};
const empty = const empty =
!prefs.include_topics.length && !prefs.include_flavors.length && !f.include_topics.length && !f.include_flavors.length &&
!prefs.mute_topics.length && !prefs.mute_flavors.length && !f.mute_topics.length && !f.mute_flavors.length &&
!prefs.avoid_terms.length && !prefs.pauses.length && prefs.max_cortisol == null; !f.avoid_terms.length && !f.pauses.length && f.max_cortisol == null;
return empty ? '' : 'prefs=' + encodeURIComponent(JSON.stringify(prefs)); return empty ? '' : 'prefs=' + encodeURIComponent(JSON.stringify(f));
} }
+105
View File
@@ -0,0 +1,105 @@
// Daily Ritual — the day's "calm set", a finite daily loop. Derived ENTIRELY
// from signals we already store (game state in localStorage + a brief-seen flag)
// — no backend, no new sync.
//
// "Today" is ALWAYS the server's puzzle/brief date passed in by the caller,
// never the browser's local date, so the daily reset matches the site's actual
// daily content (a reader in another timezone never sees "fresh set tomorrow"
// while today's puzzle is still up).
//
// Spirit (Claude + Codex): gentle and non-instrumental. "Enjoyed," not
// "completed"; "N of M · fresh set tomorrow," never "finish the rest." Brief
// counts only when the end-cap is reached (the finite read), not on page open.
//
// The set is CURATABLE: only daily-cadence "one-a-day, finish-and-done" things
// are eligible (never Free Play / ambient toys), and the reader chooses which of
// those fill THEIR set (settings → Calm set). Default = all eligible items.
const BRIEF_SEEN_KEY = (date) => `goodnews:briefSeen:${date}`;
function read(key) {
try { return JSON.parse(localStorage.getItem(key) || 'null'); } catch { return null; }
}
// Mark the brief as enjoyed for `date` — call only when the reader actually
// reaches the end-cap, not merely on open.
export function markBriefSeen(date) {
if (!date) return;
try { localStorage.setItem(BRIEF_SEEN_KEY(date), '1'); } catch { /* ignore */ }
}
export function briefSeen(date) {
if (!date) return false;
try { return localStorage.getItem(BRIEF_SEEN_KEY(date)) === '1'; } catch { return false; }
}
// A word is enjoyed once you've seen it through (won or lost) on either length.
function wordEnjoyed(date) {
for (const v of ['5', '6']) {
const s = read(`goodnews:word:${v}:${date}`);
if (s && (s.status === 'won' || s.status === 'lost')) return true;
}
return false;
}
// A word search is enjoyed once any size is cleared.
function wordsearchEnjoyed(date) {
for (const sz of ['small', 'med', 'large']) {
const s = read(`goodnews:wordsearch:${sz}:${date}`);
if (s && s.status === 'done') return true;
}
return false;
}
// Bloom is enjoyed once the daily reaches the top tier (Flourishing) — the day's
// goal, the "saw it through" point (BloomGame persists `top`). Free Play doesn't count.
function bloomEnjoyed(date) {
const s = read(`goodnews:bloom:${date}`);
return !!(s && s.top);
}
// Memory Match is enjoyed once ANY daily board (any tier/format) is cleared — the
// reader's chosen mood-level counts; Free Play never does.
const MATCH_VARIANTS = ['gentle', 'standard', 'expert'].flatMap(
(t) => ['icons', 'colors'].map((f) => `${t}-${f}`));
function matchEnjoyed(date) {
for (const v of MATCH_VARIANTS) {
const s = read(`goodnews:match:${v}:${date}`);
if (s && s.done) return true;
}
return false;
}
// Eligible daily-cadence items, in display order. New daily games join here.
export const RITUAL_ITEMS = [
{ key: 'brief', label: 'Brief', href: '/', done: briefSeen },
{ key: 'word', label: 'Daily Word', href: '/play?game=word', done: wordEnjoyed },
{ key: 'wordsearch', label: 'Word Search', href: '/play?game=wordsearch', done: wordsearchEnjoyed },
{ key: 'bloom', label: 'Bloom', href: '/play?game=bloom&v=daily', done: bloomEnjoyed },
{ key: 'match', label: 'Memory Match', href: '/play?game=match&v=daily-icons-standard', done: matchEnjoyed },
];
const ALL_KEYS = RITUAL_ITEMS.map((i) => i.key);
// The default (out-of-box) set is a CURATED subset of the eligible items, kept
// separate from RITUAL_ITEMS on purpose: adding a new eligible daily game must
// NOT auto-bloat every uncustomized reader's set. A new game becomes selectable
// in the chooser immediately; whether it joins the default is a deliberate
// case-by-case decision (add its key here). `null` pref = "follow this default."
export const DEFAULT_KEYS = ['brief', 'word', 'wordsearch', 'bloom'];
// Normalize the reader's selection: null/undefined → the curated default;
// an array → those keys in canonical order; unknown keys ignored.
export function ritualKeys(enabled) {
if (!Array.isArray(enabled)) return DEFAULT_KEYS.filter((k) => ALL_KEYS.includes(k));
return ALL_KEYS.filter((k) => enabled.includes(k));
}
// The day's ritual snapshot for the reader's chosen set. Reads localStorage on
// each call (cheap) — callers recompute on mount / navigation / focus.
export function ritualState(date, enabled) {
const keys = ritualKeys(enabled);
const items = RITUAL_ITEMS
.filter((i) => keys.includes(i.key))
.map((i) => ({ key: i.key, label: i.label, href: i.href, done: i.done(date) }));
return { items, count: items.filter((i) => i.done).length, total: items.length };
}
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ritualState, ritualKeys, markBriefSeen, briefSeen, RITUAL_ITEMS, DEFAULT_KEYS } from './ritual.js';
// Minimal localStorage stand-in (jsdom may not provide one in this config).
beforeEach(() => {
const store = new Map();
globalThis.localStorage = {
getItem: (k) => (store.has(k) ? store.get(k) : null),
setItem: (k, v) => store.set(k, String(v)),
removeItem: (k) => store.delete(k),
clear: () => store.clear(),
};
});
const D = '2026-06-14';
const done = (s, key) => s.items.find((i) => i.key === key)?.done;
describe('ritualState', () => {
it('defaults to the curated DEFAULT_KEYS set, none done', () => {
const s = ritualState(D);
expect(s.items.map((i) => i.key)).toEqual(DEFAULT_KEYS);
expect(s.count).toBe(0);
expect(s.total).toBe(DEFAULT_KEYS.length);
});
it('counts the brief only once the end-cap is marked', () => {
expect(briefSeen(D)).toBe(false);
markBriefSeen(D);
expect(done(ritualState(D), 'brief')).toBe(true);
expect(ritualState(D).count).toBe(1);
});
it('counts a word at a terminal state, a word search when cleared', () => {
localStorage.setItem(`goodnews:word:5:${D}`, JSON.stringify({ status: 'playing', guesses: ['aaaaa'] }));
expect(done(ritualState(D), 'word')).toBe(false); // in-progress isn't "enjoyed"
localStorage.setItem(`goodnews:word:6:${D}`, JSON.stringify({ status: 'lost' }));
expect(done(ritualState(D), 'word')).toBe(true);
localStorage.setItem(`goodnews:wordsearch:med:${D}`, JSON.stringify({ status: 'done', foundWords: ['x'] }));
expect(done(ritualState(D), 'wordsearch')).toBe(true);
});
it('counts Bloom only when the daily reached the top tier (top flag)', () => {
localStorage.setItem(`goodnews:bloom:${D}`, JSON.stringify({ found: ['area'], score: 4 }));
expect(done(ritualState(D), 'bloom')).toBe(false); // played but not top tier
localStorage.setItem(`goodnews:bloom:${D}`, JSON.stringify({ found: ['area'], score: 99, top: true }));
expect(done(ritualState(D), 'bloom')).toBe(true);
});
it('honors a curated set — only chosen items appear and count', () => {
markBriefSeen(D);
localStorage.setItem(`goodnews:bloom:${D}`, JSON.stringify({ top: true }));
const s = ritualState(D, ['brief', 'bloom']); // reader's chosen set
expect(s.items.map((i) => i.key)).toEqual(['brief', 'bloom']);
expect(s.total).toBe(2);
expect(s.count).toBe(2);
});
it('is scoped per server date — yesterday does not leak into today', () => {
markBriefSeen('2026-06-13');
expect(ritualState(D).count).toBe(0);
});
it('empty/missing date → nothing done (offline-safe)', () => {
expect(ritualState('').count).toBe(0);
});
});
describe('ritualKeys', () => {
it('null/undefined → the curated default (NOT all eligible items)', () => {
expect(ritualKeys(null)).toEqual(DEFAULT_KEYS);
expect(ritualKeys(undefined)).toEqual(DEFAULT_KEYS);
// default is a subset of eligible — adding a future eligible game must not auto-join it
expect(DEFAULT_KEYS.every((k) => RITUAL_ITEMS.some((i) => i.key === k))).toBe(true);
});
it('an array → those keys in canonical order, unknown ignored', () => {
expect(ritualKeys(['bloom', 'brief', 'nope'])).toEqual(['brief', 'bloom']);
expect(ritualKeys([])).toEqual([]); // explicitly empty set
});
});
+50
View File
@@ -0,0 +1,50 @@
// The homepage hub as data. Each "room" is one entry — add, reorder, or resize by
// editing this list. `size` drives the grid footprint (large | wide | tall | small);
// `preview` picks how the card shows itself (news/art = live snippet, static = blurb).
// Copy here is placeholder — owner-written final wording lands later.
export const ROOMS = [
{
id: 'news',
title: 'Good News',
blurb: "The world's quiet good, gathered each morning.",
href: '/', // becomes /news once the Brief is split out
cta: 'Read the Brief',
size: 'large',
preview: 'news',
icon: '📰',
tint: '#fbecdb', // soft amber
},
{
id: 'art',
title: 'Daily Art',
blurb: 'A masterwork a day, beautifully framed.',
href: '/art',
cta: 'View today',
size: 'wide',
preview: 'art',
icon: '🖼️',
tint: '#f4ecf3', // soft lilac
},
{
id: 'play',
title: 'Play',
blurb: 'Daily Word, Word Search, Bloom, Memory Match.',
href: '/play',
cta: 'Enter',
size: 'small',
preview: 'static',
icon: '🎲',
tint: '#e9f1f9', // soft sky
},
{
id: 'moment',
title: 'Daily Moment',
blurb: 'A small calm to carry with you.',
href: '#',
cta: 'Soon',
size: 'small',
preview: 'static',
icon: '🌿',
tint: '#eaf4ea', // soft sage
},
];
+207
View File
@@ -0,0 +1,207 @@
// The Zen Den aquarium — raw Three.js, lazy-loaded only on /zen (keeps three's
// ~150KB off every other page). Phase A: get UB alive on screen (Idle_swim),
// soft light, simple backdrop, capped pixel ratio, reduced-motion aware, clean
// teardown. The behavior engine (steering / spine-bend turns / darts) lands in
// Phase B; the structure here (kept animation actions, a single update loop)
// is set up to grow into it.
//
// UB ships (ub-split.glb) as THREE materials — UB_Body, UB_Fins, UB_Tail — so each
// renders differently. The single-mesh approach hit a hard ceiling: a transparent,
// double-sided whole fish can't depth-sort its own triangles (tail/eye drew through
// the face), and any alpha cutoff that cleaned the fin fringe also chewed the fins.
// Thin midline fans (tail especially) also self-overlap as they fold/sweep, so even
// single-sided *transparent* tail triangles bleed through each other → "two tails".
// The render is fully parameter-driven (see DEFAULTS) so it can be tuned live via
// /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-angelfish.glb';
// 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 = {
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,
};
const SIDE = { front: THREE.FrontSide, back: THREE.BackSide, double: THREE.DoubleSide };
export async function createAquarium(canvas, initial = {}) {
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const params = { ...DEFAULTS, ...initial };
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 1.5)); // mobile thermal guard
renderer.outputColorSpace = THREE.SRGBColorSpace;
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
camera.position.set(0, 0.25, 4.6);
camera.lookAt(0, 0, 0);
// Soft, warm light — calm, gently dimensional (no harsh shadows in Phase A).
scene.add(new THREE.HemisphereLight(0xffffff, 0x8aa0b0, 1.05));
const key = new THREE.DirectionalLight(0xfff2e0, 1.25);
key.position.set(2.5, 4, 3);
scene.add(key);
function resize() {
const w = canvas.clientWidth || 1;
const h = canvas.clientHeight || 1;
renderer.setSize(w, h, false);
camera.aspect = w / h;
camera.updateProjectionMatrix();
}
// Load UB.
const gltf = await new GLTFLoader().loadAsync(MODEL_URL);
const ub = gltf.scene;
// Center at origin and scale to a consistent on-screen size, regardless of the
// model's native units.
const box = new THREE.Box3().setFromObject(ub);
const size = box.getSize(new THREE.Vector3());
const center = box.getCenter(new THREE.Vector3());
ub.position.sub(center);
const maxDim = Math.max(size.x, size.y, size.z) || 1;
const baseScale = 2.6 / maxDim; // auto-fit; params.scale fine-tunes it live
// 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) 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.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) { // fins (+ tail): opaque alpha-tested by default
const m = fins.material;
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; // clean, coherent
}
fins.renderOrder = 2; m.needsUpdate = true;
}
}
applyMaterials();
ub.scale.setScalar(baseScale * (params.scale ?? 1));
// 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);
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
// shifts (mobile browser-chrome show/hide, future side panels) a bare 'resize' misses.
const onResize = () => resize();
window.addEventListener('resize', onResize);
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(onResize) : null;
ro?.observe(canvas);
const clock = new THREE.Clock();
renderer.setAnimationLoop(() => {
const dt = clock.getDelta();
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 {
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 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();
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 }; },
dispose() {
renderer.setAnimationLoop(null);
window.removeEventListener('resize', onResize);
ro?.disconnect();
scene.traverse((o) => {
o.geometry?.dispose?.();
const mats = Array.isArray(o.material) ? o.material : o.material ? [o.material] : [];
for (const m of mats) {
for (const v of Object.values(m)) v?.isTexture && v.dispose();
m.dispose?.();
}
});
renderer.dispose();
},
};
}
+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;
},
};
}
+12 -26
View File
@@ -2,38 +2,24 @@
import '../app.css'; import '../app.css';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import FeedbackModal from '$lib/components/FeedbackModal.svelte'; import FeedbackModal from '$lib/components/FeedbackModal.svelte';
import { fb, openFeedback, closeFeedback } from '$lib/feedback.svelte.js'; import { fb, closeFeedback } from '$lib/feedback.svelte.js';
import { trackVisit, armEngaged } from '$lib/analytics.js';
let { children } = $props(); let { children } = $props();
// Tell the boot-failure seatbelt (app.html) the app mounted — clears the // Tell the boot-failure seatbelt (app.html) the app mounted — clears the
// recovery card + timeout as soon as the shell hydrates. // recovery card + timeout as soon as the shell hydrates.
onMount(() => window.__ubBooted?.()); onMount(() => {
window.__ubBooted?.();
// 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> </script>
{@render children()} {@render children()}
{#if fb.open}<FeedbackModal onclose={closeFeedback} />{/if} {#if fb.open}<FeedbackModal onclose={closeFeedback} />{/if}
<footer class="site"> <!-- Footer is per-surface now (shared $lib/components/Footer.svelte) so each section
<div class="container"> can add its own extras; FeedbackModal stays global, triggered from any footer. -->
<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>
+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
+56 -2
View File
@@ -4,9 +4,11 @@
import { getJSON, postJSON } from '$lib/api.js'; import { getJSON, postJSON } from '$lib/api.js';
import { auth, savedIds, refresh, toggleFollow } from '$lib/auth.svelte.js'; import { auth, savedIds, refresh, toggleFollow } from '$lib/auth.svelte.js';
import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js'; import { prefs, initPrefs, persistPrefs } from '$lib/prefs.svelte.js';
import { RITUAL_ITEMS, ritualKeys, DEFAULT_KEYS } from '$lib/ritual.js';
import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js'; import { history, initHistory, loadServerHistory, removeOne, clearAll, record } from '$lib/history.svelte.js';
import { track } from '$lib/analytics.js'; import { track } from '$lib/analytics.js';
import { openFeedback } from '$lib/feedback.svelte.js'; import { openFeedback } from '$lib/feedback.svelte.js';
import Footer from '$lib/components/Footer.svelte';
import AccountPanel from '$lib/components/AccountPanel.svelte'; import AccountPanel from '$lib/components/AccountPanel.svelte';
import BoundariesPanel from '$lib/components/BoundariesPanel.svelte'; import BoundariesPanel from '$lib/components/BoundariesPanel.svelte';
import LanePicker from '$lib/components/LanePicker.svelte'; import LanePicker from '$lib/components/LanePicker.svelte';
@@ -25,9 +27,24 @@
{ key: 'following', label: 'Following' }, { key: 'following', label: 'Following' },
{ key: 'history', label: 'History' }, { key: 'history', label: 'History' },
{ key: 'lanes', label: 'Lanes' }, { key: 'lanes', label: 'Lanes' },
{ key: 'calmset', label: 'Calm set' },
{ key: 'boundaries', label: 'Boundaries' }, { key: 'boundaries', label: 'Boundaries' },
]; ];
// Calm set: which daily items make up the reader's gentle daily ritual.
let calmEnabled = $derived(new Set(ritualKeys(prefs.data.ritual)));
function toggleCalmItem(key) {
const cur = new Set(ritualKeys(prefs.data.ritual));
cur.has(key) ? cur.delete(key) : cur.add(key);
const keys = RITUAL_ITEMS.map((i) => i.key).filter((k) => cur.has(k));
// null = "follow the curated default" (only when the selection IS the default);
// otherwise store the explicit chosen list (frozen — future games won't auto-join).
const isDefault = keys.length === DEFAULT_KEYS.length && DEFAULT_KEYS.every((k) => cur.has(k));
prefs.data.ritual = isDefault ? null : keys;
prefs.data = { ...prefs.data };
persistPrefs();
}
let follows = $state([]); let follows = $state([]);
let followsReady = $state(false); let followsReady = $state(false);
async function loadFollowsList() { async function loadFollowsList() {
@@ -84,7 +101,7 @@
<header class="bar"> <header class="bar">
<div class="container inner"> <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"> <div class="baractions">
<button class="fb" onclick={openFeedback} aria-label="Share feedback" title="Share feedback"> <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> <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>
@@ -112,6 +129,25 @@
<p class="muted">Loading…</p> <p class="muted">Loading…</p>
{/if} {/if}
{:else if section === 'calmset'}
<section class="panel">
<h2>Your calm set</h2>
<p class="dnote">The gentle daily loop shown on Highlights and Play — a finite "today's set"
you can finish and feel caught up. Choose what fills yours, like building a workout. No
pressure, no streaks; uncheck anything you'd rather not see there.</p>
<ul class="calmpick">
{#each RITUAL_ITEMS as it (it.key)}
<li>
<button class="cpitem" class:on={calmEnabled.has(it.key)} onclick={() => toggleCalmItem(it.key)}
aria-pressed={calmEnabled.has(it.key)}>
<span class="cpmark" aria-hidden="true"></span>{it.label}
</button>
</li>
{/each}
</ul>
{#if calmEnabled.size === 0}<p class="empty">Your calm set is empty — it won't show until you add something.</p>{/if}
</section>
{:else if section === 'boundaries'} {:else if section === 'boundaries'}
<BoundariesPanel prefs={prefs.data} onchange={persistPrefs} /> <BoundariesPanel prefs={prefs.data} onchange={persistPrefs} />
@@ -158,7 +194,7 @@
<p class="gate">Sign in, then follow sources and topics to build your own calm lane.</p> <p class="gate">Sign in, then follow sources and topics to build your own calm lane.</p>
{:else} {:else}
<h2>Following</h2> <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} {#if follows.length}
<ul class="follows"> <ul class="follows">
{#each follows as f (f.kind + ':' + f.value)} {#each follows as f (f.kind + ':' + f.value)}
@@ -193,6 +229,8 @@
</div> </div>
</main> </main>
<Footer />
<style> <style>
.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; } .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; } .inner { display: flex; align-items: center; justify-content: space-between; height: 64px; }
@@ -226,6 +264,22 @@
.digest { margin-top: 16px; } .digest { margin-top: 16px; }
.digest h2 { font-size: 1.1rem; margin: 0 0 6px; } .digest h2 { font-size: 1.1rem; margin: 0 0 6px; }
.dnote { color: var(--muted); font-size: 0.9rem; margin: 0 0 14px; line-height: 1.5; } .dnote { color: var(--muted); font-size: 0.9rem; margin: 0 0 14px; line-height: 1.5; }
.calmpick { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; max-width: 360px; }
.cpitem {
display: flex; align-items: center; gap: 10px; width: 100%; text-align: left;
background: var(--surface); border: 1px solid var(--line); border-radius: 12px;
padding: 12px 16px; font: inherit; font-weight: 600; color: var(--muted); cursor: pointer;
transition: border-color 0.14s ease, color 0.14s ease;
}
.cpitem.on { color: var(--ink); border-color: var(--accent); }
.cpitem:hover { border-color: var(--accent); }
.cpmark { width: 18px; height: 18px; border-radius: 50%; border: 1.5px solid var(--line); flex-shrink: 0;
transition: background 0.14s ease, border-color 0.14s ease; }
.cpitem.on .cpmark {
background: var(--accent); border-color: var(--accent);
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M5 12l5 5 9-10' fill='none' stroke='white' stroke-width='2.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-size: 14px; background-repeat: no-repeat; background-position: center;
}
.dtoggle { .dtoggle {
font: inherit; font-size: 0.9rem; border-radius: 999px; padding: 9px 18px; cursor: pointer; font: inherit; font-size: 0.9rem; border-radius: 999px; padding: 9px 18px; cursor: pointer;
border: 1px solid var(--accent); background: var(--surface); color: var(--accent-deep); border: 1px solid var(--accent); background: var(--surface); color: var(--accent-deep);
+773 -16
View File
@@ -4,6 +4,7 @@
import { page } from '$app/stores'; import { page } from '$app/stores';
import { getJSON, postJSON, delJSON } from '$lib/api.js'; import { getJSON, postJSON, delJSON } from '$lib/api.js';
import { auth, refresh } from '$lib/auth.svelte.js'; import { auth, refresh } from '$lib/auth.svelte.js';
import EmojiPicker from '$lib/EmojiPicker.svelte';
let stats = $state(null); let stats = $state(null);
let feedback = $state([]); let feedback = $state([]);
@@ -21,6 +22,22 @@
try { await loadStats(); } catch { error = "Couldn't load stats."; } 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 () => { onMount(async () => {
if (!auth.ready) await refresh(); if (!auth.ready) await refresh();
if (!auth.user || !auth.user.is_admin) { if (!auth.user || !auth.user.is_admin) {
@@ -31,26 +48,216 @@
// Load all panels in PARALLEL — these were sequential awaits, so the page // Load all panels in PARALLEL — these were sequential awaits, so the page
// paid the (uncached, origin) round-trip six times back-to-back. One batch // paid the (uncached, origin) round-trip six times back-to-back. One batch
// instead of a chain. // instead of a chain.
const [, fb, cand, wp, ce, ws] = await Promise.all([ const [, fb, cand, wp, ce, ws, bq] = await Promise.all([
loadStats(), loadStats(),
getJSON('/api/admin/feedback'), getJSON('/api/admin/feedback'),
getJSON('/api/admin/candidates'), getJSON('/api/admin/candidates'),
getJSON('/api/admin/word/pool'), getJSON('/api/admin/word/pool'),
getJSON('/api/admin/client-errors'), getJSON('/api/admin/client-errors'),
getJSON('/api/admin/wordsearch/themes'), getJSON('/api/admin/wordsearch/themes'),
getJSON('/api/admin/bloom/reports').catch(() => null),
]); ]);
feedback = fb; feedback = fb;
candidates = cand; candidates = cand;
wpPool = wp; wpPool = wp;
clientErrors = ce; clientErrors = ce;
wsThemes = ws; wsThemes = ws;
if (bq) { bloomReports = bq.reports || []; bloomOverrides = bq.overrides || []; }
} catch { } catch {
error = "Couldn't load stats."; error = "Couldn't load stats.";
} }
}); });
// --- Publishing Desk: build the X share queue, write blurbs, open in X ---------
let pubItems = $state([]); // active: queued/drafting/opened
let pubArchived = $state([]); // recoverable tray: skipped/snoozed
let pubBuilding = $state(false);
let pubLast = $state(null); // last build summary {added, active, ranked_by}
let pubError = $state(null); // last build error (e.g. non-model failure), surfaced to the user
let pubLoaded = $state(false);
let pubHandleInput = $state({}); // keyed "itemId:entity" → handle being saved
let pubPollTimer = null;
const pubDraftTimers = {};
async function loadPublish() {
try {
const r = await getJSON('/api/admin/publishing/queue?archived=true');
pubBuilding = r.building; pubLast = r.last; pubError = r.error || null;
const items = r.items || [];
pubItems = items.filter((i) => ['queued', 'drafting', 'opened'].includes(i.status));
pubArchived = items.filter((i) => ['skipped', 'snoozed'].includes(i.status));
pubLoaded = true;
} catch { /* leave as-is */ }
}
function pollPublish() {
clearTimeout(pubPollTimer);
pubPollTimer = setTimeout(async () => {
await loadPublish();
if (pubBuilding) pollPublish();
}, 1500);
}
async function buildPublish() {
pubBuilding = true;
try { await postJSON('/api/admin/publishing/build', {}); pollPublish(); }
catch { pubBuilding = false; }
}
function onDraftInput(item) {
clearTimeout(pubDraftTimers[item.id]);
pubDraftTimers[item.id] = setTimeout(() => {
postJSON(`/api/admin/publishing/${item.id}/draft`, { draft_text: item.draft_text || '' }).catch(() => {});
}, 700); // debounced autosave
}
async function pubSetStatus(item, status, extra = {}) {
clearTimeout(pubDraftTimers[item.id]); // cancel a pending autosave so it can't land after the transition
try { await postJSON(`/api/admin/publishing/${item.id}/status`, { status, ...extra }); await loadPublish(); }
catch (e) { item._err = e?.message || 'Action failed.'; }
}
// "Posted" is a confirm step: the user may have edited the text inside X, so we let
// them correct the saved text + paste the real post URL before recording it.
async function pubConfirmPosted(item) {
await pubSetStatus(item, 'posted', {
final_text: item.draft_text || '',
post_url: (item._postUrl || '').trim() || null,
});
}
async function pubRestore(item) {
try { await postJSON(`/api/admin/publishing/${item.id}/restore`, {}); await loadPublish(); } catch { /* ignore */ }
}
function markOpened(item) {
// fire-and-forget so the X tab opens immediately from the click (no awaited call
// before navigation, or the browser may block the popup)
if (item.status !== 'opened') {
postJSON(`/api/admin/publishing/${item.id}/status`, { status: 'opened' }).catch(() => {});
item.status = 'opened';
}
}
async function pubSaveHandle(item, entity) {
const h = (pubHandleInput[`${item.id}:${entity}`] || '').trim();
if (!h) return;
try { await postJSON('/api/admin/publishing/handles', { entity_name: entity, handle: h }); await loadPublish(); }
catch (e) { item._err = e?.message || 'Bad handle.'; }
}
function insertHandle(item, handle) {
item.draft_text = ((item.draft_text || '').trimEnd() + ' ' + handle + ' ').trimStart();
onDraftInput(item);
}
// Emoji picker: a full searchable set (EmojiPicker.svelte) inserted at the caret so it
// drops in mid-sentence cleanly. The textarea also still accepts your OS picker.
let pubEmojiOpen = $state(null); // id of the card whose palette is open
const pubTextareas = {}; // id -> <textarea> element, for caret-aware insert
function regTextarea(node, id) {
pubTextareas[id] = node;
return { destroy() { if (pubTextareas[id] === node) delete pubTextareas[id]; } };
}
function insertEmoji(item, emoji) {
const el = pubTextareas[item.id];
const cur = item.draft_text || '';
if (el && typeof el.selectionStart === 'number') {
const s = el.selectionStart, e = el.selectionEnd;
item.draft_text = cur.slice(0, s) + emoji + cur.slice(e);
const pos = s + emoji.length;
requestAnimationFrame(() => { el.focus(); el.setSelectionRange(pos, pos); }); // caret after the emoji
} else {
item.draft_text = cur + emoji;
}
onDraftInput(item);
}
function snoozeDate(days) {
return new Date(Date.now() + days * 86400000).toISOString().slice(0, 19).replace('T', ' ');
}
function intentURL(item) {
return `https://x.com/intent/tweet?text=${encodeURIComponent(item.draft_text || '')}&url=${encodeURIComponent(item.share_url || '')}`;
}
function findOnX(entity) {
return `https://x.com/search?q=${encodeURIComponent(entity)}&f=user`;
}
// Weighted length ≈ X's counting: any URL = 23. The share link X auto-appends adds
// ~24 (a space + the 23-char t.co link), so the live budget reflects the real tweet.
function weightedLen(t) {
const urls = (t || '').match(/https?:\/\/\S+/g) || [];
return (t || '').replace(/https?:\/\/\S+/g, '').length + urls.length * 23;
}
function pubRemaining(item) {
return 280 - (weightedLen(item.draft_text) + 24);
}
// Named entities (for Find-on-X + save-a-handle); the verified ones already show as
// chips above, so this is mostly the not-yet-known ones.
function namedEntities(item) {
return (item.entities || []).slice(0, 5);
}
$effect(() => { if (section === 'publish' && !pubLoaded) loadPublish(); });
let clientErrors = $state([]); let clientErrors = $state([]);
// "X ago" from a UTC 'YYYY-MM-DD HH:MM:SS' created_at — so a morning glance shows
// whether the newest error is fresh or just last night.
function ago(ts) {
if (!ts) return '—';
const d = new Date(ts.replace(' ', 'T') + 'Z');
const s = Math.max(0, (Date.now() - d.getTime()) / 1000);
if (s < 90) return 'just now';
if (s < 5400) return Math.round(s / 60) + 'm ago';
if (s < 129600) return Math.round(s / 3600) + 'h ago';
return Math.round(s / 86400) + 'd ago';
}
// Classify a beacon by layer so one scary count doesn't conflate a 30s HTML stall
// (incident) with a 5s app boot or a post-deploy chunk miss (benign).
function errType(e) {
if (e.bot) return { k: 'bot', label: 'bot' };
const r = e.reason || '';
if (/preload|dynamically imported|failed to fetch/i.test(r)) return { k: 'preload', label: 'preload' };
const m = r.match(/^boot-slow.*?\bhtml\s+(\d+)ms/i);
if (m) return Number(m[1]) >= 3000 ? { k: 'html', label: 'html-slow' } : { k: 'app', label: 'app-slow' };
if (/^boot-slow/i.test(r)) return { k: 'app', label: 'app-slow' };
return { k: 'runtime', label: 'runtime' };
}
// --- Games: Bloom word curation (reports queue + allow/block overrides) ---
let bloomReports = $state([]);
let bloomOverrides = $state([]);
let bloomOvrWord = $state('');
async function loadBloomQueue() {
try { const r = await getJSON('/api/admin/bloom/reports'); bloomReports = r.reports || []; bloomOverrides = r.overrides || []; }
catch { /* ignore */ }
}
async function resolveBloom(id, action) {
try { await postJSON('/api/admin/bloom/reports/' + id, { action }); await loadBloomQueue(); } catch { /* ignore */ }
}
async function addBloomOverride(action) {
const w = bloomOvrWord.trim().toLowerCase();
if (!w) return;
try { await postJSON('/api/admin/bloom/overrides', { word: w, action }); bloomOvrWord = ''; await loadBloomQueue(); }
catch { /* ignore */ }
}
async function removeBloomOverride(w) {
try { await delJSON('/api/admin/bloom/overrides/' + encodeURIComponent(w)); await loadBloomQueue(); } catch { /* ignore */ }
}
// --- Small Joys: On This Day / Quote / Word pools ---
let joyKind = $state('onthisday');
let joyItems = $state([]);
let joyLoading = $state(false);
let joyAdd = $state({});
let joyAddErr = $state('');
async function loadJoys(k) {
joyLoading = true;
try { joyItems = await getJSON('/api/admin/joys/' + k); } catch { joyItems = []; }
joyLoading = false;
}
async function joyMutate(id, action, fields) {
try { await postJSON(`/api/admin/joys/${joyKind}/items/${id}`, { action, fields }); await loadJoys(joyKind); } catch { /* ignore */ }
}
async function joyAddSubmit() {
joyAddErr = '';
try { await postJSON(`/api/admin/joys/${joyKind}/add`, joyAdd); joyAdd = {}; await loadJoys(joyKind); }
catch (e) { joyAddErr = e?.message || 'Could not add'; }
}
async function joyRepick() {
try { await postJSON(`/api/admin/joys/${joyKind}/repick`, {}); } catch { /* ignore */ }
}
$effect(() => { const k = joyKind; if (section === 'joys') loadJoys(k); });
// --- Games: Daily Word pool --- // --- Games: Daily Word pool ---
let wpWord = $state(''); let wpWord = $state('');
let wpResult = $state(null); // lookup result for the current input let wpResult = $state(null); // lookup result for the current input
@@ -168,6 +375,8 @@
{ key: 'audience', label: 'Audience' }, { key: 'audience', label: 'Audience' },
{ key: 'feedback', label: 'Feedback' }, { key: 'feedback', label: 'Feedback' },
{ key: 'games', label: 'Games' }, { key: 'games', label: 'Games' },
{ key: 'joys', label: 'Small Joys' },
{ key: 'publish', label: 'Publishing' },
]; ];
const VALID_SECTIONS = new Set(TABS.map((t) => t.key)); const VALID_SECTIONS = new Set(TABS.map((t) => t.key));
// Unknown ?section= values fall back to Overview so the page never renders blank. // Unknown ?section= values fall back to Overview so the page never renders blank.
@@ -263,6 +472,55 @@
} }
function dismissCheck(s) { s._check = null; s._checkErr = ''; } function dismissCheck(s) { s._check = null; s._checkErr = ''; }
// --- Source article inspector: the real articles behind the metrics ---
async function toggleArticles(s) {
if (s._showArts) { s._showArts = false; return; }
s._showArts = true;
if (!s._arts) await loadArticles(s, 'all', true);
}
async function loadArticles(s, filter, reset) {
s._artBusy = true; s._artErr = '';
if (reset) { s._artFilter = filter; s._artOffset = 0; s._arts = []; }
try {
const q = `filter=${s._artFilter}&limit=25&offset=${s._artOffset}`;
const r = await getJSON(`/api/admin/sources/${s.id}/articles?${q}`);
s._arts = reset ? r.articles : [...(s._arts || []), ...r.articles];
if (r.summary) s._artSummary = r.summary;
s._artMore = r.has_more;
s._artOffset += r.articles.length;
} catch (e) { s._artErr = e?.message || 'Could not load articles.'; }
finally { s._artBusy = false; }
}
const ART_FILTERS = [['all', 'All'], ['accepted', 'Accepted'], ['rejected', 'Rejected'], ['held', 'Held'], ['no_image', 'No image'], ['duplicates', 'Duplicates']];
function pwBasis(sum) {
if (!sum) return '';
if (sum.paywall_override === 'free') return 'OFF (override)';
if (sum.paywall_override === 'paywalled') return 'ON (override)';
return sum.paywalled ? 'ON (domain)' : 'off';
}
// Per-source paywall override — corrects a domain-rule false positive/negative
// and flows into feed/lead/brief ranking + the table's 🔒. Optimistic in place
// so the inspector panel stays open.
async function setPaywall(s, override) {
const ov = override || null;
try {
await postJSON(`/api/admin/sources/${s.id}/paywall`, { override: ov });
const eff = ov === 'free' ? false : ov === 'paywalled' ? true : (s._artSummary?.paywall_domain ?? s.paywalled);
s.paywall_override = ov; s.paywalled = eff; // updates the Media-column 🔒
if (s._artSummary) { s._artSummary.paywall_override = ov; s._artSummary.paywalled = eff; }
} 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 --- // --- Source candidates: supervised "add a source" pipeline ---
let candidates = $state([]); let candidates = $state([]);
let newFeedUrl = $state(''); let newFeedUrl = $state('');
@@ -270,6 +528,7 @@
let addBusy = $state(false); let addBusy = $state(false);
let addErr = $state(''); let addErr = $state('');
let pendingCandidates = $derived(candidates.filter((c) => c.status !== 'promoted' && c.status !== 'rejected')); let pendingCandidates = $derived(candidates.filter((c) => c.status !== 'promoted' && c.status !== 'rejected'));
let rejectedCandidates = $derived(candidates.filter((c) => c.status === 'rejected'));
async function addCandidate() { async function addCandidate() {
const url = newFeedUrl.trim(); const url = newFeedUrl.trim();
@@ -299,7 +558,7 @@
try { try {
// ~5-7s/item on the LAN model; bound the wait so a model stall can't pin // ~5-7s/item on the LAN model; bound the wait so a model stall can't pin
// the button on "Deep-checking…" forever. // the button on "Deep-checking…" forever.
const res = await postJSON(`/api/admin/candidates/${c.id}/preview?deep=true`, undefined, { timeout: 120000 }); const res = await postJSON(`/api/admin/candidates/${c.id}/preview?deep=true`, undefined, { timeout: 180000 });
Object.assign(c, res, { _deep: false }); Object.assign(c, res, { _deep: false });
} catch (e) { c._err = e?.message || 'Deep preview failed.'; c._deep = false; } } catch (e) { c._err = e?.message || 'Deep preview failed.'; c._deep = false; }
} }
@@ -326,6 +585,19 @@
try { Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/reject`)); } try { Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/reject`)); }
catch { /* leave as-is */ } catch { /* leave as-is */ }
} }
// Codex's operating rule for the Deep-Preview access verdict, shown on hover.
const ACC_HELP = {
fine: 'Sample reads fine — judge mainly on content quality.',
review: 'Click 23 example links before promoting — catches false-positive paywalls and bot-blocks (readable in a browser, blocked to us).',
'reject-ready': 'Domain rule AND the sample agree its walled — usually reject, unless its a source you want and the examples open fine in your browser.',
};
// Send a rejected candidate back to staging for another look (status → suggested).
async function restoreCandidate(c) {
try {
Object.assign(c, await postJSON(`/api/admin/candidates/${c.id}/restore`));
candidates = [...candidates]; // nudge the derived queue/tray to recompute
} catch { /* leave as-is */ }
}
// Feedback inbox: filter + read/unread + delete. // Feedback inbox: filter + read/unread + delete.
let fbCat = $state('all'); // 'all' | 'unread' | a category let fbCat = $state('all'); // 'all' | 'unread' | a category
@@ -429,7 +701,7 @@
<header class="bar"> <header class="bar">
<div class="container inner"> <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> <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> </div>
</header> </header>
@@ -472,33 +744,49 @@
<h2>Pulse</h2> <h2>Pulse</h2>
<div class="cards"> <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.engaged_today}</span><span class="l">Engaged 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.today}</span><span class="l">Visits today (raw)</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.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.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.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">{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">{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> <div class="stat"><span class="n">{stats.accounts.total}</span><span class="l">Accounts</span></div>
{#if stats.client_errors} {#if stats.client_errors}
<div class="stat" class:alert={stats.client_errors.today > 0}> <div class="stat" class:alert={stats.client_errors.unread > 0}>
<span class="n">{stats.client_errors.today}</span><span class="l">Load errors today</span> <span class="n">{stats.client_errors.unread}</span><span class="l">Unread load errors</span>
</div> </div>
{/if} {/if}
</div> </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} {#if clientErrors.length}
<h2>Recent load errors <span class="count">(last {clientErrors.length})</span></h2>
<ul class="cerrs"> <ul class="cerrs">
{#each clientErrors as e (e.created_at + e.reason)} {#each clientErrors as e (e.id)}
<li class:bot={e.bot}> {@const t = errType(e)}
<li class:bot={e.bot} class:isread={e.read}>
<span class="ce-when">{fdate(e.created_at)}</span> <span class="ce-when">{fdate(e.created_at)}</span>
<span class="ce-reason">{e.reason || '—'}{#if e.bot}<span class="ce-bot">bot</span>{/if}</span> <span class="ce-reason"><span class="ce-tag {t.k}">{t.label}</span>{e.reason || '—'}</span>
<span class="ce-path">{e.path || '/'}</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> <span class="ce-ua">{e.user_agent}{#if e.app_version} · build {e.app_version}{/if}</span>
</li> </li>
{/each} {/each}
</ul> </ul>
{:else}
<p class="ce-empty">{ceFilter === 'unread' ? 'All clear — no unread load errors. ✨' : 'Nothing to show.'}</p>
{/if} {/if}
{:else if section === 'content'} {:else if section === 'content'}
@@ -600,9 +888,19 @@
<div class="curl">{c.feed_url}</div> <div class="curl">{c.feed_url}</div>
{#if c.preview} {#if c.preview}
<div class="cprev"> <div class="cprev">
{c.preview.accepted ?? 0}/{c.preview.sampled ?? 0} sampled would pass{#if c.preview.acceptance_rate != null} · {Math.round(c.preview.acceptance_rate * 100)}% accept{/if}{#if c.preview.recent_7d != null} · {c.preview.recent_7d} in last 7d{/if} {c.preview.accepted ?? 0}/{c.preview.sampled ?? 0} sampled would pass{#if c.preview.acceptance_rate != null} · {Math.round(c.preview.acceptance_rate * 100)}% accept{/if}{#if c.preview.non_english} · <span class="held" title="Non-English items are held (English-only feed for now), not counted as rejections">{c.preview.non_english} held · non-English</span>{/if}{#if c.preview.recent_7d != null} · {c.preview.recent_7d} in last 7d{/if}
{#if c.preview.classified}<span class="vbadge model" title="Scored by the real classifier — the true acceptance view">model-checked</span>{:else}<span class="vbadge fast" title="Fast keyword heuristic — an estimate. Run Deep preview for the model's real verdict.">quick estimate</span>{/if} {#if c.preview.classified}<span class="vbadge model" title="Scored by the real classifier — the true acceptance view">model-checked</span>{:else}<span class="vbadge fast" title="Fast keyword heuristic — an estimate. Run Deep preview for the model's real verdict.">quick estimate</span>{/if}
{#if c.preview.paywall_rule}<span class="vbadge wall" title="This domain is on the paywall list (a hint, not a verdict)">paywall domain</span>{/if}
{#if c.preview.examples_accepted?.length}<div class="cex">e.g. {c.preview.examples_accepted.slice(0, 3).join(' · ')}</div>{/if} {#if c.preview.examples_accepted?.length}<div class="cex">e.g. {c.preview.examples_accepted.slice(0, 3).join(' · ')}</div>{/if}
{#if c.preview.access}
<div class="caccess">
<span class="acc-verdict {c.preview.access_verdict}" title={ACC_HELP[c.preview.access_verdict] || ''}>{c.preview.access_verdict}</span>
<span class="acc-counts">Access: {c.preview.access.readable} readable · {c.preview.access.paywalled} paywalled{#if c.preview.access.blocked} · <span title="Couldn't fetch — may be a bot-block (readable in a browser), not a reader paywall">{c.preview.access.blocked} blocked</span>{/if}{#if c.preview.access.unknown} · {c.preview.access.unknown} unknown{/if} <span class="acc-of">({c.preview.access.checked} sampled)</span></span>
{#if c.preview.access.examples?.length}
<div class="acc-ex-row">{#each c.preview.access.examples as ex (ex.url)}<a class="acc-ex {ex.access}" href={ex.url} target="_blank" rel="noopener">{ex.access}</a>{/each}</div>
{/if}
</div>
{/if}
</div> </div>
{/if} {/if}
{#if c._err}<p class="cerr">{c._err}</p>{/if} {#if c._err}<p class="cerr">{c._err}</p>{/if}
@@ -620,6 +918,29 @@
</section> </section>
{/if} {/if}
{#if rejectedCandidates.length}
<section>
<details class="rejtray">
<summary>Rejected candidates <span class="count">({rejectedCandidates.length})</span></summary>
<ul class="candlist rejlist">
{#each rejectedCandidates as c (c.id)}
<li>
<div class="chead">
<span class="cname">{c.name || c.feed_url}</span>
<span class="cstatus">rejected</span>
</div>
<div class="curl">{c.feed_url}</div>
{#if c._err}<p class="cerr">{c._err}</p>{/if}
<div class="cactions">
<button class="csend" onclick={() => restoreCandidate(c)}>Send back to staging</button>
</div>
</li>
{/each}
</ul>
</details>
</section>
{/if}
<h2>Sources <a class="exportlink" href="/api/admin/export/sources.csv" download>export CSV ↓</a></h2> <h2>Sources <a class="exportlink" href="/api/admin/export/sources.csv" download>export CSV ↓</a></h2>
<p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {retired} retired · {sources.length} total</p> <p class="sub2">{healthy} healthy · {resting} resting · {flagged} flagged · {paused} paused · {retired} retired · {sources.length} total</p>
<div class="srctools"> <div class="srctools">
@@ -677,6 +998,7 @@
<button class="act" onclick={() => (s.review_flag ? clearReview(s) : openFlag(s))}>{s.review_flag ? 'Clear' : 'Flag'}</button> <button class="act" onclick={() => (s.review_flag ? clearReview(s) : openFlag(s))}>{s.review_flag ? 'Clear' : 'Flag'}</button>
<button class="act" onclick={() => toggleVisible(s)}>{s.content_visible ? 'Hide' : 'Show'}</button> <button class="act" onclick={() => toggleVisible(s)}>{s.content_visible ? 'Hide' : 'Show'}</button>
<button class="act" title="Read-only spot-check of the live feed" onclick={() => checkSource(s)} disabled={s._checking}>{s._checking ? 'Checking…' : 'Check'}</button> <button class="act" title="Read-only spot-check of the live feed" onclick={() => checkSource(s)} disabled={s._checking}>{s._checking ? 'Checking…' : 'Check'}</button>
<button class="act" title="Inspect this source's real ingested articles" onclick={() => toggleArticles(s)}>{s._showArts ? 'Hide' : 'Articles'}</button>
</td> </td>
</tr> </tr>
{#if s._checking || s._check || s._checkErr} {#if s._checking || s._check || s._checkErr}
@@ -698,6 +1020,65 @@
</td> </td>
</tr> </tr>
{/if} {/if}
{#if s._showArts}
<tr class="artrow">
<td colspan="10">
{#if s._artSummary}
<div class="artsum">
<strong>{s._artSummary.total}</strong> ingested · {s._artSummary.accepted} accepted ·
{s._artSummary.rejected} rejected{#if s._artSummary.non_english} · <span class="held">{s._artSummary.non_english} held · non-English</span>{/if} · {s._artSummary.no_image} no image · {s._artSummary.duplicates} dup
</div>
<div class="pwctl">
<span>Paywall: <span class="pwrule" class:on={s._artSummary.paywalled}>{pwBasis(s._artSummary)}</span></span>
<select class="pwsel" onchange={(e) => setPaywall(s, e.currentTarget.value)}>
<option value="" selected={!s._artSummary.paywall_override}>Use domain rule ({s._artSummary.paywall_domain ? 'on' : 'off'})</option>
<option value="free" selected={s._artSummary.paywall_override === 'free'}>Treat as free</option>
<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)}
<button class="chip sm" class:on={(s._artFilter || 'all') === key} onclick={() => loadArticles(s, key, true)}>{label}</button>
{/each}
<button class="act" onclick={() => (s._showArts = false)}>close</button>
</div>
{#if s._artErr}<p class="cerr">{s._artErr}</p>{/if}
{#if s._arts?.length}
<ul class="artlist">
{#each s._arts as a (a.id)}
<li>
<div class="art-row">
{#if typeof a.url === 'string' && /^https?:\/\//.test(a.url)}
<a class="art-title" href={a.url} target="_blank" rel="noopener">{a.title}</a>
{:else}<span class="art-title plain">{a.title}</span>{/if}
{#if a.accepted === 1}<span class="badge ok">accepted</span>{:else if a.held}<span class="badge held">held · non-English</span>{:else if a.accepted === 0}<span class="badge no">rejected</span>{/if}
{#if a.paywalled}<span class="pw" title="domain paywall rule">🔒</span>{/if}
{#if !a.has_image}<span class="art-flag" title="no image extracted">no img</span>{/if}
{#if a.duplicate}<span class="art-flag" title="marked duplicate">dup</span>{/if}
{#if a.topic}<span class="art-cat">{a.topic}</span>{/if}
<span class="art-when">{a.published_at ? fdate(a.published_at) : ''}</span>
</div>
{#if a.reason}<div class="art-reason" title={a.reason}>{a.reason}</div>{/if}
</li>
{/each}
</ul>
{#if s._artMore}<button class="act more" onclick={() => loadArticles(s, s._artFilter, false)} disabled={s._artBusy}>{s._artBusy ? 'Loading…' : 'Load more'}</button>{/if}
{:else if !s._artBusy}
<p class="muted small">No articles{s._artFilter && s._artFilter !== 'all' ? ' match this filter' : ' yet'}.</p>
{/if}
{#if s._artBusy && !s._arts?.length}<p class="muted small">Loading articles…</p>{/if}
</td>
</tr>
{/if}
{:else} {:else}
<tr><td colspan="10" class="srcempty">{srcSearch.trim() ? `No sources match “${srcSearch.trim()}.` : 'No sources in this view.'}</td></tr> <tr><td colspan="10" class="srcempty">{srcSearch.trim() ? `No sources match “${srcSearch.trim()}.` : 'No sources in this view.'}</td></tr>
{/each} {/each}
@@ -709,7 +1090,16 @@
{:else if section === 'audience'} {: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> <p class="exporthdr"><a class="exportlink" href={'/api/admin/export/audience.csv?days=' + range} download>export audience CSV ({range}d) ↓</a></p>
<section> <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="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.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> <div class="stat"><span class="n">{stats.visitors.d7}</span><span class="l">Last 7 days</span></div>
@@ -743,6 +1133,26 @@
<div class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div> <div class="stat"><span class="n">{stats.funnel.full_story}</span><span class="l">Straight to source</span></div>
</div> </div>
</section> </section>
{#if stats.games}
<section>
<h2>Games funnel <span class="muted small">· the share loop</span></h2>
<div class="cards">
<div class="stat"><span class="n">{stats.games.totals.arrival}</span><span class="l">Share arrivals</span></div>
<div class="stat"><span class="n">{stats.games.totals.started}</span><span class="l">Engaged (first move)</span></div>
<div class="stat"><span class="n">{stats.games.totals.completed}</span><span class="l">Completed</span></div>
<div class="stat"><span class="n">{stats.games.totals.shared}</span><span class="l">Shared</span></div>
</div>
<table class="gfunnel">
<thead><tr><th>Game</th><th>Arrivals</th><th>Engaged</th><th>Completed</th><th>Shared</th></tr></thead>
<tbody>
{#each [['word', 'Daily Word'], ['wordsearch', 'Word Search'], ['bloom', 'Bloom'], ['match', 'Memory Match']] as [k, label] (k)}
<tr><td>{label}</td><td>{stats.games.by_game[k].arrival}</td><td>{stats.games.by_game[k].started}</td><td>{stats.games.by_game[k].completed}</td><td>{stats.games.by_game[k].shared}</td></tr>
{/each}
</tbody>
</table>
<p class="muted small">“Share arrivals” = landings via a shared game link (utm_source=game_share). “Engaged” = first move (guess/find/flip), not just opening the page.</p>
</section>
{/if}
<section> <section>
<h2>Emotional mix &amp; friction</h2> <h2>Emotional mix &amp; friction</h2>
<div class="cards"> <div class="cards">
@@ -867,7 +1277,46 @@
{:else}<p class="muted">No feedback yet.</p>{/if} {:else}<p class="muted">No feedback yet.</p>{/if}
{:else if section === 'games'} {:else if section === 'games'}
<h2>Daily Word pool</h2> <h2>Bloom words <span class="count">({bloomReports.length} to review)</span></h2>
<p class="muted">Acceptance is broad (every valid dictionary word) — these are player
“should this count?” reports. <strong>Approve</strong> allows the word everywhere
(takes effect immediately, no deploy); <strong>Block</strong> hides it; <strong>Dismiss</strong>
clears the report without a rule.</p>
{#if bloomReports.length}
<ul class="bloom-reports">
{#each bloomReports as r (r.id)}
<li>
<span class="br-word">{r.word}</span>
<span class="br-ctx">{r.format || ''}{r.letters ? ' · ' + r.letters.toUpperCase() : ''}{r.puzzle_date ? ' · ' + r.puzzle_date : ''}</span>
<span class="br-acts">
<button class="wp-add" onclick={() => resolveBloom(r.id, 'approve')}>Approve</button>
<button class="act del" onclick={() => resolveBloom(r.id, 'block')}>Block</button>
<button class="link" onclick={() => resolveBloom(r.id, 'dismiss')}>Dismiss</button>
</span>
</li>
{/each}
</ul>
{:else}
<p class="empty">No pending word reports.</p>
{/if}
<div class="wp-lookup">
<input type="text" bind:value={bloomOvrWord} maxlength="24" autocapitalize="off"
autocomplete="off" spellcheck="false" placeholder="Manually allow/block a word…" />
<button class="wp-add" onclick={() => addBloomOverride('allow')}>Allow</button>
<button class="act del" onclick={() => addBloomOverride('block')}>Block</button>
</div>
{#if bloomOverrides.length}
<div class="bloom-ovr">
{#each bloomOverrides as o (o.word)}
<span class="ovr-chip {o.action}">{o.word} · {o.action}
<button class="ovr-x" aria-label="Remove override" onclick={() => removeBloomOverride(o.word)}>×</button>
</span>
{/each}
</div>
{/if}
<h2 style="margin-top:32px">Daily Word pool</h2>
<p class="muted">Look up a word to add or remove it from the answer pool. Only real, 5- or 6-letter <p class="muted">Look up a word to add or remove it from the answer pool. Only real, 5- or 6-letter
words in the guess dictionary qualify, so the daily answer is always solvable. Removals take words in the guess dictionary qualify, so the daily answer is always solvable. Removals take
effect for future puzzles and can be restored any time.</p> effect for future puzzles and can be restored any time.</p>
@@ -991,6 +1440,166 @@
{/each} {/each}
</ul> </ul>
{:else}<p class="muted small">No custom themes yet — the daily rotation uses the built-in ones.</p>{/if} {:else}<p class="muted small">No custom themes yet — the daily rotation uses the built-in ones.</p>{/if}
{:else if section === 'publish'}
<h2>Publishing Desk <span class="count">({pubItems.length})</span></h2>
<p class="sub2">Build a queue of share-worthy stories, write the blurb in your own voice, and open it in X. Posts your <code>/a/</code> share link (your card, drives to UB, still credits the source). Opening X can't confirm a post — you mark Posted.</p>
<div class="pubtools">
<button class="csend" onclick={buildPublish} disabled={pubBuilding}>{pubBuilding ? 'Building…' : 'Build queue'}</button>
{#if pubLast}<span class="muted small">Last build: +{pubLast.added} · {pubLast.active} active · ranked {pubLast.ranked_by}</span>{/if}
</div>
{#if pubError}<p class="cerr">Build error: {pubError}</p>{/if}
{#if !pubItems.length && !pubBuilding}
<p class="muted small">Queue is empty — hit “Build queue” to gather candidates.</p>
{/if}
<ul class="publist">
{#each pubItems as item (item.id)}
{@const remaining = pubRemaining(item)}
<li class="pubcard" class:opened={item.status === 'opened'}>
<div class="pub-head">
{#if item.image_url}<img class="pub-img" src={item.image_url} alt="" loading="lazy" />{/if}
<div class="pub-meta">
<a class="pub-title" href={item.share_url} target="_blank" rel="noopener">{item.title}</a>
<span class="pub-src">{item.source_name}{#if item.social_score != null} · interest {item.social_score}/10{/if}{#if item.status === 'opened'} · <span class="pub-openedtag">opened</span>{/if}</span>
</div>
</div>
{#if item.rationale}<p class="pub-why">{item.rationale}</p>{/if}
{#if item.talking_points?.length}
<ul class="pub-points">{#each item.talking_points as p}<li>{p}</li>{/each}</ul>
{/if}
{#if item.angle}<p class="pub-angle"><span class="hlbl">Angle:</span> {item.angle}</p>{/if}
{#if item.suggested_handles?.length}
<div class="pub-handles">
<span class="hlbl">Tag:</span>
{#each item.suggested_handles as h}<button class="hchip" onclick={() => insertHandle(item, h.handle)} title="Insert into your post">{h.handle}</button>{/each}
</div>
{/if}
<div class="pub-toolbar">
<button type="button" class="emoji-toggle" class:on={pubEmojiOpen === item.id}
onclick={() => (pubEmojiOpen = pubEmojiOpen === item.id ? null : item.id)}
title="Insert emoji">😊 Emoji</button>
{#if pubEmojiOpen === item.id}
<div class="emoji-pop">
<EmojiPicker onpick={(em) => insertEmoji(item, em)} />
</div>
{/if}
</div>
<textarea class="pub-draft" rows="3" placeholder="Write your post — in your voice…"
use:regTextarea={item.id}
bind:value={item.draft_text} oninput={() => onDraftInput(item)}></textarea>
<div class="pub-count" class:over={remaining < 0}>{remaining} left <span class="muted">(+ your /a/ link)</span></div>
{#if namedEntities(item).length}
<details class="pub-find">
<summary>Find / save handles</summary>
{#each namedEntities(item) as ent (ent)}
<div class="pub-ent">
<span class="pub-entname">{ent}</span>
<a class="act mini" href={findOnX(ent)} target="_blank" rel="noopener">Find on X ↗</a>
<input class="hin" placeholder="@handle" bind:value={pubHandleInput[`${item.id}:${ent}`]} />
<button class="act mini" onclick={() => pubSaveHandle(item, ent)}>Save</button>
</div>
{/each}
</details>
{/if}
{#if item._err}<p class="cerr">{item._err}</p>{/if}
{#if item._confirm}
<div class="pub-confirm">
<p class="muted small">If you tweaked the post inside X, edit the text above to match before confirming — it's saved as your final wording.</p>
<input class="hin wide" placeholder="Post URL (optional)" bind:value={item._postUrl} />
<div class="pub-actions">
<button class="csend" onclick={() => pubConfirmPosted(item)}>Confirm posted</button>
<button class="act" onclick={() => (item._confirm = false)}>Cancel</button>
</div>
</div>
{:else}
<div class="pub-actions">
<a class="csend" href={intentURL(item)} target="_blank" rel="noopener" onclick={() => markOpened(item)}>Open in X ↗</a>
<button class="act" onclick={() => (item._confirm = true)}>Posted ✓</button>
<button class="act" onclick={() => pubSetStatus(item, 'snoozed', { snooze_until: snoozeDate(1) })}>Snooze 1d</button>
<button class="act del" onclick={() => pubSetStatus(item, 'skipped')}>Skip</button>
</div>
{/if}
</li>
{/each}
</ul>
{#if pubArchived.length}
<details class="rejtray">
<summary>Archived · skipped &amp; snoozed <span class="count">({pubArchived.length})</span></summary>
<ul class="candlist">
{#each pubArchived as item (item.id)}
<li>
<div class="chead"><span class="cname">{item.title}</span><span class="cstatus">{item.status}{#if item.snooze_until} · until {item.snooze_until.slice(0, 16)}{/if}</span></div>
<div class="curl">{item.source_name}</div>
<div class="cactions"><button class="csend" onclick={() => pubRestore(item)}>Restore to queue</button></div>
</li>
{/each}
</ul>
</details>
{/if}
{:else if section === 'joys'}
<div class="joy-bar">
<div class="joy-kinds">
<button class:on={joyKind === 'onthisday'} onclick={() => (joyKind = 'onthisday')}>On This Day</button>
<button class:on={joyKind === 'quote'} onclick={() => (joyKind = 'quote')}>Quote</button>
<button class:on={joyKind === 'word'} onclick={() => (joyKind = 'word')}>Word</button>
</div>
<button class="joy-repick" onclick={joyRepick}>Re-pick today</button>
</div>
<details class="joy-add">
<summary>Add {joyKind === 'word' ? 'a word' : joyKind === 'quote' ? 'a quote' : 'a fact'}</summary>
<div class="joy-add-fields">
{#if joyKind === 'word'}
<input placeholder="word (looked up in the dictionary)" bind:value={joyAdd.word} />
{:else if joyKind === 'quote'}
<textarea placeholder="quote text" bind:value={joyAdd.text}></textarea>
<input placeholder="author" bind:value={joyAdd.author} />
<input placeholder="work / source (optional)" bind:value={joyAdd.work} />
{:else}
<input placeholder="MM-DD (e.g. 06-22)" bind:value={joyAdd.md} />
<input placeholder="year" type="number" bind:value={joyAdd.year} />
<textarea placeholder="what happened" bind:value={joyAdd.text}></textarea>
<textarea placeholder="summary (optional)" bind:value={joyAdd.summary}></textarea>
{/if}
<button onclick={joyAddSubmit}>Add</button>
{#if joyAddErr}<span class="cerr">{joyAddErr}</span>{/if}
</div>
</details>
{#if joyLoading}<p class="muted">Loading…</p>{/if}
<ul class="joy-list">
{#each joyItems as it (it.id)}
<li class="joy-row" class:blocked={it.blocked}>
<div class="joy-main">
{#if joyKind === 'word'}
<div><strong>{it.word}</strong> <span class="muted small">{it.part_of_speech ?? ''} {it.phonetic ?? ''}</span></div>
<div class="joy-sub">{it.definition}</div>
{:else if joyKind === 'quote'}
<div class="joy-sub">{it.text}</div>
<span class="muted small">{it.author}{#if it.work}, {it.work}{/if}</span>
{:else}
<span class="muted small">{it.md} · {it.year ?? ''}</span>
<div class="joy-sub">{it.text}</div>
{/if}
</div>
<div class="joy-acts">
{#if it.featured}<span class="joy-badge">featured</span>{/if}
{#if it.shown_at}<span class="muted small">shown {it.shown_at}</span>{/if}
<button onclick={() => joyMutate(it.id, it.featured ? 'unfeature' : 'feature')}>{it.featured ? 'Unfeature' : 'Feature'}</button>
<button onclick={() => joyMutate(it.id, it.blocked ? 'unblock' : 'block')}>{it.blocked ? 'Unblock' : 'Block'}</button>
<button class="joy-del" onclick={() => joyMutate(it.id, 'delete')}>Delete</button>
</div>
</li>
{/each}
</ul>
{/if} {/if}
{/if} {/if}
</main> </main>
@@ -1057,6 +1666,12 @@
.stat .n { font-size: 1.7rem; font-weight: 700; font-family: var(--label); color: var(--ink); } .stat .n { font-size: 1.7rem; font-weight: 700; font-family: var(--label); color: var(--ink); }
.stat .l { color: var(--muted); font-size: 0.8rem; } .stat .l { color: var(--muted); font-size: 0.8rem; }
.gfunnel { width: 100%; border-collapse: collapse; margin-top: 12px; font-size: 0.9rem; }
.gfunnel th, .gfunnel td { text-align: right; padding: 6px 10px; border-bottom: 1px solid var(--line); }
.gfunnel th:first-child, .gfunnel td:first-child { text-align: left; }
.gfunnel th { color: var(--muted); font-weight: 600; font-size: 0.78rem; }
.gfunnel td { font-variant-numeric: tabular-nums; }
.two { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; } .two { display: grid; grid-template-columns: 1fr 1fr; gap: 30px; }
@media (max-width: 620px) { .two { grid-template-columns: 1fr; } } @media (max-width: 620px) { .two { grid-template-columns: 1fr; } }
@@ -1125,6 +1740,8 @@
.addrow input { flex: 1 1 200px; box-sizing: border-box; font: inherit; font-size: 0.9rem; padding: 8px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg); color: var(--ink); } .addrow input { flex: 1 1 200px; box-sizing: border-box; font: inherit; font-size: 0.9rem; padding: 8px 11px; border: 1px solid var(--line); border-radius: 9px; background: var(--bg); color: var(--ink); }
.addrow input:focus { outline: none; border-color: var(--accent); } .addrow input:focus { outline: none; border-color: var(--accent); }
ul.candlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } ul.candlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.rejtray > summary { cursor: pointer; font-family: var(--label); font-weight: 600; color: var(--muted); margin-bottom: 10px; }
.rejtray .rejlist { opacity: 0.85; } /* tucked-away, lower-emphasis tray */
ul.candlist li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; } ul.candlist li { background: var(--surface); border: 1px solid var(--line); border-radius: 12px; padding: 12px 14px; }
.chead { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; } .chead { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; }
.chead .cname { font-weight: 600; color: var(--ink); } .chead .cname { font-weight: 600; color: var(--ink); }
@@ -1138,11 +1755,26 @@
.curl { font-size: 0.76rem; color: var(--muted); word-break: break-all; margin-top: 2px; } .curl { font-size: 0.76rem; color: var(--muted); word-break: break-all; margin-top: 2px; }
.cprev { font-size: 0.84rem; color: var(--ink); margin-top: 7px; } .cprev { font-size: 0.84rem; color: var(--ink); margin-top: 7px; }
.cprev .cex { color: var(--muted); font-size: 0.8rem; margin-top: 2px; font-style: italic; } .cprev .cex { color: var(--muted); font-size: 0.8rem; margin-top: 2px; font-style: italic; }
.held { color: #8a5a18; font-weight: 600; } /* language-held (not a rejection) */
/* Heuristic-vs-model preview badge */ /* Heuristic-vs-model preview badge */
.vbadge { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px; .vbadge { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; cursor: help; } font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; cursor: help; }
.vbadge.model { background: #e3efe4; color: #3f7048; } .vbadge.model { background: #e3efe4; color: #3f7048; }
.vbadge.fast { background: var(--line); color: var(--muted); } .vbadge.fast { background: var(--line); color: var(--muted); }
.vbadge.wall { background: #fdf0d8; color: #8a5a18; }
/* Deep-preview accessibility sample */
.caccess { margin-top: 6px; font-size: 0.8rem; color: var(--ink); display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.acc-verdict { padding: 1px 8px; border-radius: 999px; font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; }
.acc-verdict.fine { background: #e3efe4; color: #3f7048; }
.acc-verdict.review { background: #fdf0d8; color: #8a5a18; }
.acc-verdict.reject-ready { background: #fbe3e3; color: #9a3b3b; }
.acc-of { color: var(--muted); }
.acc-ex-row { display: flex; gap: 6px; flex-wrap: wrap; flex-basis: 100%; }
.acc-ex { font-size: 0.7rem; padding: 1px 7px; border-radius: 7px; text-decoration: none; }
.acc-ex.readable { background: #e3efe4; color: #3f7048; }
.acc-ex.paywalled { background: #fbe3e3; color: #9a3b3b; }
.acc-ex.blocked { background: #eceff2; color: #67727d; }
.acc-ex.unknown { background: var(--line); color: var(--muted); }
.cactions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 10px; } .cactions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 10px; }
.cactions .ccat { font: inherit; font-size: 0.8rem; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); width: 150px; } .cactions .ccat { font: inherit; font-size: 0.8rem; padding: 5px 9px; border: 1px solid var(--line); border-radius: 8px; background: var(--bg); color: var(--ink); width: 150px; }
.cactions .cchk { font-size: 0.8rem; color: var(--muted); display: inline-flex; align-items: center; gap: 5px; } .cactions .cchk { font-size: 0.8rem; color: var(--muted); display: inline-flex; align-items: center; gap: 5px; }
@@ -1188,6 +1820,33 @@
.chkex { margin-top: 5px; color: var(--ink); } .chkex { margin-top: 5px; color: var(--ink); }
.chkex .chklbl { color: var(--muted); } .chkex .chklbl { color: var(--muted); }
.chkex.chkrej { color: var(--muted); } .chkex.chkrej { color: var(--muted); }
/* Source article inspector */
.srctable tr.artrow td { background: var(--bg); font-size: 0.84rem; padding: 10px 12px; }
.artsum { color: var(--ink); margin-bottom: 8px; }
.pwrule { color: var(--muted); font-weight: 600; }
.pwrule.on { color: #9a3b3b; }
.pwctl { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 8px; font-size: 0.8rem; color: var(--muted); }
.pwsel { font: inherit; font-size: 0.78rem; padding: 3px 8px; border: 1px solid var(--line); border-radius: 8px; background: var(--surface); color: var(--ink); }
.artfilters { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; margin-bottom: 8px; }
.chip.sm { font-size: 0.74rem; padding: 3px 10px; }
.artlist { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 7px; max-height: 360px; overflow-y: auto; }
.artlist li { border-bottom: 1px solid var(--line); padding-bottom: 6px; }
.art-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.art-title { color: var(--accent-deep); font-weight: 600; text-decoration: none; }
.art-title:hover { text-decoration: underline; }
.art-row .badge { font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em; padding: 1px 7px; border-radius: 999px; }
.badge.ok { background: #e3efe4; color: #3f7048; }
.badge.no { background: #f3e0e0; color: #9a3b3b; }
.badge.held { background: #fdf0d8; color: #8a5a18; } /* language-held, not rejected */
.art-row .pw { font-size: 0.78rem; }
.art-flag { font-size: 0.7rem; color: var(--muted); border: 1px solid var(--line); border-radius: 999px; padding: 0 7px; }
.art-cat { font-size: 0.72rem; color: var(--muted); text-transform: capitalize; }
.art-when { font-size: 0.72rem; color: var(--muted); margin-left: auto; white-space: nowrap; }
.art-title.plain { color: var(--ink); font-weight: 600; }
.art-reason { font-size: 0.76rem; color: var(--muted); font-style: italic; margin-top: 2px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; cursor: help; }
.act.more { margin-top: 8px; }
.srctable .rowactions { white-space: nowrap; } .srctable .rowactions { white-space: nowrap; }
.srctable .rowactions .act { .srctable .rowactions .act {
background: none; border: 1px solid var(--line); color: var(--accent-deep); background: none; border: 1px solid var(--line); color: var(--accent-deep);
@@ -1278,11 +1937,32 @@
.stat.alert { background: #f3e0e0; } .stat.alert { background: #f3e0e0; }
.stat.alert .n { color: #9a3b3b; } .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 { 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; } 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-when { color: var(--muted); white-space: nowrap; }
.ce-reason { font-family: var(--label); color: #9a3b3b; } .ce-reason { font-family: var(--label); color: #9a3b3b; }
/* Layer tag — html-slow is the only incident-grade one, so it reads loudest. */
.ce-tag { display: inline-block; margin-right: 8px; padding: 1px 7px; border-radius: 999px;
font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.04em;
vertical-align: middle; }
.ce-tag.html { background: #fbe3e3; color: #9a3b3b; }
.ce-tag.app { background: #fdf0d8; color: #8a5a18; }
.ce-tag.preload { background: var(--accent-soft); color: var(--accent-deep); }
.ce-tag.runtime { background: #fbe3e3; color: #9a3b3b; }
.ce-tag.bot { background: #eceff2; color: #67727d; }
.cerrs li.bot { opacity: 0.6; } .cerrs li.bot { opacity: 0.6; }
.cerrs li.bot .ce-reason { color: var(--muted); } .cerrs li.bot .ce-reason { color: var(--muted); }
.ce-bot { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px; .ce-bot { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
@@ -1305,6 +1985,20 @@
font: inherit; font-weight: 600; cursor: pointer; } font: inherit; font-weight: 600; cursor: pointer; }
.wp-add:hover { background: var(--accent-deep); } .wp-add:hover { background: var(--accent-deep); }
.wp-msg { color: var(--accent-deep); font-size: 0.9rem; margin: 6px 0 0; } .wp-msg { color: var(--accent-deep); font-size: 0.9rem; margin: 6px 0 0; }
.bloom-reports { list-style: none; padding: 0; margin: 10px 0 18px; display: flex; flex-direction: column; gap: 8px; }
.bloom-reports li { display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
background: var(--surface); border: 1px solid var(--line); border-radius: 10px; padding: 10px 14px; }
.br-word { font-weight: 700; text-transform: capitalize; }
.br-ctx { color: var(--muted); font-size: 0.82rem; }
.br-acts { margin-left: auto; display: flex; align-items: center; gap: 10px; }
.br-acts .wp-add { padding: 5px 13px; font-size: 0.85rem; }
.bloom-ovr { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 8px; }
.ovr-chip { display: inline-flex; align-items: center; gap: 6px; border-radius: 999px; padding: 4px 6px 4px 12px;
font-size: 0.85rem; color: #fff; }
.ovr-chip.allow { background: #2e8b57; }
.ovr-chip.block { background: #b8553f; }
.ovr-x { background: rgba(255,255,255,0.25); border: none; color: #fff; border-radius: 50%; width: 18px; height: 18px;
line-height: 1; cursor: pointer; font-size: 0.9rem; }
.wp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 22px; margin-top: 24px; } .wp-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 22px; margin-top: 24px; }
.wp-col h3 { margin: 0 0 2px; font-size: 1.05rem; } .wp-col h3 { margin: 0 0 2px; font-size: 1.05rem; }
.wp-col .count { font-size: 0.85rem; } .wp-col .count { font-size: 0.85rem; }
@@ -1357,4 +2051,67 @@
border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; } border: 1px solid var(--line); border-radius: 12px; padding: 12px 16px; }
.wt-name { font-weight: 600; } .wt-name { font-weight: 600; }
.wt-count { color: var(--muted); font-size: 0.84rem; margin-right: auto; } .wt-count { color: var(--muted); font-size: 0.84rem; margin-right: auto; }
/* Publishing Desk */
.pubtools { display: flex; align-items: center; gap: 12px; margin: 6px 0 16px; }
.publist { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 14px; }
.pubcard { background: var(--surface); border: 1px solid var(--line); border-radius: 14px; padding: 14px 16px; }
.pubcard.opened { border-color: var(--accent); }
.pub-head { display: flex; gap: 12px; align-items: flex-start; }
.pub-img { width: 88px; height: 64px; object-fit: cover; border-radius: 9px; flex-shrink: 0; }
.pub-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.pub-title { font-weight: 600; color: var(--ink); text-decoration: none; }
.pub-title:hover { text-decoration: underline; }
.pub-src { font-size: 0.8rem; color: var(--muted); }
.pub-openedtag { color: var(--accent-deep); font-weight: 600; }
.pub-why { margin: 10px 0 4px; font-style: italic; color: var(--ink); }
.pub-points { margin: 4px 0; padding-left: 18px; color: var(--ink); font-size: 0.9rem; }
.pub-points li { margin: 1px 0; }
.pub-angle { margin: 4px 0; font-size: 0.88rem; color: var(--muted); }
.hlbl { font-family: var(--label); font-weight: 600; font-size: 0.78rem; color: var(--muted); }
.pub-handles { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 8px 0; }
.hchip { font-size: 0.8rem; padding: 2px 9px; border: 1px solid var(--accent); border-radius: 999px;
background: var(--accent-soft); color: var(--accent-deep); cursor: pointer; }
.pub-draft { width: 100%; box-sizing: border-box; margin-top: 8px; padding: 9px 11px; font: inherit;
border: 1px solid var(--line); border-radius: 10px; background: var(--bg); color: var(--ink); resize: vertical; }
.pub-toolbar { position: relative; margin-top: 8px; }
.emoji-toggle { font: inherit; font-size: 0.8rem; padding: 3px 10px; border: 1px solid var(--line);
border-radius: 999px; background: var(--bg); color: var(--ink); cursor: pointer; }
.emoji-toggle.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-deep); }
.emoji-pop { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; padding: 8px;
border: 1px solid var(--line); border-radius: 10px; background: var(--card, var(--bg));
box-shadow: 0 6px 20px rgba(0,0,0,0.18); }
.pub-count { font-size: 0.78rem; color: var(--muted); text-align: right; margin-top: 2px; font-variant-numeric: tabular-nums; }
.pub-count.over { color: #9a3b3b; font-weight: 700; }
.pub-find { margin: 8px 0; font-size: 0.85rem; }
.pub-find > summary { cursor: pointer; color: var(--accent-deep); }
.pub-ent { display: flex; align-items: center; gap: 8px; margin: 6px 0; flex-wrap: wrap; }
.pub-entname { min-width: 120px; }
.hin { font: inherit; font-size: 0.82rem; padding: 4px 8px; border: 1px solid var(--line); border-radius: 8px;
background: var(--bg); color: var(--ink); width: 130px; }
.pub-actions { display: flex; gap: 9px; align-items: center; flex-wrap: wrap; margin-top: 12px; }
.pub-confirm { margin-top: 10px; padding: 10px 12px; border: 1px solid var(--accent); border-radius: 10px; background: var(--accent-soft); }
.hin.wide { width: 100%; box-sizing: border-box; margin: 4px 0; }
/* Small Joys */
.joy-bar { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; margin-bottom: 14px; }
.joy-kinds { display: flex; gap: 8px; }
.joy-kinds button { padding: 6px 14px; border: 1px solid var(--line); border-radius: 999px; background: #fff; cursor: pointer; font-size: 0.9rem; }
.joy-kinds button.on { background: var(--accent); color: #fff; border-color: var(--accent); }
.joy-repick { padding: 6px 14px; border: 1px solid var(--line); border-radius: 8px; background: #fff; cursor: pointer; font-size: 0.9rem; }
.joy-add { margin-bottom: 14px; }
.joy-add summary { cursor: pointer; font-weight: 600; color: var(--accent); }
.joy-add-fields { display: flex; flex-direction: column; gap: 8px; max-width: 540px; margin-top: 10px; }
.joy-add-fields input, .joy-add-fields textarea { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid var(--line); border-radius: 8px; font: inherit; }
.joy-add-fields textarea { min-height: 60px; resize: vertical; }
.joy-add-fields button { align-self: flex-start; padding: 7px 16px; border: none; border-radius: 8px; background: var(--accent); color: #fff; cursor: pointer; }
.joy-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.joy-row { display: flex; justify-content: space-between; gap: 14px; align-items: flex-start; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: #fff; }
.joy-row.blocked { opacity: 0.5; }
.joy-main { min-width: 0; flex: 1; }
.joy-sub { margin-top: 3px; line-height: 1.4; }
.joy-acts { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; flex-shrink: 0; }
.joy-acts button { padding: 4px 10px; border: 1px solid var(--line); border-radius: 7px; background: #fff; cursor: pointer; font-size: 0.82rem; }
.joy-acts .joy-del { color: #b23b3b; border-color: #e6c4c4; }
.joy-badge { font-size: 0.72rem; font-weight: 700; color: var(--accent); background: var(--accent-soft); border-radius: 999px; padding: 2px 8px; }
</style> </style>
+670
View File
@@ -0,0 +1,670 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import { afterNavigate, goto } from '$app/navigation';
import HubBar from '$lib/components/HubBar.svelte';
import Footer from '$lib/components/Footer.svelte';
// Virtual frames the viewer can switch between — remembered locally, no account needed.
const FRAMES = [
{ id: 'walnut', label: 'Walnut' },
{ id: 'oak', label: 'Oak' },
{ id: 'mahogany', label: 'Mahogany' },
{ id: 'gold', label: 'Gold' },
{ id: 'silver', label: 'Silver' },
{ id: 'black', label: 'Black' },
{ id: 'none', label: 'No frame' },
];
let art = $state(null);
let state = $state('loading'); // loading | ready | empty
let zoom = $state(false);
let frame = $state('walnut');
let thickness = $state(1); // frame-scale multiplier, 0.71.9 (mat caps at 1.5)
let lightboxEl = $state(null);
// Wide paintings fill a portrait phone far better turned sideways: when the art is
// landscape we rotate the fullscreen view 90° (CSS handles WHEN — only narrow+portrait
// screens; desktop and portrait art stay upright). Aspect is read off the loaded image.
let landscape = $state(false);
// Desktop zoom: a gallery inspector inside the lightbox. Variable 1×–4× via a floating
// toolbar ( / slider / + / % / Fit); DRAG the artwork to pan (translate persists), so you
// can place a spot and keep zooming into it — zoom holds the viewport-centred point fixed.
// Mobile keeps native pinch — the Zoom button + toolbar are hidden there.
let zoomed = $state(false);
let zoomLevel = $state(1.5); // scale factor; enter at a gentle 1.5×
let tx = $state(0), ty = $state(0); // pan offset in px (translate)
let dragging = $state(false);
let zoomBoxEl, zoomImgEl; // refs for clamping pan to the image bounds
let lastX = 0, lastY = 0, activePointer = null;
function enterZoom() { zoomLevel = 1.5; tx = 0; ty = 0; dragging = false; activePointer = null; zoomed = true; }
function fit() { dragging = false; activePointer = null; zoomed = false; } // back to the framed gallery view
function clampPan() {
if (!zoomImgEl || !zoomBoxEl) return;
const maxX = Math.max(0, (zoomImgEl.offsetWidth * zoomLevel - zoomBoxEl.clientWidth) / 2);
const maxY = Math.max(0, (zoomImgEl.offsetHeight * zoomLevel - zoomBoxEl.clientHeight) / 2);
tx = Math.max(-maxX, Math.min(maxX, tx));
ty = Math.max(-maxY, Math.min(maxY, ty));
}
function setZoom(v) {
const z = Math.max(1, Math.min(4, Math.round(v * 10) / 10));
if (z === zoomLevel) return;
const ratio = z / zoomLevel; // keep the centred spot fixed as we zoom
tx *= ratio; ty *= ratio; zoomLevel = z;
clampPan();
}
function dragStart(e) {
if (dragging || e.button !== 0) return; // primary button only; ignore a 2nd pointer mid-drag
dragging = true; lastX = e.clientX; lastY = e.clientY; activePointer = e.pointerId;
e.currentTarget.setPointerCapture?.(e.pointerId);
}
function dragMove(e) {
if (!dragging || e.pointerId !== activePointer) return; // only the pointer that started the drag
tx += e.clientX - lastX; ty += e.clientY - lastY;
lastX = e.clientX; lastY = e.clientY;
clampPan();
}
// Ends drag on pointerup / pointercancel / lostpointercapture (NOT pointerleave, which
// fights the capture). Release only what we actually hold, so no double-release throws.
function dragEnd(e) {
dragging = false;
const el = e.currentTarget;
if (activePointer !== null && el?.hasPointerCapture?.(activePointer)) el.releasePointerCapture(activePointer);
activePointer = null;
}
function onKey(e) {
if (e.key !== 'Escape') return;
if (zoomed) { zoomed = false; dragging = false; activePointer = null; } // Escape steps out of inspection first, then closes
else if (zoom) zoom = false;
}
// Leaving the lightbox always resets the inspector, so re-opening starts framed.
$effect(() => { if (!zoom) { zoomed = false; dragging = false; activePointer = null; zoomLevel = 1.5; tx = 0; ty = 0; } });
// Move focus to the lightbox when it opens, so Escape/Enter work and focus is trapped sanely.
$effect(() => { if (zoom && lightboxEl) lightboxEl.focus(); });
// Same single-history Back as the hub pages: return to where you came from (the hub),
// or fall back to it on a cold deep-link without pushing a new entry.
let cameFromApp = $state(false);
afterNavigate(({ from }) => { if (from) cameFromApp = true; });
function goBack() {
if (cameFromApp && typeof history !== 'undefined') history.back();
else goto('/', { replaceState: true });
}
// Woods are built from four real mitered rails (grain turns at the corners); metals/none aren't.
let isWood = $derived(['walnut', 'oak', 'mahogany'].includes(frame));
onMount(async () => {
try {
const saved = localStorage.getItem('ub_art_frame');
if (saved && FRAMES.some((f) => f.id === saved)) frame = saved;
const t = parseFloat(localStorage.getItem('ub_art_thickness'));
if (t >= 0.7 && t <= 1.9) thickness = t;
} catch { /* private mode — defaults are fine */ }
try {
art = await getJSON('/api/art/today');
state = art?.image_url ? 'ready' : 'empty';
} catch {
state = 'empty';
}
});
function setFrame(id) {
frame = id;
try { localStorage.setItem('ub_art_frame', id); } catch { /* ignore */ }
}
function saveThickness() {
try { localStorage.setItem('ub_art_thickness', String(thickness)); } catch { /* ignore */ }
}
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
let dateLabel = $derived.by(() => {
if (!art?.date) return '';
const [, m, d] = art.date.split('-').map(Number);
return MONTHS[m - 1] ? `${MONTHS[m - 1]} ${d}` : '';
});
// "Winslow Homer, 1865 — oil on canvas"
let attribution = $derived.by(() => {
if (!art) return '';
const who = [art.artist, art.date_text].filter(Boolean).join(', ');
return art.medium ? (who ? `${who} — ${art.medium}` : art.medium) : who;
});
let dlName = $derived(art ? `${(art.title || 'artwork').replace(/[^\w \-]+/g, '').trim()}.jpg` : 'artwork.jpg');
let detailsOpen = $state(true); // mobile: collapse the writeup to bring the controls up near the art
let copied = $state(false);
async function share() {
const url = art?.source_url || location.href;
try {
if (navigator.share) { await navigator.share({ title: art?.title || 'Daily Art', url }); return; }
await navigator.clipboard.writeText(url);
copied = true; setTimeout(() => (copied = false), 1800);
} catch { /* cancelled / unsupported — no-op */ }
}
</script>
<svelte:window onkeydown={onKey} />
<svelte:head>
<title>Daily Art · upbeatBytes</title>
<meta name="description" content="A masterwork a day from the world's open museum collections — one piece, beautifully framed, on upbeatBytes." />
</svelte:head>
{#snippet woodRails()}
<span class="rail rail--t"></span>
<span class="rail rail--r"></span>
<span class="rail rail--b"></span>
<span class="rail rail--l"></span>
{/snippet}
<div class="room">
<HubBar active="art" />
<main class="gallery">
<button class="back" onclick={goBack} aria-label="Go back">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M15 18l-6-6 6-6" />
</svg>
Back
</button>
{#if state === 'ready'}
<!-- "The Story": the guide write-up on the left, the framed piece + controls on the right -->
<article class="story-card">
<div class="left">
<div class="head">
<div class="kicker"><span class="kicker-rule"></span>Daily Art{#if dateLabel} · {dateLabel}{/if}</div>
<h1 class="art-title">{art.title}</h1>
{#if attribution}<p class="attribution">{attribution}</p>{/if}
</div>
<div class="body">
<button class="details-toggle" onclick={() => (detailsOpen = !detailsOpen)}
aria-expanded={detailsOpen} aria-controls="art-details">
About this piece
<svg class="chev" class:open={detailsOpen} viewBox="0 0 24 24" width="14" height="14"
fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 9l6 6 6-6" />
</svg>
</button>
<div class="body-content" id="art-details" class:collapsed={!detailsOpen}>
{#if art.blurb}<p class="blurb">{art.blurb}</p>{/if}
<div class="meta-cols">
<div class="meta"><span class="meta-label">Collection</span><span class="meta-val">{art.museum}</span></div>
{#if art.license}<div class="meta"><span class="meta-label">Rights</span><span class="meta-val">{art.license}</span></div>{/if}
</div>
{#if art.source_url}
<a class="cta" href={art.source_url} target="_blank" rel="noopener">View at {art.museum}</a>
{/if}
</div>
</div>
</div>
<div class="right">
<div class="art-stage">
<button class="frame frame--{frame} art-frame" style="--frame-scale:{thickness}"
onclick={() => (zoom = true)} aria-label="Expand artwork">
{#if isWood}{@render woodRails()}{/if}
<span class="mat">
<img src={art.image_url} alt={art.title}
onload={(e) => (landscape = e.currentTarget.naturalWidth > e.currentTarget.naturalHeight)} />
<span class="hint">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5" />
</svg>
Expand
</span>
</span>
</button>
</div>
<div class="controls">
<div class="ctl-row">
<span class="ctl-label">Frame</span>
<div class="swatches">
{#each FRAMES as f}
<button class="swatch swatch--{f.id}" class:on={frame === f.id}
onclick={() => setFrame(f.id)} aria-pressed={frame === f.id} title={f.label}>
<span class="sr">{f.label}</span>
</button>
{/each}
</div>
</div>
{#if frame !== 'none'}
<div class="ctl-row">
<span class="ctl-label">Thickness</span>
<input type="range" min="0.7" max="1.9" step="0.05"
bind:value={thickness} oninput={saveThickness} aria-label="Frame thickness" />
</div>
{/if}
{#if art.palette?.length}
<div class="ctl-divider"></div>
<div class="ctl-label">Colors in this piece</div>
<div class="palette" aria-hidden="true">
{#each art.palette as c}<span class="chip" style="background:{c}"></span>{/each}
</div>
{/if}
<div class="actions">
<button class="act act-share" onclick={share}>{copied ? 'Link copied' : '↗ Share'}</button>
{#if art.is_public_domain}
<a class="act act-dl" href={art.image_url_large || art.image_url} download={dlName}>↓ Download</a>
{/if}
</div>
</div>
</div>
</article>
{:else if state === 'empty'}
<p class="note">The gallery's resting — a new piece is hung each morning. Check back soon.</p>
{:else}
<p class="note">Hanging today's piece…</p>
{/if}
</main>
<Footer />
</div>
{#if zoom && art}
<div class="lightbox" class:rotate={landscape && !zoomed} class:zoomed bind:this={lightboxEl}
tabindex="-1" role="dialog" aria-modal="true" aria-label="{art.title}, full screen">
<button class="lb-backdrop" onclick={() => (zoom = false)} aria-label="Close artwork"></button>
<button class="lb-close" onclick={() => (zoom = false)} aria-label="Close">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
{#if zoomed}
<!-- gallery inspector: DRAG to pan; zoom with the toolbar; Fit/Esc to exit -->
<div class="lb-zoom" class:dragging bind:this={zoomBoxEl}
onpointerdown={dragStart} onpointermove={dragMove} onpointerup={dragEnd}
onpointercancel={dragEnd} onlostpointercapture={dragEnd}
role="img" aria-label="{art.title}, magnified — drag to pan">
<img bind:this={zoomImgEl} src={art.image_url_large || art.image_url} alt={art.title}
style="transform:translate({tx}px,{ty}px) scale({zoomLevel})" draggable="false" />
</div>
<div class="lb-tools" role="group" aria-label="Zoom controls">
<button class="lb-t" onclick={() => setZoom(zoomLevel - 0.1)} aria-label="Zoom out"></button>
<input class="lb-slider" type="range" min="1" max="4" step="0.1" value={zoomLevel}
oninput={(e) => setZoom(+e.currentTarget.value)}
aria-label="Zoom level" aria-valuetext="{Math.round(zoomLevel * 100)} percent" />
<button class="lb-t" onclick={() => setZoom(zoomLevel + 0.1)} aria-label="Zoom in">+</button>
<span class="lb-pct">{Math.round(zoomLevel * 100)}%</span>
<button class="lb-fit" onclick={fit}>Fit</button>
</div>
{:else}
<span class="lb-stage">
<span class="frame frame--{frame} lb-frame" style="--frame-scale:{thickness}">
{#if isWood}{@render woodRails()}{/if}
<span class="mat"><img src={art.image_url_large || art.image_url} alt={art.title} /></span>
</span>
<span class="lb-cap">{art.title}{#if art.artist}<span class="sep">·</span>{art.artist}{/if}</span>
<button class="lb-zoombtn" onclick={enterZoom}>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="11" cy="11" r="7" /><path d="M21 21l-3.5-3.5M11 8v6M8 11h6" /></svg>
Zoom in
</button>
</span>
{/if}
</div>
{/if}
<style>
@font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 700; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
@font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-italic-var.woff2') format('woff2'); font-weight: 400 500; font-style: italic; font-display: swap; }
@font-face { font-family: 'Space Mono'; src: url('/fonts/space-mono-latin.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
/* --- "The Story": editorial Daily Art page. Daily-Art identity = the purple accent. --- */
.room {
--canvas: #faf6ee; /* warm room ground */
--card: #ece1cc; /* warm-tan story card */
--art-band: #ddcfb2; /* deeper tan behind the framed piece */
--ink: #232a31; /* dark slate — titles */
--story: #4f4a3f; /* warm body text */
--muted: #8a8273;
--label: #4a4334; /* Space Mono micro-labels — darker so they stand out */
--line: #ece5d8;
--accent: #8857C2; /* Daily-Art purple (matches the home tile) */
--accent-deep: #6f42a8;
min-height: 100vh;
background: var(--canvas);
color: var(--ink);
font-family: 'Hanken Grotesk', ui-sans-serif, system-ui, -apple-system, sans-serif;
display: flex;
flex-direction: column;
overflow-x: clip; /* seatbelt: nothing can scroll the page sideways */
}
/* /art runs wider than the hub's 1180 column — the artwork is the point, and the
mobile view is separate, so the desktop card gets room for a properly-sized piece. */
.gallery {
flex: 1; width: 100%; max-width: 1280px; margin: 0 auto;
padding: clamp(10px, 2vw, 22px) clamp(18px, 5vw, 44px) clamp(28px, 5vw, 48px);
box-sizing: border-box;
display: flex; flex-direction: column; justify-content: center;
}
/* top-left Back */
.back {
align-self: flex-start; display: inline-flex; align-items: center; gap: 6px;
margin: 0 0 clamp(10px, 2vw, 16px); padding: 6px 10px 6px 0;
background: none; border: none; cursor: pointer; font: inherit; font-size: 14px;
font-weight: 600; color: var(--muted); transition: color 0.15s ease;
-webkit-tap-highlight-color: transparent;
}
.back:hover { color: var(--accent); }
.back svg { transition: transform 0.15s ease; }
.back:hover svg { transform: translateX(-2px); }
/* The Story card: writeup (left) + framed art & controls (right). On phones the wrappers
collapse (display:contents) so the pieces reflow to head → art → writeup → controls. */
.story-card {
display: flex; border-radius: 22px; overflow: hidden; background: var(--card);
box-shadow: 0 16px 40px -22px rgba(120, 95, 50, 0.32);
}
.left { flex: 1; min-width: 0; padding: clamp(28px, 4vw, 50px) clamp(24px, 3.4vw, 46px); display: flex; flex-direction: column; justify-content: center; }
.head, .body, .body-content { display: flex; flex-direction: column; } /* so .cta align-self works */
.details-toggle { display: none; } /* desktop: the writeup is always shown in the left column */
.chev { transition: transform 0.2s ease; }
.kicker { display: inline-flex; align-items: center; gap: 9px; font-family: 'Space Mono', monospace; font-size: 12px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--accent); }
.kicker-rule { width: 18px; height: 2px; background: var(--accent); border-radius: 2px; }
.art-title { font-family: 'Newsreader', Georgia, serif; font-weight: 500; font-size: clamp(2rem, 4.2vw, 2.9rem); line-height: 1.05; letter-spacing: -0.01em; color: var(--ink); margin: 14px 0 0; }
.attribution { font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: clamp(1rem, 1.6vw, 1.13rem); color: #7a7263; margin: 12px 0 0; }
.blurb { font-size: clamp(0.97rem, 1.25vw, 1.02rem); line-height: 1.72; color: var(--story); margin: 20px 0 0; max-width: 44ch; text-wrap: pretty; }
.meta-cols { display: flex; gap: 36px; margin-top: 26px; padding-top: 22px; border-top: 1px solid rgba(120, 95, 50, 0.20); }
.meta { display: flex; flex-direction: column; gap: 4px; }
.meta-label { font-family: 'Space Mono', monospace; font-size: 10.5px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--label); }
.meta-val { font-size: 14px; font-weight: 600; color: #3f3a30; }
.cta { align-self: flex-start; margin-top: 26px; background: var(--accent); color: #fff; font-size: 14px; font-weight: 700; padding: 13px 22px; border-radius: 9px; text-decoration: none; transition: background 0.15s ease; -webkit-tap-highlight-color: transparent; }
.cta:hover { background: var(--accent-deep); }
/* right column: the framed piece on a deeper-tan ground, controls beneath */
.right { flex: 0 0 50%; background: var(--art-band); display: flex; flex-direction: column; align-items: center; justify-content: center; padding: clamp(28px, 3vw, 42px) clamp(20px, 2.5vw, 34px); }
.art-stage { display: flex; justify-content: center; width: 100%; }
.art-frame { max-width: 100%; }
.controls { width: 100%; max-width: 440px; margin-top: clamp(24px, 3vw, 32px); }
.ctl-row { display: flex; align-items: center; gap: 12px; margin-top: 16px; }
.ctl-row:first-child { margin-top: 0; }
.ctl-label { font-family: 'Space Mono', monospace; font-size: 10.5px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--label); }
.ctl-row > .ctl-label { width: 78px; flex: none; }
.swatches { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
.ctl-row input[type="range"] { flex: 1; accent-color: var(--accent); cursor: pointer; }
.ctl-divider { height: 1px; background: rgba(120, 95, 50, 0.18); margin: 24px 0 18px; }
.palette { display: flex; gap: 9px; margin-top: 11px; }
.chip { flex: 1; max-width: 46px; height: 34px; border-radius: 8px; }
.actions { display: flex; gap: 11px; margin-top: 24px; }
.act { flex: 1; text-align: center; font-size: 13.5px; font-weight: 700; padding: 12px 0; border-radius: 9px; cursor: pointer; text-decoration: none; -webkit-tap-highlight-color: transparent; }
.act-share { background: var(--accent); color: #fff; border: none; font-family: inherit; transition: background 0.15s ease; }
.act-share:hover { background: var(--accent-deep); }
.act-dl { border: 1.5px solid #c7b48f; color: var(--story); background: transparent; display: inline-flex; align-items: center; justify-content: center; transition: border-color 0.15s ease, color 0.15s ease; }
.act-dl:hover { border-color: var(--accent); color: var(--accent); }
/* Phones: collapse the wrappers so the four blocks reflow into one calm column —
head → artwork → writeup → controls (the artwork sits high, seen before it's read). */
@media (max-width: 760px) {
.gallery { justify-content: flex-start; }
.story-card { flex-direction: column; }
.left, .right { display: contents; } /* promote head/body/art-stage/controls */
.head { order: 1; padding: clamp(22px, 5vw, 28px) clamp(18px, 5vw, 22px) 14px; }
.art-stage { order: 2; background: var(--art-band); padding: 22px 18px; box-sizing: border-box; }
.body { order: 3; padding: 6px clamp(18px, 5vw, 22px) 4px; }
/* writeup collapses (tap "About this piece") so the controls rise up beside the art */
.details-toggle {
display: flex; align-items: center; justify-content: space-between; gap: 10px; width: 100%;
background: none; border: none; cursor: pointer; padding: 14px 0;
font-family: 'Space Mono', monospace; font-size: 11px; letter-spacing: 0.14em;
text-transform: uppercase; color: var(--label); -webkit-tap-highlight-color: transparent;
border-top: 1px solid rgba(120, 95, 50, 0.16);
}
.chev.open { transform: rotate(180deg); }
.body-content.collapsed { display: none; }
.controls { order: 4; width: auto; max-width: none; box-sizing: border-box;
margin: 16px clamp(14px, 4vw, 18px) clamp(20px, 5vw, 24px);
background: #fff; border: 1px solid #ece3d0; border-radius: 16px; padding: 18px 16px; }
.meta-cols { gap: 28px; }
.cta { align-self: stretch; text-align: center; }
}
/* The frame: a beveled moulding (wood/metal) around a cream mat around the art.
--rail / --mat are the moulding and mat widths; both scale with --frame-scale (the
thickness slider). EVERY variant — even "No frame" — reserves the same footprint, so
switching frames never reflows the page. */
.frame {
box-sizing: border-box;
/* Rail scales across the whole slider; the mat caps at scale 1.5 so the top of the
slider thickens only the moulding (wood), holding the white border steady. */
--rail: calc(clamp(11px, 2.3vw, 22px) * var(--frame-scale, 1));
--mat: calc(clamp(10px, 2.4vw, 22px) * min(var(--frame-scale, 1), 1.5));
border: none; cursor: zoom-in; background: none;
position: relative; line-height: 0; display: inline-block; max-width: 100%;
border-radius: 4px; padding: var(--rail);
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.20), 0 3px 10px rgba(20, 30, 45, 0.10);
}
/* The three woods share one real (CC0, Poly Haven) fine-grain texture, recolored per
species with a CSS filter, with a soft bevel baked into the texture layer. Metals
stay gradient. The texture sits on ::before (behind the mat); miters on ::after. */
.frame--walnut, .frame--oak, .frame--mahogany {
background: linear-gradient(135deg, #5a3a24, #34210f); /* fallback if texture misses */
}
/* Four real mitered rails: the grain TURNS at every corner — horizontal moulding on the
top/bottom rails (rotated texture), vertical on the sides — like four cut lengths of
wood. Each rail is a trapezoid clipped to 45° at the joints; --rail drives size + cut.
A directional bevel per rail (lit top-left) gives the moulding a rounded profile. */
.rail {
position: absolute; z-index: 0; pointer-events: none;
background-position: center; background-size: cover; background-repeat: no-repeat;
}
.rail--t { top: 0; left: 0; right: 0; height: var(--rail);
clip-path: polygon(0 0, 100% 0, calc(100% - var(--rail)) 100%, var(--rail) 100%);
background-image: linear-gradient(to bottom, rgba(255,255,255,0.22), rgba(0,0,0,0.22)), url("/textures/wood-grain-h.jpg"); }
.rail--b { bottom: 0; left: 0; right: 0; height: var(--rail);
clip-path: polygon(var(--rail) 0, calc(100% - var(--rail)) 0, 100% 100%, 0 100%);
background-image: linear-gradient(to top, rgba(255,255,255,0.05), rgba(0,0,0,0.36)), url("/textures/wood-grain-h.jpg"); }
.rail--l { top: 0; bottom: 0; left: 0; width: var(--rail);
clip-path: polygon(0 0, 100% var(--rail), 100% calc(100% - var(--rail)), 0 100%);
background-image: linear-gradient(to right, rgba(255,255,255,0.20), rgba(0,0,0,0.22)), url("/textures/wood-grain.jpg"); }
.rail--r { top: 0; bottom: 0; right: 0; width: var(--rail);
clip-path: polygon(0 var(--rail), 100% 0, 100% 100%, 0 calc(100% - var(--rail)));
background-image: linear-gradient(to left, rgba(255,255,255,0.05), rgba(0,0,0,0.36)), url("/textures/wood-grain.jpg"); }
/* per-species recolor, now applied to the rails */
.frame--walnut .rail { filter: brightness(1.08) saturate(1.05); }
.frame--oak .rail { filter: brightness(1.72) contrast(0.92) saturate(1.12) sepia(0.22) hue-rotate(-6deg); }
.frame--mahogany .rail { filter: brightness(0.98) contrast(1.06) saturate(1.55) sepia(0.4) hue-rotate(-16deg); }
/* Metals: fine brushed striations over an anisotropic sheen. */
.frame--gold {
background:
repeating-linear-gradient(180deg, rgba(255,255,255,0.08) 0 1px, rgba(120,80,10,0.07) 1px 2px),
linear-gradient(135deg, #b88c3d, #ecd293 42%, #a9772f 60%, #dcbd71);
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.20),
inset 0 0 0 1px rgba(255, 255, 255, 0.30),
inset 0 2px 3px rgba(255, 249, 226, 0.60),
inset 0 -3px 9px rgba(92, 62, 12, 0.48);
}
.frame--silver {
background:
repeating-linear-gradient(180deg, rgba(255,255,255,0.11) 0 1px, rgba(40,50,60,0.07) 1px 2px),
linear-gradient(135deg, #a9b0ba, #edf0f3 45%, #98a0ab 62%, #d2d7de);
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.18),
inset 0 0 0 1px rgba(255, 255, 255, 0.50),
inset 0 2px 3px rgba(255, 255, 255, 0.75),
inset 0 -3px 9px rgba(60, 70, 85, 0.42);
}
.frame--black {
background: linear-gradient(135deg, #2a2c30, #15171a 45%, #25282c 60%, #101113);
box-shadow: 0 24px 58px rgba(20, 30, 45, 0.30),
inset 0 0 0 1px rgba(255, 255, 255, 0.08),
inset 0 2px 3px rgba(255, 255, 255, 0.10),
inset 0 -3px 9px rgba(0, 0, 0, 0.60);
}
/* Mitered (45°) corner joints for the woods — each seam runs from the outer corner to
the mat, sized to the rail width so it tracks the thickness slider. */
.frame--walnut::after, .frame--oak::after, .frame--mahogany::after {
content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none; border-radius: inherit;
background:
linear-gradient(45deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) top left / var(--rail) var(--rail) no-repeat,
linear-gradient(135deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) top right / var(--rail) var(--rail) no-repeat,
linear-gradient(135deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) bottom left / var(--rail) var(--rail) no-repeat,
linear-gradient(45deg, transparent calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% - 0.9px), rgba(28, 16, 6, 0.7) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 0.1px), rgba(255, 230, 190, 0.34) calc(50% + 1px), transparent calc(50% + 1px)) bottom right / var(--rail) var(--rail) no-repeat;
}
.mat {
display: block; position: relative; z-index: 1; background: #fbf8f1; border-radius: 1px;
padding: var(--mat);
/* The mat sits recessed below the moulding: a crisp rabbet groove at the lip, the
frame casting shadow onto the top/left of the mat, the bottom/right catching light
— a small chiseled step instead of a flat transition. */
box-shadow:
inset 0 0 0 1px rgba(40, 28, 16, 0.18),
inset 0 6px 9px -3px rgba(0, 0, 0, 0.34),
inset 6px 0 9px -4px rgba(0, 0, 0, 0.24),
inset 0 -3px 6px -2px rgba(255, 255, 255, 0.55),
inset -3px 0 6px -2px rgba(255, 255, 255, 0.40);
}
.frame img { display: block; width: 100%; height: auto; max-height: 72vh; object-fit: contain; border-radius: 1px; }
/* No frame: keep the footprint (so switching never shifts the layout), drop the
moulding + mat, and let the bare art float with a soft drop shadow. */
.frame--none { background: transparent; box-shadow: none; }
.frame--none .mat { background: transparent; box-shadow: none; }
.frame--none img {
border-radius: 6px;
box-shadow: 0 18px 44px rgba(20, 30, 45, 0.20), 0 4px 12px rgba(20, 30, 45, 0.10);
}
/* A quiet, always-there affordance — no hover wobble. */
.hint {
position: absolute; right: 12px; bottom: 12px;
display: inline-flex; align-items: center; gap: 6px;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 0.76rem; font-weight: 600; line-height: 1; color: #fff;
background: rgba(20, 26, 33, 0.50); border-radius: 999px; padding: 7px 11px;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
pointer-events: none; transition: background 0.2s ease;
}
.frame:hover .hint, .frame:focus-visible .hint { background: rgba(20, 26, 33, 0.72); }
.sep { display: inline-block; margin: 0 0.5em; color: var(--muted); } /* used in the lightbox caption */
/* Frame chips read as little beveled beads; selection is a clean offset ring. */
.swatch {
width: 30px; height: 30px; border-radius: 50%; border: none; cursor: pointer; padding: 0;
box-shadow: inset 0 2px 3px rgba(255, 255, 255, 0.38),
inset 0 -3px 5px rgba(0, 0, 0, 0.30), 0 1px 3px rgba(0, 0, 0, 0.22);
outline: 2.5px solid transparent; outline-offset: 2px;
transition: outline-color 0.15s ease, transform 0.15s ease;
}
.swatch:hover { transform: translateY(-1px); }
.swatch.on { outline-color: var(--accent); }
.swatch--walnut { background: linear-gradient(150deg, #6b4528, #8a5d31 55%, #4f3420); }
.swatch--oak { background: linear-gradient(150deg, #b07f46, #cb9c5b 55%, #8c5d2f); }
.swatch--mahogany { background: linear-gradient(150deg, #5a241a, #7e3826 55%, #3a160e); }
.swatch--gold { background: linear-gradient(150deg, #c79a45, #ecd293 55%, #a9772f); }
.swatch--silver { background: linear-gradient(150deg, #aab1bb, #edf0f3 55%, #98a0ab); }
.swatch--black { background: linear-gradient(150deg, #34373c, #16181b 55%, #0c0d0f); }
.swatch--none { background: linear-gradient(150deg, #ffffff, #ece6da); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.note { color: var(--muted); font-size: 1.05rem; margin-top: 40px; }
.lightbox {
position: fixed; inset: 0; z-index: 50;
/* A soft, top-lit gallery wall — lighter than the page so every frame (Black
included) reads, like a piece hung on a real wall. */
background: linear-gradient(180deg, #efe9dd, #e2dbcc);
display: flex; flex-direction: column;
align-items: center; justify-content: center; padding: 2.5vmin;
}
/* full-bleed click target behind the content — clicking the wall closes */
.lb-backdrop { position: absolute; inset: 0; z-index: 0; border: none; background: none; padding: 0; cursor: zoom-out; }
.lb-close {
position: absolute; top: clamp(10px, 2vw, 20px); right: clamp(10px, 2vw, 20px); z-index: 3;
width: 40px; height: 40px; border-radius: 999px; border: 1px solid rgba(40, 30, 20, 0.12);
background: rgba(255, 255, 255, 0.72); color: #3a3a3a; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.lb-close:hover { background: #fff; }
/* frame + caption travel together so a rotated view turns as one piece */
.lb-stage { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; gap: 12px; }
/* magnified inspection view (desktop): cursor pans via transform-origin */
.lb-zoom {
position: relative; z-index: 1; width: 96vw; height: 88vh; overflow: hidden;
cursor: grab; display: flex; align-items: center; justify-content: center; touch-action: none;
}
.lb-zoom.dragging { cursor: grabbing; }
.lb-zoom img {
max-width: 100%; max-height: 100%; width: auto; height: auto;
will-change: transform; user-select: none; -webkit-user-drag: none;
}
/* floating gallery-inspector toolbar — a quiet translucent dark pill, white controls */
.lb-tools {
position: absolute; bottom: clamp(14px, 3vh, 28px); left: 50%; transform: translateX(-50%);
z-index: 3; display: flex; align-items: center; gap: 12px;
background: rgba(20, 18, 16, 0.72); -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
border-radius: 999px; padding: 8px 14px; color: #fff; box-shadow: 0 8px 24px -12px rgba(0, 0, 0, 0.5);
}
.lb-t {
width: 26px; height: 26px; flex: none; border-radius: 999px; border: 1px solid rgba(255, 255, 255, 0.28);
background: none; color: #fff; cursor: pointer; font-size: 1.05rem; line-height: 1;
display: flex; align-items: center; justify-content: center;
}
.lb-t:hover { background: rgba(255, 255, 255, 0.18); }
.lb-slider { width: clamp(120px, 22vw, 220px); accent-color: #fff; cursor: pointer; }
.lb-pct { font-size: 0.8rem; font-variant-numeric: tabular-nums; min-width: 3.2em; text-align: center; opacity: 0.92; }
.lb-fit {
border: 1px solid rgba(255, 255, 255, 0.32); background: none; color: #fff; cursor: pointer;
border-radius: 999px; padding: 4px 13px; font-size: 0.82rem; font-family: inherit; font-weight: 600;
}
.lb-fit:hover { background: rgba(255, 255, 255, 0.18); }
@media (hover: none) { .lb-tools { display: none; } } /* mobile keeps native pinch */
.lb-zoombtn {
display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
border: 1px solid #d9cdeb; background: #fff; color: var(--accent);
border-radius: 999px; padding: 8px 16px; font-family: inherit; font-size: 0.9rem; font-weight: 600;
transition: background 0.14s ease, color 0.14s ease;
}
.lb-zoombtn:hover { background: var(--accent); color: #fff; border-color: var(--accent); }
@media (hover: none) { .lb-zoombtn { display: none; } } /* mobile keeps native pinch-zoom */
/* The full-screen view wears the same frame. Rail + mat are only a touch larger than the
page (keeping the ~1:1 wood:white proportion), and the image is capped so the whole
framed piece — including the bottom rail — always fits on screen. */
.lb-frame {
max-width: 92vw;
--rail: calc(clamp(18px, 1.9vw, 36px) * var(--frame-scale, 1));
--mat: calc(clamp(16px, 1.6vw, 30px) * min(var(--frame-scale, 1), 1.5));
}
.lb-frame.frame--none { --rail: 0px; --mat: 0px; } /* bare art fills the screen */
.lb-frame img { max-width: 86vw; max-height: 66vh; width: auto; height: auto; object-fit: contain; }
.lb-frame.frame--none img { max-width: 96vw; max-height: 88vh; } /* no frame → go big */
.lb-cap { color: #5b636e; font-size: 0.9rem; }
.lb-cap .sep { color: #a6acb4; }
/* On a narrow portrait phone the framed piece (image + mat + rail) overflowed the
viewport — the rail got clipped and the whole thing read as distorted. Pull the
image cap in so the WHOLE frame fits on screen, and leave room for the caption. */
@media (max-width: 640px) {
.lb-frame { max-width: 94vw; }
.lb-frame img { max-width: 72vw; max-height: 60vh; }
.lb-frame.frame--none img { max-width: 92vw; max-height: 78vh; }
}
/* Landscape artwork on a PORTRAIT phone: turn the whole stage 90° so the painting
fills the long axis (turn the phone to view it level; tap anywhere to close). The
image caps are in SWAPPED units — vh drives the on-screen long edge after rotation,
vw the short edge — so it always fits. Desktop and portrait art never hit this. */
@media (max-width: 640px) and (orientation: portrait) {
.lightbox.rotate .lb-stage { transform: rotate(90deg); transform-origin: center; }
.lightbox.rotate .lb-cap { display: none; } /* would sit sideways; the placard's on the page */
/* The image's SHORT edge + the frame's rail/mat map to the phone's narrow width, so
cap it there (max-height, pre-rotation) with a modest fixed frame so the whole
moulding always fits; the long edge (max-width → screen height) stays generous. */
.lightbox.rotate .lb-frame { max-width: 92vh; } /* rail/mat keep scaling with thickness */
/* short edge (→ screen width after rotation) capped low enough that the frame fits even
at max thickness; the long edge stays generous */
.lightbox.rotate .lb-frame img { max-width: 88vh; max-height: 60vw; }
.lightbox.rotate .lb-frame.frame--none img { max-width: 94vh; max-height: 92vw; }
}
</style>
+1 -1
View File
@@ -35,7 +35,7 @@
{:else} {:else}
<h1>Couldn't sign you in</h1> <h1>Couldn't sign you in</h1>
<p class="muted">{error}</p> <p class="muted">{error}</p>
<a class="back" href="/">← Back to Upbeat Bytes</a> <a class="back" href="/">← Back to upbeatBytes</a>
{/if} {/if}
</main> </main>
+7
View File
@@ -0,0 +1,7 @@
<script>
// /news — the feed's home: Latest by default, Highlights at ?view=highlights, plus
// search/categories/following/saved. Renders the shared NewsFeed in hub chrome.
import NewsFeed from '$lib/components/NewsFeed.svelte';
</script>
<NewsFeed chrome="hub" />
+179
View File
@@ -0,0 +1,179 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import HubShell from '$lib/components/HubShell.svelte';
let f = $state(null);
let state = $state('loading');
const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function dateline(s) {
if (!s) return '';
const [y, m, d] = s.split('-').map(Number);
return `${WEEKDAYS[new Date(y, m - 1, d).getDay()]}, ${MONTHS[m - 1]} ${d}`;
}
let readLabel = $derived(f?.source_url?.includes('wikipedia') ? 'Read more on Wikipedia' : 'Read more');
// Figure-vs-photo: OTD images are uncontrolled (Wikimedia photos, but also seals,
// logos, maps, diagrams). Photos look best full-bleed (cover); figures must be shown
// WHOLE (contain on a clean matte) or they get cropped to nonsense. PNG/SVG sources
// are almost always figures/logos with transparency; refine with aspect ratio on load.
let heroAspect = $state(null);
let heroFit = $derived.by(() => {
const url = f?.image_url || '';
if (/\.(png|svg)(\?|#|$)/i.test(url)) return 'contain';
// filename hints catch JPEG logos/maps/crests the aspect check would crop
if (/(seal|flag|logo|map|diagram|crest|emblem|coat[_-]?of[_-]?arms)/i.test(url)) return 'contain';
if (heroAspect == null) return 'cover';
return (heroAspect < 0.9 || heroAspect > 2.0) ? 'contain' : 'cover';
});
function onHeroLoad(e) {
const img = e.currentTarget;
if (img.naturalWidth && img.naturalHeight) heroAspect = img.naturalWidth / img.naturalHeight;
}
onMount(async () => {
try {
f = await getJSON('/api/onthisday/today');
state = f ? 'ready' : 'empty';
} catch {
state = 'empty';
}
});
</script>
<svelte:head>
<title>On This Day · upbeatBytes</title>
<meta name="description" content="A good thing that happened on today's date in history." />
</svelte:head>
<HubShell active="">
<article class="otd-page">
{#if state === 'ready'}
<!-- CD's "letter" treatment in a green key: deckle frame, dateline, hero with year overlay -->
<div class="card">
<div class="deckle">
<div class="eyebrow">
<span class="eye-rule"></span>
<span class="eye-label">On This Day</span>
</div>
<div class="dateline">
<div class="year-block">
<span class="yb-label">In history</span>
<span class="yb-year">{f.year}</span>
</div>
<span class="dl-rule"></span>
<span class="dl-label">{dateline(f.date)}</span>
</div>
{#if f.image_url}
<div class="hero" class:figure={heroFit === 'contain'}>
<img class="hero-img" src={f.image_url} alt="" loading="lazy" onload={onHeroLoad} />
</div>
{/if}
<p class="headline">{f.text}</p>
{#if f.summary}
<div class="story-head">
<span class="sh-rule"></span>
<span class="sh-label">A little context</span>
</div>
<p class="story">{f.summary}</p>
{/if}
{#if f.source_url}
<div class="cta-row">
<a class="cta" href={f.source_url} target="_blank" rel="noopener">
{readLabel}
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6"/></svg>
</a>
</div>
{/if}
</div>
</div>
{:else if state === 'empty'}
<p class="note">Today's moment in history is on its way. Check back soon.</p>
{:else}
<p class="note">Finding today's good thing…</p>
{/if}
</article>
</HubShell>
<style>
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-var.woff2') format('woff2'); font-weight: 500 700; font-style: normal; font-display: swap; }
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-italic-var.woff2') format('woff2'); font-weight: 500 700; font-style: italic; font-display: swap; }
.otd-page { max-width: 880px; margin: 0 auto; }
.card {
position: relative; margin-top: clamp(24px, 4vw, 40px); background: #f6ead0;
border: 1px solid rgba(120, 90, 40, 0.10); border-radius: 16px;
box-shadow: 0 26px 50px -32px rgba(60, 80, 55, 0.6); padding: clamp(12px, 1.6vw, 18px);
}
.deckle {
position: relative; border: 1.5px dashed rgba(70, 120, 90, 0.38); border-radius: 11px;
padding: clamp(26px, 5vw, 34px) clamp(20px, 5vw, 34px) clamp(34px, 6vw, 44px); overflow: hidden;
}
/* eyebrow matches the homepage "On this day" card accent (clay) so the card → page read as one */
.eyebrow { display: flex; align-items: center; gap: 14px; }
.eye-rule { width: 34px; height: 3px; background: #9a5a38; border-radius: 2px; flex: none; }
.eye-label { font-family: 'Hanken Grotesk', sans-serif; font-size: clamp(17px, 2.4vw, 22px); font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #9a5a38; }
/* extra breathing room between the title and the content below */
.dateline { display: flex; align-items: baseline; flex-wrap: wrap; gap: 10px 16px; margin: clamp(32px, 5vw, 48px) 0 16px; }
.dl-rule { flex: 1 1 30px; min-width: 24px; height: 1.5px; align-self: baseline; margin: 0; background: rgba(70, 120, 90, 0.26); }
.dl-label { font-family: 'Hanken Grotesk', sans-serif; font-weight: 700; font-size: clamp(14px, 1.7vw, 17px); letter-spacing: 0.1em; text-transform: uppercase; color: #2c5d44; }
/* "2013" is the SOLE in-flow baseline anchor (shares one baseline with the date + rule);
"IN HISTORY" is absolutely positioned in the reserved start-padding so raising it can never
drag that baseline (Codex's structure). Both green so the line ties in the Read-more button. */
.year-block { --ys: clamp(34px, 5.5vw, 46px); position: relative; display: inline-block; padding-inline-start: 90px; line-height: 1; flex: none; }
/* offset tied to the year size (≈cap-top), so it lands by construction and holds across
viewports instead of needing per-pixel re-guesses */
.yb-label { position: absolute; inset-inline-start: 0; top: calc(0.2 * var(--ys) + 1px); line-height: 1; font-family: 'Hanken Grotesk', sans-serif; font-size: 12px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #367653; }
.yb-year { position: static; display: inline-block; font-family: 'Playfair Display', Georgia, serif; font-weight: 700; font-size: var(--ys); line-height: 1; color: #2c5d44; }
/* the full line can't fit cleanly on a narrow phone — wrap it deliberately: year lockup on its
own row, rule + date on the next (instead of accidental mid-line flex wrapping) */
@media (max-width: 520px) { .year-block { flex: 0 0 100%; } }
.hero {
position: relative; border-radius: 12px; overflow: hidden; background: #21392e;
height: clamp(220px, 38vw, 330px);
}
.hero-img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* figures (seals/logos/diagrams) shown whole on a clean light matte, never cropped */
.hero.figure { background: #f4f6f1; border: 1px solid rgba(70, 120, 90, 0.16); }
.hero.figure .hero-img { object-fit: contain; padding: clamp(18px, 3vw, 30px); box-sizing: border-box; }
.headline {
font-family: 'Playfair Display', Georgia, serif; font-style: italic; font-weight: 600;
font-size: clamp(1.45rem, 4vw, 2.06rem); line-height: 1.28; color: #243d31;
margin: clamp(24px, 4vw, 30px) 0 0; max-width: 40ch;
}
.story-head { display: flex; align-items: center; gap: 12px; margin-top: clamp(26px, 4vw, 34px); }
.sh-rule { width: 26px; height: 2px; background: #bcae93; border-radius: 2px; flex: none; }
.sh-label { font-family: 'Hanken Grotesk', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #74633f; }
.story {
font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.8vw, 1.13rem);
line-height: 1.68; color: #4f574f; margin: 12px 0 0; max-width: 64ch;
}
.cta-row { margin-top: clamp(24px, 4vw, 30px); }
/* clay to match the "On This Day" title — gives this card its own identity (AA: white on #9a5a38) */
.cta {
display: inline-flex; align-items: center; gap: 8px; background: #9a5a38; color: #fff;
text-decoration: none; font-family: 'Hanken Grotesk', sans-serif; font-size: 14px; font-weight: 600;
padding: 11px 20px; border-radius: 999px; -webkit-tap-highlight-color: transparent;
transition: background 0.15s ease;
}
.cta:hover { background: #854c2f; }
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
</style>
+402 -60
View File
@@ -3,27 +3,58 @@
import { goto, afterNavigate } from '$app/navigation'; import { goto, afterNavigate } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { getJSON } from '$lib/api.js'; 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';
import { isDevGated, blockedForViewer } from '$lib/devgate.js';
import { trackGame } from '$lib/analytics.js';
import WordGame from '$lib/components/WordGame.svelte'; import WordGame from '$lib/components/WordGame.svelte';
import WordSearchGame from '$lib/components/WordSearchGame.svelte'; import WordSearchGame from '$lib/components/WordSearchGame.svelte';
import BloomGame from '$lib/components/BloomGame.svelte';
import MatchGame from '$lib/components/MatchGame.svelte';
// Screen is derived from the URL so the device/browser Back button steps through // Screen is derived from the URL so the device/browser Back button steps through
// Hub → Game Selection → Game (each screen is its own history entry), instead of // Hub → Game Selection → Game (each screen is its own history entry), instead of
// jumping straight out of /play. // jumping straight out of /play.
let sp = $derived($page.url.searchParams); let sp = $derived($page.url.searchParams);
let game = $derived(sp.get('game') === 'wordsearch' ? 'wordsearch' : 'word'); let game = $derived(['wordsearch', 'bloom', 'match'].includes(sp.get('game')) ? sp.get('game') : 'word');
let view = $derived(!sp.get('game') ? 'hub' : (sp.get('v') ? 'play' : 'select')); let view = $derived(!sp.get('game') ? 'hub' : (sp.get('v') ? 'play' : 'select'));
// Bloom v: 'daily' (shared Center Circle) | 'free-center' | 'free-wild'.
let bloomMode = $derived(sp.get('v') === 'daily' ? 'daily' : 'free');
let bloomFormat = $derived(sp.get('v') === 'free-wild' ? 'wild' : 'center');
// Hide an in-dev game's card from non-admins, and bounce them off its route.
let bloomBlocked = $derived(blockedForViewer('bloom', auth.user, $page.url));
let bloomStatus = $state(null);
let variant = $derived(['5', '6'].includes(sp.get('v')) ? sp.get('v') : '5'); let variant = $derived(['5', '6'].includes(sp.get('v')) ? sp.get('v') : '5');
let wsSize = $derived(['small', 'med', 'large'].includes(sp.get('v')) ? sp.get('v') : 'med'); let wsSize = $derived(['small', 'med', 'large'].includes(sp.get('v')) ? sp.get('v') : 'med');
// Memory Match. v = "<mode>-<format>-<tier>" e.g. daily-icons-standard / free-colors-expert.
let matchBlocked = $derived(blockedForViewer('match', auth.user, $page.url));
let matchParts = $derived((sp.get('v') || '').split('-'));
let matchMode = $derived(matchParts[0] === 'free' ? 'free' : 'daily');
let matchFormat = $derived(matchParts[1] === 'colors' ? 'colors' : 'icons');
let matchTier = $derived(['gentle', 'standard', 'expert'].includes(matchParts[2]) ? matchParts[2] : 'standard');
let matchStatus = $state(null);
let matchFmt = $state('icons'); // format toggle on the Match selection screen
let date = $state(''); let date = $state('');
let wordStatus = $state({ 5: null, 6: null }); let wordStatus = $state({ 5: null, 6: null });
let wsStatus = $state(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) { function readWord(v) {
try { try {
const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null'); const s = JSON.parse(localStorage.getItem(`goodnews:word:${v}:${date}`) || 'null');
if (s && (s.status === 'won' || s.status === 'lost')) { const tries = (s?.guesses || []).length;
return { status: s.status, tries: (s.guesses || []).length, max: v === '6' ? 7 : 6 }; // Surface in-progress too (so "continue on another device" shows on the card),
// not just finished games.
if (s && (s.status === 'won' || s.status === 'lost' || (s.status === 'playing' && tries > 0))) {
return { status: s.status, tries, max: v === '6' ? 7 : 6 };
} }
} catch { /* ignore */ } } catch { /* ignore */ }
return null; return null;
@@ -35,6 +66,36 @@
} catch { /* ignore */ } } catch { /* ignore */ }
return null; return null;
} }
function readBloom() {
try {
const s = JSON.parse(localStorage.getItem(`goodnews:bloom:${date}`) || 'null');
if (s && Array.isArray(s.found)) return { count: s.found.length, full: !!s.full };
} catch { /* ignore */ }
return null;
}
// All daily Match boards (any tier × format) for the day.
const MATCH_TIERS = [
['gentle', 'Gentle', '4×3 · 6 pairs'],
['standard', 'Standard', '4×4 · 8 pairs'],
['expert', 'Expert', '6×4 · match 3 of a kind'],
];
const MATCH_VARIANTS = MATCH_TIERS.flatMap(([t]) => ['icons', 'colors'].map((f) => `${t}-${f}`));
const MATCH_VS = ['daily', 'free'].flatMap((m) =>
['icons', 'colors'].flatMap((f) => MATCH_TIERS.map(([t]) => `${m}-${f}-${t}`)));
function readMatchVariant(v) {
try { return JSON.parse(localStorage.getItem(`goodnews:match:${v}:${date}`) || 'null'); } catch { return null; }
}
function readMatch() {
let best = null;
for (const v of MATCH_VARIANTS) {
const s = readMatchVariant(v);
if (!s) continue;
if (s.done) return { done: true };
const count = (s.matched || []).length;
if (count > 0 && (!best || count > best.count)) best = { done: false, count };
}
return best;
}
function refreshStatus() { function refreshStatus() {
wordStatus = { 5: readWord('5'), 6: readWord('6') }; wordStatus = { 5: readWord('5'), 6: readWord('6') };
let ws = null; let ws = null;
@@ -44,17 +105,61 @@
if (s && s.found > 0 && !ws) ws = s; if (s && s.found > 0 && !ws) ws = s;
} }
wsStatus = ws; wsStatus = ws;
bloomStatus = readBloom();
matchStatus = readMatch();
} }
function fmtMs(ms) { function fmtMs(ms) {
const s = Math.round(ms / 1000); const s = Math.round(ms / 1000);
return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
} }
// The hub itself reconciles every game with the server (signed-in), so cards
// show cross-device status WITHOUT having to open each game first, and this
// device's local progress gets uploaded even for games it hasn't reopened.
// Every daily board the hub surfaces, as [game, variant, localStorage key]. Match
// includes all tier×format variants so cross-device progress shows without opening
// the game.
function gameSpecs() {
return [
['word', '5', `goodnews:word:5:${date}`],
['word', '6', `goodnews:word:6:${date}`],
['wordsearch', 'small', `goodnews:wordsearch:small:${date}`],
['wordsearch', 'med', `goodnews:wordsearch:med:${date}`],
['wordsearch', 'large', `goodnews:wordsearch:large:${date}`],
['bloom', '', `goodnews:bloom:${date}`],
...MATCH_VARIANTS.map((v) => ['match', v, `goodnews:match:${v}:${date}`]),
];
}
// One batch request reconciles ALL boards (instead of a dozen calls on every /play
// load — that fan-out was tripping the boot-slow beacon). Still server-merged, so
// cross-device pull is preserved.
async function syncAllGames() {
if (!auth.user || !date) return;
const specs = gameSpecs();
const items = specs.map(([game, variant, key]) => {
let local = null;
try { local = JSON.parse(localStorage.getItem(key) || 'null'); } catch { /* ignore */ }
return { game, variant, state: local || {} };
});
const states = await pushGameStatesBatch(date, items);
if (states) {
const keyOf = (g, v) => specs.find((s) => s[0] === g && s[1] === v)?.[2];
for (const { game, variant, state } of states) {
if (!state) continue;
const merged = { ...state };
if (game === 'wordsearch') merged.status = merged.ms ? 'done' : 'playing'; // card reads .status
const key = keyOf(game, variant);
if (key) { try { localStorage.setItem(key, JSON.stringify(merged)); } catch { /* ignore */ } }
}
}
refreshStatus();
}
// Hub card one-liners // Hub card one-liners
function wordLabel() { function wordLabel() {
const a = wordStatus['5'], b = wordStatus['6']; const a = wordStatus['5'], b = wordStatus['6'];
if (!a && !b) return 'Guess the days word'; if (!a && !b) return 'Guess the days word';
const part = (s, mx) => s ? (s.status === 'won' ? `${s.tries}/${mx}` : 'X') : ''; const part = (s, mx) => !s ? '' : s.status === 'won' ? `${s.tries}/${mx}` : s.status === 'lost' ? 'X' : `${s.tries}…`;
return `Today · 5:${part(a, 6)} 6:${part(b, 7)}`; return `Today · 5:${part(a, 6)} 6:${part(b, 7)}`;
} }
function wsHubLabel() { function wsHubLabel() {
@@ -63,12 +168,32 @@
if (wsStatus.found > 0) return `Today: ${wsStatus.found} found`; if (wsStatus.found > 0) return `Today: ${wsStatus.found} found`;
return 'Find the days themed words'; return 'Find the days themed words';
} }
function bloomHubLabel() {
if (!bloomStatus || !bloomStatus.count) return 'Make words from todays letters';
if (bloomStatus.full) return 'Today: Full Bloom 🌸';
return `Today: ${bloomStatus.count} ${bloomStatus.count === 1 ? 'word' : 'words'}`;
}
function matchHubLabel() {
if (!matchStatus) return 'Match the days pairs';
if (matchStatus.done) return 'Today: cleared';
return `Today: ${matchStatus.count} matched`;
}
function matchOpt(t) {
const s = readMatchVariant(`${t}-${matchFmt}`);
if (!s) return 'Play';
if (s.done) return 'Cleared';
const c = (s.matched || []).length;
return c > 0 ? `${c} matched` : 'Play';
}
// Game-selection option statuses // Game-selection option statuses
function wordOpt(v) { function wordOpt(v) {
const s = wordStatus[v]; const s = wordStatus[v];
if (!s) return 'Play'; if (!s) return 'Play';
return s.status === 'won' ? `Solved ${s.tries}/${s.max}` : 'Out of guesses'; if (s.status === 'won') return `Solved ${s.tries}/${s.max}`;
if (s.status === 'lost') return 'Out of guesses';
return `Continue · ${s.tries}/${s.max}`;
} }
function wsOpt(sz) { function wsOpt(sz) {
const s = readWsSize(sz); const s = readWsSize(sz);
@@ -83,6 +208,11 @@
// (no in-app history) it navigates to the parent screen instead of leaving the // (no in-app history) it navigates to the parent screen instead of leaving the
// site. Device Back stays browser-native either way. // site. Device Back stays browser-native either way.
let appNavDepth = 0; let appNavDepth = 0;
let cameFromApp = $state(false); // arrived via in-app nav (e.g. the hub) → Back returns there
function leavePlay() {
if (cameFromApp && typeof history !== 'undefined') history.back();
else goto('/', { replaceState: true });
}
function openGame(g) { appNavDepth++; goto('/play?game=' + g); } function openGame(g) { appNavDepth++; goto('/play?game=' + g); }
function pick(v) { appNavDepth++; goto('/play?game=' + game + '&v=' + v); } function pick(v) { appNavDepth++; goto('/play?game=' + game + '&v=' + v); }
function back() { function back() {
@@ -91,13 +221,19 @@
} }
// Canonicalize shareable/bookmarked URLs: unknown game → hub; invalid v for the // Canonicalize shareable/bookmarked URLs: unknown game → hub; invalid v for the
// game → its default (replaceState, so it doesn't add a history entry). // game → its default (replaceState, so it doesn't add a history entry). An in-dev
// game's route bounces non-admins back to the hub.
$effect(() => { $effect(() => {
const g = sp.get('game'), v = sp.get('v'); const g = sp.get('game'), v = sp.get('v');
if (g && g !== 'word' && g !== 'wordsearch') { goto('/play', { replaceState: true }); return; } if (g && !['word', 'wordsearch', 'bloom', 'match'].includes(g)) { goto('/play', { replaceState: true }); return; }
if (g && blockedForViewer(g, auth.user, $page.url)) { goto('/play', { replaceState: true }); return; }
if (g && v) { if (g && v) {
const valid = g === 'word' ? ['5', '6'] : ['small', 'med', 'large']; const valid = g === 'word' ? ['5', '6']
if (!valid.includes(v)) goto(`/play?game=${g}&v=${g === 'word' ? '5' : 'med'}`, { replaceState: true }); : g === 'wordsearch' ? ['small', 'med', 'large']
: g === 'match' ? MATCH_VS
: ['daily', 'free-center', 'free-wild'];
const def = g === 'word' ? '5' : g === 'wordsearch' ? 'med' : g === 'match' ? 'daily-icons-standard' : 'daily';
if (!valid.includes(v)) goto(`/play?game=${g}&v=${def}`, { replaceState: true });
} }
}); });
@@ -120,63 +256,180 @@
let wsTheme = $state(''); let wsTheme = $state('');
onMount(async () => { onMount(async () => {
// Share-loop acquisition: record an arrival when someone lands via a shared game
// link (gameShareUrl tags it utm_source=game_share), attributed to that game.
if ($page.url.searchParams.get('utm_source') === 'game_share') trackGame(game, 'arrival');
initPrefs(); // so the reader's chosen calm set is available on a direct /play landing
try { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ } try { date = (await getJSON('/api/puzzle/word?variant=5')).date; } catch { /* offline */ }
try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ } try { wsTheme = (await getJSON('/api/puzzle/wordsearch?variant=med')).theme; } catch { /* offline */ }
refreshStatus(); refreshStatus();
syncAllGames(); // signed-in: pull cross-device status into the cards + upload local progress
}); });
// Refresh hub/selection statuses whenever we land on a screen (incl. Back). // Refresh hub/selection statuses whenever we land on a screen (incl. Back).
afterNavigate(() => refreshStatus()); afterNavigate(({ from }) => { if (from) cameFromApp = true; refreshStatus(); });
</script> </script>
<svelte:head><title>Play · Upbeat Bytes</title></svelte:head> <svelte:head>
<!-- Canonical/OG/description for /play are baked into the static play.html at build
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 · upbeatBytes — calm daily games</title>
{#if isDevGated(game)}<meta name="robots" content="noindex" />{/if}
</svelte:head>
<header class="bar"> <HubBar active="games" />
<div class="container inner">
<a class="brand" href="/"><img class="logo" src="/logo.svg" alt="Upbeat Bytes" /></a>
{#if view === 'hub'}
<a class="back" href="/"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>News</a>
{:else}
<button class="back" onclick={back}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M19 12H5M11 6l-6 6 6 6" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/></svg>{view === 'play' ? 'Game Selection' : 'Play Hub'}
</button>
{/if}
</div>
</header>
<main class="container page" class:gameview={view === 'play'}> <main class="container page" class:gameview={view === 'play'}>
<!-- In a game, a step-back row (selection / hub). On the games landing, Back lives in the
header row instead (right-justified, level with "Play"). -->
{#if view !== 'hub'}
<button class="gameback" onclick={back}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
{view === 'play' ? 'Game Selection' : 'Play Hub'}
</button>
{/if}
{#if view === 'hub'} {#if view === 'hub'}
<h1>Play</h1> <div class="arcade-head">
<p class="sub">A small calm thing after the brief. One of each a day — no rush, no score to beat but your own.</p> <div class="head-left">
<div class="cards"> <span class="eyelash" aria-hidden="true"></span>
<button class="gamecard" onclick={() => openGame('word')}> <h1 class="play-title">Play</h1>
<div class="gc-icon"></div> <span class="head-div" aria-hidden="true"></span>
<div class="gc-body"> <p class="head-sub">A few little games, fresh every morning. Pick one and dive in.</p>
<h2>Daily Word</h2>
<p class="gc-sub">Guess the hopeful word · 5 or 6 letters</p>
<p class="gc-status" class:played={wordStatus['5'] || wordStatus['6']}>{wordLabel()}</p>
</div> </div>
<button class="gameback hub-back" onclick={leavePlay}>
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M15 18l-6-6 6-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
Back
</button> </button>
<button class="gamecard" onclick={() => openGame('wordsearch')}>
<div class="gc-icon"></div>
<div class="gc-body">
<h2>Word Search</h2>
<p class="gc-sub">Find the days themed words</p>
<p class="gc-status" class:played={wsStatus && (wsStatus.status === 'done' || wsStatus.found > 0)}>{wsHubLabel()}</p>
</div> </div>
<div class="arcade">
<button class="tile tile-word" onclick={() => openGame('word')}>
<span class="motif m-word" aria-hidden="true">
{#each ['B', 'Y', 'T', 'E', '', 'C', 'A', 'L', 'M', ''] as ch}
<span class="wt" class:on={ch}>{ch}</span>
{/each}
</span>
<span class="foot">
<span class="t-name">Daily Word</span>
<span class="t-clue">Todays word · 5 or 6 letters</span>
</span>
</button> </button>
<button class="tile tile-ws" onclick={() => openGame('wordsearch')}>
<span class="motif m-ws" aria-hidden="true">
{#each WS_GRID as ch, i}<span class="wc" class:hl={WS_HL.has(i)}>{ch}</span>{/each}
</span>
<span class="foot">
<span class="t-name">Word Search</span>
<span class="t-clue">Todays themed grid</span>
</span>
</button>
{#if !bloomBlocked}
<button class="tile tile-bloom" onclick={() => openGame('bloom')}>
<span class="motif m-bloom" aria-hidden="true">
<span class="bloom-ring">
<span class="petal pc">T</span>
<span class="petal p1">A</span><span class="petal p2">E</span><span class="petal p3">G</span>
<span class="petal p4">L</span><span class="petal p5">R</span><span class="petal p6">Y</span>
</span>
</span>
<span class="foot">
<span class="t-name">Bloom{#if isDevGated('bloom')}<span class="devtag">dev</span>{/if}</span>
<span class="t-clue">Todays letter garden</span>
</span>
</button>
{/if}
{#if !matchBlocked}
<button class="tile tile-match" onclick={() => openGame('match')}>
<span class="motif m-match" aria-hidden="true">
{#each [0, 1, 2, 3, 4, 5, 6, 7] as i}
<span class="mm" class:flip={i === 1 || i === 6}>{#if i === 1 || i === 6}<span class="dot"></span>{/if}</span>
{/each}
</span>
<span class="foot">
<span class="t-name">Memory Match{#if isDevGated('match')}<span class="devtag">dev</span>{/if}</span>
<span class="t-clue">Todays board</span>
</span>
</button>
{/if}
<!-- teased, not built yet -->
<div class="tile tile-bubble soon">
<span class="motif m-bubble" aria-hidden="true">
<span class="bub b-a" style="left:38px;top:12px"></span>
<span class="bub b-b" style="left:73px;top:12px"></span>
<span class="bub b-d" style="left:108px;top:12px"></span>
<span class="bub b-a" style="left:143px;top:12px"></span>
<span class="bub b-b" style="left:178px;top:12px"></span>
<span class="bub b-d" style="left:55px;top:41px"></span>
<span class="bub b-a" style="left:90px;top:41px"></span>
<span class="bub b-b" style="left:125px;top:41px"></span>
<span class="bub b-d" style="left:160px;top:41px"></span>
<span class="bub b-b" style="left:73px;top:70px"></span>
<span class="bub b-c" style="left:108px;top:70px"></span>
<span class="bub b-d" style="left:143px;top:70px"></span>
<span class="bub-aim"></span>
<span class="bub-shooter b-c"></span>
</span>
<span class="ribbon">Coming soon</span>
<span class="foot">
<span class="t-name">Bubble Blaster</span>
<span class="t-clue">A gentle bubble-pop puzzle</span>
</span>
</div>
<div class="tile tile-more soon">
<span class="more-plus" aria-hidden="true">+</span>
<span class="t-name">More coming soon</span>
<span class="t-clue">Little games in the workshop</span>
</div>
</div> </div>
{:else if view === 'select'} {:else if view === 'select'}
<h1 class="seltitle">{game === 'word' ? 'Daily Word' : 'Word Search'}</h1> <h1 class="seltitle">{game === 'word' ? 'Daily Word' : game === 'wordsearch' ? 'Word Search' : game === 'match' ? 'Memory Match' : 'Bloom'}</h1>
{#if game === 'wordsearch' && wsTheme} {#if game === 'wordsearch' && wsTheme}
<div class="themecard"> <div class="themecard">
<span class="tc-label">Todays theme</span> <span class="tc-label">Todays theme</span>
<span class="tc-name">{wsTheme}</span> <span class="tc-name">{wsTheme}</span>
</div> </div>
{/if} {/if}
<p class="sub">{game === 'word' ? 'Pick your length.' : 'Pick your size.'}</p> <p class="sub">{game === 'word' ? 'Pick your length.' : game === 'wordsearch' ? 'Pick your size.' : game === 'match' ? 'Pick a format, then todays board or free play.' : 'Play todays shared puzzle, or graze freely.'}</p>
<div class="opts"> <div class="opts">
{#if game === 'word'} {#if game === 'match'}
<div class="seg">
<button class="segbtn" class:on={matchFmt === 'icons'} onclick={() => (matchFmt = 'icons')}>Memory · icons</button>
<button class="segbtn" class:on={matchFmt === 'colors'} onclick={() => (matchFmt = 'colors')}>Color Match</button>
</div>
<p class="grp">Todays board — shared daily</p>
{#each MATCH_TIERS as [t, name, desc] (t)}
<button class="opt" onclick={() => pick(`daily-${matchFmt}-${t}`)}>
<span class="opt-main"><strong>{name}</strong><span>{desc}</span></span>
<span class="opt-go" class:done={readMatchVariant(`${t}-${matchFmt}`)?.done}>{matchOpt(t)}</span>
</button>
{/each}
<p class="grp">Free play — fresh boards anytime</p>
{#each MATCH_TIERS as [t, name, desc] (t)}
<button class="opt" onclick={() => pick(`free-${matchFmt}-${t}`)}>
<span class="opt-main"><strong>{name}</strong><span>{desc}</span></span>
<span class="opt-go">Play</span>
</button>
{/each}
{:else if game === 'bloom'}
<button class="opt" onclick={() => pick('daily')}>
<span class="opt-main"><strong>Todays Bloom</strong><span>the shared daily · center letter</span></span>
<span class="opt-go" class:done={bloomStatus && bloomStatus.count > 0}>{bloomStatus && bloomStatus.count ? (bloomStatus.full ? 'Full Bloom 🌸' : `${bloomStatus.count} found`) : 'Play'}</span>
</button>
<button class="opt" onclick={() => pick('free-center')}>
<span class="opt-main"><strong>Free Play · Center Circle</strong><span>fresh wheels anytime · center letter</span></span>
<span class="opt-go">Play</span>
</button>
<button class="opt" onclick={() => pick('free-wild')}>
<span class="opt-main"><strong>Free Play · Wild Bloom</strong><span>fresh wheels · use any letters</span></span>
<span class="opt-go">Play</span>
</button>
{:else if game === 'word'}
<button class="opt" onclick={() => pick('5')}> <button class="opt" onclick={() => pick('5')}>
<span class="opt-main"><strong>Daily Word</strong><span>5 letters · 6 guesses</span></span> <span class="opt-main"><strong>Daily Word</strong><span>5 letters · 6 guesses</span></span>
<span class="opt-go" class:done={wordStatus['5']}>{wordOpt('5')}</span> <span class="opt-go" class:done={wordStatus['5']}>{wordOpt('5')}</span>
@@ -198,20 +451,32 @@
{:else if view === 'play'} {:else if view === 'play'}
{#if game === 'word'} {#if game === 'word'}
<WordGame {variant} onstatus={refreshStatus} /> <WordGame {variant} onstatus={refreshStatus} />
{:else} {:else if game === 'wordsearch'}
<WordSearchGame size={wsSize} onstatus={refreshStatus} /> <WordSearchGame size={wsSize} onstatus={refreshStatus} />
{:else if game === 'bloom'}
{#key sp.get('v')}
<BloomGame mode={bloomMode} format={bloomFormat} onstatus={refreshStatus} />
{/key}
{:else if game === 'match'}
{#key sp.get('v')}
<MatchGame mode={matchMode} format={matchFormat} tier={matchTier} {date} onstatus={refreshStatus} />
{/key}
{/if} {/if}
{/if} {/if}
</main> </main>
<Footer />
<style> <style>
header.bar { background: var(--surface); border-bottom: 1px solid var(--line); position: sticky; top: 0; z-index: 20; } /* in-game step-back, below the shared HubBar (matches the hub Back affordance) */
.inner { display: flex; align-items: center; justify-content: space-between; height: 64px; } .gameback { color: var(--accent-deep); font-size: 0.9rem; font-weight: 600; display: inline-flex;
.logo { height: 40px; display: block; } align-items: center; gap: 6px; background: none; border: none; font-family: inherit;
.back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px; cursor: pointer; padding: 6px 10px 6px 0; margin-bottom: 4px;
background: none; border: none; font-family: inherit; cursor: pointer; } -webkit-tap-highlight-color: transparent; }
.back svg { width: 17px; height: 17px; display: block; } .gameback svg { width: 16px; height: 16px; display: block; transition: transform 0.15s ease; }
.page { padding: 22px 20px 70px; } .gameback:hover { color: var(--accent); }
.gameback:hover svg { transform: translateX(-2px); }
.page { padding: 16px 20px 70px; }
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 6px; } h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 6px; }
.seltitle { font-size: clamp(1.7rem, 4.5vw, 2.2rem); } .seltitle { font-size: clamp(1.7rem, 4.5vw, 2.2rem); }
.sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; } .sub { color: var(--muted); margin: 0 0 24px; max-width: 540px; }
@@ -219,20 +484,97 @@
margin: 8px 0 24px; max-width: 460px; text-align: center; box-shadow: var(--shadow); } margin: 8px 0 24px; max-width: 460px; text-align: center; box-shadow: var(--shadow); }
.tc-label { display: block; text-transform: uppercase; letter-spacing: 0.13em; font-size: 0.66rem; .tc-label { display: block; text-transform: uppercase; letter-spacing: 0.13em; font-size: 0.66rem;
font-family: var(--label); font-weight: 600; color: var(--accent-deep); margin-bottom: 4px; } font-family: var(--label); font-weight: 600; color: var(--accent-deep); margin-bottom: 4px; }
/* Memory Match selection: format toggle + grouped tier options */
.seg { display: flex; gap: 6px; background: var(--bg); border: 1px solid var(--line);
border-radius: 12px; padding: 4px; margin-bottom: 6px; }
.segbtn { flex: 1; padding: 9px 10px; border: none; border-radius: 9px; background: none;
font-family: inherit; font-size: 0.9rem; color: var(--muted); cursor: pointer; }
.segbtn.on { background: var(--surface); color: var(--accent-deep); font-weight: 600; box-shadow: var(--shadow); }
.grp { margin: 14px 0 2px; font-family: var(--label); text-transform: uppercase; letter-spacing: 0.04em;
font-size: 0.7rem; color: var(--muted); }
.tc-name { font-family: var(--serif); font-size: 1.7rem; color: var(--accent-deep); line-height: 1.15; } .tc-name { font-family: var(--serif); font-size: 1.7rem; color: var(--accent-deep); line-height: 1.15; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 16px; } /* ===== Arcade hub (CD "Play - Arcade"): colored game tiles, whole-card tap, no
.gamecard { dashboard/streaks/XP — just games. Fonts match the rest of the hub. ===== */
display: flex; gap: 14px; align-items: center; text-align: left; @font-face { font-family: 'Hanken Grotesk'; src: url('/fonts/hanken-var.woff2') format('woff2'); font-weight: 400 800; font-style: normal; font-display: swap; }
background: var(--surface); border: 1px solid var(--line); border-radius: 16px; @font-face { font-family: 'Newsreader'; src: url('/fonts/newsreader-var.woff2') format('woff2'); font-weight: 400 600; font-style: normal; font-display: swap; }
padding: 18px 20px; cursor: pointer; font: inherit; color: var(--ink); @font-face { font-family: 'Space Mono'; src: url('/fonts/space-mono-latin.woff2') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; }
box-shadow: var(--shadow); transition: border-color 0.14s ease, transform 0.14s ease;
.arcade-head { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: clamp(16px, 3vw, 28px) 0 24px; }
.head-left { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; min-width: 0; }
.hub-back { flex: none; align-self: flex-start; }
.eyelash { width: 34px; height: 4px; border-radius: 2px; background: #f0a830; flex: none; }
.play-title { font-family: 'Newsreader', Georgia, serif; font-weight: 600; font-size: clamp(2.4rem, 6vw, 3.1rem); color: #e5882a; letter-spacing: -0.015em; line-height: 0.9; margin: 0; }
.head-div { width: 1px; height: 32px; background: rgba(120, 95, 50, 0.22); flex: none; }
.head-sub { color: var(--muted); font-size: 15px; line-height: 1.45; max-width: 340px; margin: 0; }
.arcade { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
.tile {
position: relative; min-width: 0; min-height: 244px; border-radius: 22px; overflow: hidden;
border: none; padding: 0; margin: 0; width: 100%; box-sizing: border-box; display: block;
cursor: pointer; text-align: left; color: inherit; font-family: 'Hanken Grotesk', sans-serif;
transition: transform 0.18s cubic-bezier(.34, 1.4, .5, 1), box-shadow 0.18s ease;
-webkit-tap-highlight-color: transparent;
} }
.gamecard:hover { border-color: var(--accent); transform: translateY(-1px); } .tile:hover { transform: translateY(-5px); }
.gc-icon { font-size: 2rem; color: var(--accent); line-height: 1; flex-shrink: 0; } .tile.soon { cursor: default; }
.gc-body h2 { font-size: 1.2rem; margin: 0 0 3px; } .tile.soon:hover { transform: none; }
.gc-sub { color: var(--muted); font-size: 0.86rem; margin: 0 0 8px; } @media (prefers-reduced-motion: reduce) { .tile { transition: none; } .tile:hover { transform: none; } }
.gc-status { font-size: 0.84rem; color: var(--accent-deep); font-weight: 600; margin: 0; } .tile-word { background: #f0a830; box-shadow: 0 12px 26px -10px rgba(224, 148, 31, 0.55); }
.tile-ws { background: #2f7fb0; box-shadow: 0 12px 26px -10px rgba(47, 127, 176, 0.55); }
.tile-bloom { background: #5a9b63; box-shadow: 0 12px 26px -10px rgba(90, 155, 99, 0.55); }
.tile-match { background: #c0688c; box-shadow: 0 12px 26px -10px rgba(192, 104, 140, 0.55); }
.tile-bubble { background: linear-gradient(160deg, #6a6fc6, #4a4ea0); box-shadow: 0 12px 26px -10px rgba(74, 78, 160, 0.55); }
.tile-more { background: #efe6d4; border: 1.5px dashed #cdbb9a; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 24px; text-align: center; }
.foot { position: absolute; left: 0; right: 0; bottom: 0; padding: 30px 18px 15px; display: flex; flex-direction: column; gap: 3px; background: linear-gradient(to top, rgba(50, 33, 12, 0.62), transparent); }
.t-name { font-weight: 800; font-size: 19px; color: #fff; }
.t-clue { font-family: 'Space Mono', monospace; font-size: 10.5px; letter-spacing: 0.03em; color: rgba(255, 255, 255, 0.84); }
.tile-more .t-name { color: #6f6757; font-size: 16px; }
.tile-more .t-clue { color: #a89a7c; }
.more-plus { font-family: 'Newsreader', serif; font-size: 42px; line-height: 1; color: #cdbb9a; }
.ribbon { position: absolute; top: 14px; right: 14px; font-family: 'Space Mono', monospace; font-size: 9.5px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; color: #fff; background: rgba(0, 0, 0, 0.3); padding: 5px 10px; border-radius: 12px; }
.devtag { margin-left: 8px; font-size: 0.58rem; font-family: 'Space Mono', monospace; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: #fff; background: rgba(0, 0, 0, 0.32); border-radius: 5px; padding: 2px 6px; vertical-align: middle; }
/* tile motifs — decorative; clipped by the tile's overflow:hidden */
.motif { position: absolute; inset: 0; pointer-events: none; }
.m-word { padding: 18px 22px 66px; display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; align-content: center; }
.wt { aspect-ratio: 1; box-sizing: border-box; border: 2.5px solid rgba(255, 255, 255, 0.45); border-radius: 8px; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: clamp(15px, 3.4vw, 22px); color: #cf7d1c; }
.wt.on { background: #fff; border-color: #fff; }
.m-ws { padding: 12px 16px 66px; display: grid; grid-template-columns: repeat(6, 29px); gap: 4px; justify-content: center; align-content: center; }
.wc { width: 29px; height: 29px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 12.5px; color: rgba(255, 255, 255, 0.42); }
.wc.hl { background: rgba(255, 255, 255, 0.93); color: #2f7fb0; border-radius: 7px; box-shadow: 0 2px 7px rgba(0, 0, 0, 0.12); }
/* Bloom is a circular medallion, so center it in the tile's visual field (~43%), lower than
the other top-illustration motifs — just a small padding-bottom to keep the lower petal
off the title/gradient. */
.m-bloom { display: flex; align-items: center; justify-content: center; padding-bottom: 32px; box-sizing: border-box; }
.bloom-ring { position: relative; width: 150px; height: 150px; }
.m-bloom .petal { position: absolute; width: 42px; height: 42px; border-radius: 50%; background: #fff; color: #4f8a58; display: flex; align-items: center; justify-content: center; font-weight: 800; font-size: 18px; box-shadow: 0 3px 7px rgba(0, 0, 0, 0.1); }
.m-bloom .pc { width: 50px; height: 50px; background: #3e7a47; color: #fff; font-size: 22px; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); z-index: 1; left: 50%; top: 50%; transform: translate(-50%, -50%); }
.m-bloom .p1 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(0, -56px); }
.m-bloom .p2 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(48px, -28px); }
.m-bloom .p3 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(48px, 28px); }
.m-bloom .p4 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(0, 56px); }
.m-bloom .p5 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(-48px, 28px); }
.m-bloom .p6 { left: 50%; top: 50%; transform: translate(-50%, -50%) translate(-48px, -28px); }
.m-match { padding: 18px 22px 66px; display: grid; grid-template-columns: repeat(4, 50px); gap: 10px; justify-content: center; align-content: center; }
.mm { width: 50px; height: 50px; border-radius: 9px; background: rgba(255, 255, 255, 0.2); display: flex; align-items: center; justify-content: center; }
.mm.flip { background: #fff; }
.mm .dot { width: 17px; height: 17px; border-radius: 50%; background: #c0688c; }
/* bubble shooter: a packed cluster up top, a dashed aim line, a loaded shooter below */
.bub { position: absolute; width: 34px; height: 34px; border-radius: 50%; box-shadow: 0 3px 8px rgba(0, 0, 0, 0.16); }
/* shooter sits to the RIGHT (clear of the title); a diagonal dashed aim line runs up-left
to the matching blue bubble at the cluster's center, like a shot being lined up */
.bub-aim { position: absolute; left: 62%; bottom: 74px; width: 2px; height: 96px; transform-origin: bottom center; transform: translateX(-50%) rotate(-33deg);
background: repeating-linear-gradient(to top, rgba(255, 255, 255, 0.65) 0 5px, transparent 5px 11px); }
.bub-shooter { position: absolute; left: 62%; bottom: 46px; transform: translateX(-50%); width: 36px; height: 36px; border-radius: 50%; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24); }
.b-a { background: radial-gradient(circle at 32% 28%, #fff, #f0a830 62%); }
.b-b { background: radial-gradient(circle at 32% 28%, #fff, #e8607f 62%); }
.b-c { background: radial-gradient(circle at 32% 28%, #fff, #4fb0e0 62%); }
.b-d { background: radial-gradient(circle at 32% 28%, #fff, #6ad0a8 62%); }
@media (max-width: 880px) { .arcade { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px) { .arcade { grid-template-columns: 1fr; } }
/* Game-selection options */ /* Game-selection options */
.opts { display: flex; flex-direction: column; gap: 12px; max-width: 460px; } .opts { display: flex; flex-direction: column; gap: 12px; max-width: 460px; }
+188
View File
@@ -0,0 +1,188 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import HubShell from '$lib/components/HubShell.svelte';
let q = $state(null);
let state = $state('loading');
const MONTHS = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC'];
let seal = $derived.by(() => {
if (!q?.date) return null;
const [, m, d] = q.date.split('-').map(Number);
return MONTHS[m - 1] ? { mon: MONTHS[m - 1], day: d } : null;
});
// one attributed string for both Copy and Share, so native sharing keeps the author too
let attributed = $derived(q ? `“${q.text}”${q.author ? ` — ${q.author}${q.work ? ', ' + q.work : ''}` : ''}` : '');
let copied = $state(false);
async function copyQuote() {
try {
await navigator.clipboard.writeText(attributed);
copied = true; setTimeout(() => (copied = false), 1800);
} catch { /* no clipboard — silent */ }
}
let shared = $state(false);
async function share() {
const url = location.href;
try {
if (navigator.share) { await navigator.share({ title: 'Quote of the Day · upbeatBytes', text: attributed, url }); return; }
await navigator.clipboard.writeText(url);
shared = true; setTimeout(() => (shared = false), 1800);
} catch { /* cancelled / unsupported */ }
}
onMount(async () => {
try {
q = await getJSON('/api/quote/today');
state = q ? 'ready' : 'empty';
} catch {
state = 'empty';
}
});
</script>
<svelte:head>
<title>Quote of the Day · upbeatBytes</title>
<meta name="description" content="A hopeful quote every day — with its source and a plain-language take on what it means." />
</svelte:head>
<HubShell active="">
<article class="quote-page">
{#if state === 'ready'}
<!-- CD's "letter" treatment: warm card, dashed deckle frame, wax-seal date, faded quote-mark watermark -->
<div class="letter">
{#if seal}
<div class="seal" aria-hidden="true">
<span class="seal-mon">{seal.mon}</span>
<span class="seal-day">{seal.day}</span>
</div>
{/if}
<div class="deckle">
<span class="wm" aria-hidden="true"></span>
<div class="eyebrow">
<span class="eye-rule"></span>
<span class="eye-label">Quote of the Day</span>
</div>
<p class="quote">{q.text}</p>
{#if q.author}
<div class="attrib">
<span class="at-rule"></span>
<span class="at-by">{q.author}{#if q.work}, <span class="work">{q.work}</span>{/if}</span>
</div>
{/if}
{#if q.meaning}
<div class="hr"></div>
<div class="means-head">
<span class="mh-rule"></span>
<span class="mh-label">What it means</span>
</div>
<p class="meaning">{q.meaning}</p>
{/if}
<div class="actions">
<button class="act act-copy" onclick={copyQuote}>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></svg>
{copied ? 'Copied' : 'Copy'}
</button>
<button class="act act-share" onclick={share}>
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><path d="M8.6 13.5l6.8 4M15.4 6.5l-6.8 4"/></svg>
{shared ? 'Link copied' : 'Share'}
</button>
</div>
</div>
</div>
{:else if state === 'empty'}
<p class="note">Today's quote is on its way. Check back soon.</p>
{:else}
<p class="note">Finding today's quote…</p>
{/if}
</article>
</HubShell>
<style>
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-var.woff2') format('woff2'); font-weight: 500 700; font-style: normal; font-display: swap; }
@font-face { font-family: 'Playfair Display'; src: url('/fonts/playfair-italic-var.woff2') format('woff2'); font-weight: 500 700; font-style: italic; font-display: swap; }
.quote-page { max-width: 880px; margin: 0 auto; }
/* the card + its inner deckle frame */
.letter {
position: relative; margin-top: clamp(24px, 4vw, 40px); background: #f6ead0;
border: 1px solid rgba(120, 90, 40, 0.10); border-radius: 16px;
box-shadow: 0 26px 50px -32px rgba(90, 60, 30, 0.6); padding: clamp(12px, 1.6vw, 18px);
}
.deckle {
position: relative; border: 1.5px dashed rgba(170, 110, 135, 0.4); border-radius: 11px;
padding: clamp(28px, 5vw, 40px) clamp(24px, 6vw, 56px) clamp(36px, 6vw, 54px); overflow: hidden;
}
/* giant faded closing-quote watermark, bleeding off the bottom-right */
.wm {
position: absolute; right: clamp(-20px, -2vw, -30px); bottom: clamp(-70px, -12vw, -120px);
font-family: 'Playfair Display', Georgia, serif; font-size: clamp(220px, 46vw, 400px); line-height: 1;
color: rgba(170, 110, 135, 0.08); pointer-events: none; user-select: none;
}
/* wax-seal date stamp, overlapping the top-right corner */
.seal {
position: absolute; top: -26px; right: clamp(20px, 6vw, 52px); z-index: 2;
width: clamp(62px, 9vw, 74px); height: clamp(62px, 9vw, 74px); border-radius: 50%;
background: #fbf3e3; border: 1.5px solid rgba(170, 110, 135, 0.55);
box-shadow: 0 8px 20px -8px rgba(90, 60, 30, 0.5); transform: rotate(-8deg);
display: flex; flex-direction: column; align-items: center; justify-content: center; color: #a4607a;
}
.seal-mon { font-family: 'Hanken Grotesk', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.18em; }
.seal-day { font-family: 'Playfair Display', Georgia, serif; font-size: clamp(28px, 4vw, 33px); font-weight: 700; line-height: 0.82; }
.eyebrow { position: relative; display: flex; align-items: center; gap: 14px; }
.eye-rule { width: 34px; height: 3px; background: #8f5e72; border-radius: 2px; flex: none; }
.eye-label { font-family: 'Hanken Grotesk', sans-serif; font-size: clamp(17px, 2.4vw, 22px); font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #7e4f63; }
.quote {
position: relative; font-family: 'Playfair Display', Georgia, serif; font-style: italic; font-weight: 600;
font-size: clamp(1.7rem, 5vw, 2.85rem); line-height: 1.18; letter-spacing: -0.01em; color: #3c2530;
margin: clamp(18px, 3vw, 24px) 0 0; max-width: 34ch;
}
.attrib { position: relative; display: flex; align-items: center; gap: 14px; margin-top: clamp(20px, 3vw, 26px); }
.at-rule { width: 28px; height: 1.5px; background: #c79bac; flex: none; }
.at-by { font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.6vw, 1.13rem); color: #8b596d; }
.work { font-style: italic; }
.hr { position: relative; height: 1px; background: rgba(120, 90, 40, 0.16); margin: clamp(28px, 4vw, 38px) 0 24px; }
.means-head { position: relative; display: flex; align-items: center; gap: 12px; }
.mh-rule { width: 26px; height: 2px; background: #bcae93; border-radius: 2px; flex: none; }
.mh-label { font-family: 'Hanken Grotesk', sans-serif; font-size: 11px; font-weight: 700; letter-spacing: 0.13em; text-transform: uppercase; color: #74633f; }
.meaning {
position: relative; font-family: 'Newsreader', Georgia, serif; font-size: clamp(1rem, 1.8vw, 1.13rem);
line-height: 1.65; color: #5c5249; margin: 12px 0 0; max-width: 60ch;
}
.actions { position: relative; display: flex; gap: 12px; margin-top: clamp(26px, 4vw, 32px); flex-wrap: wrap; }
.act {
display: inline-flex; align-items: center; gap: 7px; font-family: 'Hanken Grotesk', sans-serif;
font-size: 14px; font-weight: 600; padding: 10px 18px; border-radius: 999px; cursor: pointer;
-webkit-tap-highlight-color: transparent; transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
/* AA contrast: white on #8b596d passes; rose text uses the accessible secondary */
.act-copy { background: #8b596d; color: #fff; border: none; }
.act-copy:hover { background: #774a5c; }
.act-share { background: transparent; color: #8b596d; border: 1px solid #d3b3c0; }
.act-share:hover { border-color: #8b596d; color: #774a5c; }
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
/* narrow phones: keep "QUOTE OF THE DAY" + its eyelash from crowding/clipping */
@media (max-width: 380px) {
.deckle { padding-left: clamp(18px, 6vw, 24px); padding-right: clamp(18px, 6vw, 24px); }
.eyebrow { gap: 9px; }
.eye-rule { width: 24px; }
.eye-label { letter-spacing: 0.08em; }
}
</style>
+156
View File
@@ -0,0 +1,156 @@
<script>
import { onMount } from 'svelte';
import { getJSON } from '$lib/api.js';
import HubShell from '$lib/components/HubShell.svelte';
let w = $state(null);
let state = $state('loading'); // loading | ready | empty
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : '');
const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
let dateLabel = $derived.by(() => {
if (!w?.date) return '';
const [, m, d] = w.date.split('-').map(Number);
return MONTHS[m - 1] ? `${MONTHS[m - 1]} ${d}` : '';
});
onMount(async () => {
try {
w = await getJSON('/api/word/today');
state = w ? 'ready' : 'empty';
} catch {
state = 'empty';
}
});
function speak() {
if (w?.audio_url) {
const a = new Audio(w.audio_url);
a.play().catch(ttsFallback);
} else {
ttsFallback();
}
}
function ttsFallback() {
try {
const u = new SpeechSynthesisUtterance(w.word);
u.rate = 0.9;
speechSynthesis.cancel();
speechSynthesis.speak(u);
} catch { /* no speech support — silent */ }
}
</script>
<svelte:head>
<title>{w ? cap(w.word) : 'Word of the Day'} · upbeatBytes</title>
<meta name="description" content="A new uplifting word every day — with pronunciation, meaning, and how to use it." />
</svelte:head>
<HubShell active="">
<article class="word-page">
{#if state === 'ready'}
<!-- "Editorial Asymmetric" (CD): faded oversized initial, vertical part-of-speech rail,
big serif word, airy definition, left-ruled example sentences. -->
<div class="wcard">
<span class="wm" aria-hidden="true">{cap(w.word)[0]}</span>
<div class="wtop">
<span class="eyebrow">Word of the day</span>
{#if dateLabel}<span class="edition">{dateLabel}</span>{/if}
</div>
<div class="whead">
{#if w.part_of_speech}<span class="vpos">{w.part_of_speech}</span>{/if}
<div class="whead-main">
<h1 class="word">{cap(w.word)}</h1>
<div class="pron-row">
{#if w.phonetic}<span class="pron">{w.phonetic}</span>{/if}
<button class="listen" onclick={speak} aria-label="Hear it spoken">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M11 5 6 9H3v6h3l5 4z" /><path d="M15.5 8.5a5 5 0 0 1 0 7" /><path d="M18.5 6a8 8 0 0 1 0 12" />
</svg>
Listen
</button>
</div>
</div>
</div>
<p class="definition">{w.definition}</p>
{#if w.examples?.length}
<section class="examples">
<div class="ex-label">In a sentence</div>
{#each w.examples as ex}<p class="ex">{ex}</p>{/each}
</section>
{/if}
</div>
{:else if state === 'empty'}
<p class="note">Today's word is on its way. Check back soon.</p>
{:else}
<p class="note">Finding today's word…</p>
{/if}
</article>
</HubShell>
<style>
.word-page { max-width: 760px; margin: 0 auto; }
.wcard {
position: relative; overflow: hidden; background: #FBF2E1; border: 1px solid #efe2c6;
border-radius: 18px; padding: clamp(32px, 6vw, 56px) clamp(26px, 5vw, 56px);
box-shadow: 0 18px 50px -34px rgba(80, 60, 25, 0.55);
}
/* faded oversized initial bleeding off the right edge */
/* sits JUST below the date in the top-right corner — clear it, don't drop far beneath */
.wm {
position: absolute; right: clamp(-60px, -4vw, -28px); top: clamp(45px, 7vw, 62px);
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-weight: 400;
font-size: clamp(220px, 42vw, 380px); line-height: 1; color: rgba(62, 110, 151, 0.06);
pointer-events: none; user-select: none;
}
.wtop { position: relative; display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
.eyebrow { font-size: 12px; font-weight: 700; letter-spacing: 0.22em; text-transform: uppercase; color: #3E6E97; }
.edition { font-family: 'Newsreader', Georgia, serif; font-size: 15px; color: #a99a80; white-space: nowrap; }
.whead { position: relative; display: flex; align-items: flex-start; gap: clamp(14px, 2.5vw, 22px); margin-top: clamp(28px, 5vw, 46px); }
.vpos {
writing-mode: vertical-rl; transform: rotate(180deg); flex: none; padding-top: 6px;
font-size: 13px; font-weight: 700; letter-spacing: 0.3em; text-transform: uppercase; color: #5f86a6;
}
.whead-main { min-width: 0; }
.word {
font-family: 'Newsreader', Georgia, serif; font-weight: 400; letter-spacing: -0.02em;
font-size: clamp(3.4rem, 13vw, 108px); line-height: 0.92; color: #1c1a16; margin: 0; overflow-wrap: anywhere;
}
.pron-row { display: flex; align-items: center; flex-wrap: wrap; gap: 14px; margin-top: 18px; }
.pron { font-family: 'Newsreader', Georgia, serif; font-size: 23px; color: #8a8478; }
/* Always filled blue — a little warmth of colour on a laid-back page. Hover is gated to
true pointer devices so it can't "stick" lit after a tap on a phone, and the tap
highlight box is suppressed (that grey/blue flash mobile draws on tap). */
.listen {
display: inline-flex; align-items: center; gap: 7px; cursor: pointer; font-family: inherit;
font-size: 0.82rem; font-weight: 600; letter-spacing: 0.02em; color: #fdf7ec;
background: #3E6E97; border: 1px solid #3E6E97; border-radius: 999px; padding: 7px 15px;
transition: background 0.15s ease;
-webkit-tap-highlight-color: transparent; -webkit-user-select: none; user-select: none;
}
@media (hover: hover) { .listen:hover { background: #34608a; border-color: #34608a; } }
.listen:active { background: #2c557d; border-color: #2c557d; }
.definition {
position: relative; font-family: 'Newsreader', Georgia, serif; font-weight: 400;
font-size: clamp(1.4rem, 4vw, 1.95rem); line-height: 1.32; color: #2c3640;
margin: clamp(30px, 5vw, 44px) 0 0; max-width: 30em;
}
.examples { position: relative; margin-top: clamp(28px, 5vw, 40px); max-width: 34em; }
.ex-label { font-size: 11px; font-weight: 700; letter-spacing: 0.16em; text-transform: uppercase; color: #b3a487; margin-bottom: 16px; }
.ex {
font-family: 'Newsreader', Georgia, serif; font-style: italic; font-size: 1.08rem; line-height: 1.5;
color: #4a4439; padding-left: 16px; border-left: 2px solid #9bb6cf; margin: 0 0 14px;
}
.ex:last-child { margin-bottom: 0; }
.note { text-align: center; color: var(--muted); font-size: 1.05rem; margin-top: 60px; }
</style>
+192
View File
@@ -0,0 +1,192 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
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);
let loading = $state(true);
// Live tuning panel (admins, /zen?debug=1) — dial in the render without redeploys.
let handle = $state(null);
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() {
try {
await navigator.clipboard.writeText(JSON.stringify($state.snapshot(dbg), null, 2));
copied = true; setTimeout(() => (copied = false), 1500);
} catch { /* clipboard blocked — values are still visible in the panel */ }
}
onMount(() => {
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');
if (!t.getContext('webgl2') && !t.getContext('webgl')) throw new Error('no-webgl');
const { createAquarium } = await import('$lib/zen/aquarium.js'); // lazy: three loads only here
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();
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; clearInterval(statTimer); h?.dispose(); };
});
</script>
<svelte:head>
<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="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>
</div>
</header>
<main class="zen container">
<h1>The Zen Den</h1>
<p class="sub">Drop in with UB for a quiet minute.</p>
<div class="tankwrap">
{#if failed}
<div class="fallback">
<div class="fish">🐟</div>
<p>UB's tank needs a browser with WebGL. He's resting for now — try again on a different device or browser.</p>
</div>
{:else}
<canvas bind:this={canvas} class="tank" aria-label="UB the koi, swimming"></canvas>
{#if loading}<p class="loadnote">UB is settling in…</p>{/if}
{/if}
</div>
{#if debug && dbg}
<div class="panel">
<div class="prow"><strong>UB render tuner</strong><button class="copy" onclick={copyValues}>{copied ? 'copied ✓' : 'copy values'}</button></div>
<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">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 &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>alphaTest <span>{dbg.finAlphaTest.toFixed(3)}</span>
<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</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; }
.logo { height: 40px; display: block; }
.back { color: var(--accent-deep); font-size: 0.9rem; display: inline-flex; align-items: center; gap: 5px; }
.back svg { width: 17px; height: 17px; display: block; }
.zen { padding: 22px 20px 60px; }
h1 { font-size: clamp(2rem, 5vw, 2.6rem); margin: 6px 0 4px; }
.sub { color: var(--muted); margin: 0 0 18px; }
/* A calm tank: soft aqua gradient backdrop; the transparent WebGL canvas sits
on top so the water reads even before we build the real bowl (Phase C). */
.tankwrap {
position: relative; width: 100%; max-width: 640px; aspect-ratio: 4 / 3;
border-radius: 20px; overflow: hidden; box-shadow: var(--shadow);
background: radial-gradient(120% 100% at 50% 0%, #d6f0f2 0%, #aadbe0 45%, #7cc3cc 100%);
}
.tank { display: block; width: 100%; height: 100%; }
.loadnote { position: absolute; inset: auto 0 16px 0; text-align: center; color: var(--accent-deep);
font-family: var(--label); font-size: 0.9rem; }
.fallback { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center;
justify-content: center; gap: 12px; text-align: center; padding: 24px; color: #2b5560; }
.fallback .fish { font-size: 3rem; }
.fallback p { max-width: 340px; margin: 0; }
/* Dev tuning panel (admins only, ?debug=1). */
.panel { margin-top: 18px; max-width: 360px; padding: 14px 16px; border: 1px solid var(--line);
border-radius: 14px; background: var(--surface); font-size: 0.82rem; }
.prow { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.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; }
.panel input[type="range"] { width: 100%; margin-top: 3px; }
.panel select { width: 100%; margin-top: 3px; }
.copy { font-size: 0.75rem; padding: 4px 9px; border: 1px solid var(--line); border-radius: 8px;
background: var(--bg); color: var(--accent-deep); cursor: pointer; }
@media (max-width: 720px) {
.tankwrap { aspect-ratio: 3 / 4; } /* taller on phones */
}
</style>
+13
View File
@@ -0,0 +1,13 @@
manrope-var.woff2 — "Manrope" variable font by Mikhail Sharanda / Mirko Velimirovic.
License: SIL Open Font License 1.1 (free to use/bundle, no attribution required in product UI).
Source: https://github.com/sharanda/manrope (via fontsource).
hanken-var.woff2 — "Hanken Grotesk" by Alfredo Marco Pradil. License: SIL Open Font License 1.1.
newsreader-var.woff2 / newsreader-italic-var.woff2 — "Newsreader" by Production Type (Google Fonts).
License: SIL Open Font License 1.1. All self-hosted (no Google hotlink) for the /home3 design direction.
Work Sans — Wei Huang, OFL 1.1 (Google Fonts). Latin subset, variable 400700.
Space Mono — Colophon Foundry, OFL 1.1 (Google Fonts). Latin subset, 400.
Playfair Display — Claus Eggers Sørensen, OFL 1.1 (Google Fonts). Latin subset, var 500700 + italic. (QOTD)
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"name": "Upbeat Bytes", "name": "upbeatBytes",
"short_name": "Upbeat Bytes", "short_name": "upbeatBytes",
"description": "Calm, constructive news worth your attention — and nothing that isn't.", "description": "A calmer, brighter corner of the internet: good news, daily art, small games, and little resets.",
"start_url": "/", "start_url": "/",
"scope": "/", "scope": "/",
"display": "standalone", "display": "standalone",
Binary file not shown.
+4
View File
@@ -0,0 +1,4 @@
wood-grain.jpg — "Fine Grained Wood" by Rob Tuytel, Poly Haven (https://polyhaven.com/a/fine_grained_wood)
License: CC0 (Public Domain). Free to use, no attribution required. Recolored in CSS per frame species.
wood-grain-h.jpg — the same texture rotated 90° (horizontal grain), for the top/bottom rails so the grain
turns at each mitered corner. Same CC0 source/license.
Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 328 KiB

+873 -55
View File
File diff suppressed because it is too large Load Diff
+276
View File
@@ -0,0 +1,276 @@
"""Daily Art — the /art room. One gorgeous public-domain masterwork a day, picked from
a curated pool of museum highlights and cached to OUR origin (image + metadata), so the
homepage never waits on, nor hotlinks, the museum.
Source: The Met Collection API (no key; public-domain works are CC0 free, unrestricted,
caching encouraged). Curation = isHighlight + isPublicDomain + hasImages, so the pool is
masterworks, never potsherds. Built to be bulletproof: a failed pick falls through to the
next candidate, and a failed day keeps yesterday's piece — the room is never empty.
Network calls go through module-level _http_* helpers so tests can monkeypatch them.
"""
from __future__ import annotations
import hashlib
import json
import os
import sqlite3
import urllib.error
import urllib.request
from pathlib import Path
from .localtime import local_today
MET_BASE = "https://collectionapi.metmuseum.org/public/collection/v1"
# Broad, visual, museum-grade terms. Each is filtered to public-domain highlights with
# images, then deduped — a diverse pool of a few thousand masterworks.
HARVEST_QUERIES = ("painting", "portrait", "landscape", "still life", "flowers",
"sculpture", "drawing", "garden", "river", "sunset")
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
_PICK_ATTEMPTS = 8 # candidates to try before giving up for the day
_NO_REPEAT_POOL = 40 # pick the daily piece from the N least-recently-shown
_MIN_IMAGE_BYTES = 3000 # smaller than this = not a real image
def _http_json(url: str, timeout: int = 20) -> dict:
req = urllib.request.Request(url, headers=_UA)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
def _http_bytes(url: str, timeout: int = 30) -> tuple[bytes, str]:
req = urllib.request.Request(url, headers=_UA)
with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read(), (r.headers.get("Content-Type") or "")
def cache_dir() -> Path:
"""Where cached images live — beside the DB, so the host cycle writes and the API
container reads the same mounted volume."""
override = os.environ.get("GOODNEWS_ART_CACHE")
if override:
d = Path(override)
else:
db = Path(os.environ.get("GOODNEWS_DB", "data/goodnews.sqlite3"))
d = db.parent / "art_cache"
d.mkdir(parents=True, exist_ok=True)
return d
# --- harvest: build the curated pool of object IDs -------------------------------
def harvest_pool(conn: sqlite3.Connection, queries=HARVEST_QUERIES, source: str = "met") -> dict:
"""Query the Met for public-domain highlight images across broad art terms; store the
deduped object IDs. Cheap: each search returns all matching IDs in one call. Per-query
failure is non-fatal."""
found, errors = set(), 0
for q in queries:
url = (f"{MET_BASE}/search?isHighlight=true&hasImages=true&isPublicDomain=true"
f"&q={urllib.request.quote(q)}")
try:
data = _http_json(url)
for oid in (data.get("objectIDs") or []):
if isinstance(oid, int):
found.add(oid)
except Exception: # noqa: BLE001 — non-fatal per query
errors += 1
before = conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0]
conn.executemany(
"INSERT OR IGNORE INTO art_pool (source, object_id) VALUES (?, ?)",
[(source, oid) for oid in found],
)
conn.commit()
after = conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0]
return {"queried": len(queries), "errors": errors, "found": len(found),
"added": after - before, "pool": after}
# --- daily pick: choose, fetch, cache --------------------------------------------
def _object(object_id: int) -> dict:
return _http_json(f"{MET_BASE}/objects/{object_id}")
def _fetch_to_cache(url: str | None, stem: str) -> str | None:
"""Download one image URL to cache as {stem}.{ext}; return the filename or None.
Writes to a temp file then atomically renames, so a reader never sees a half-file."""
if not url:
return None
try:
data, ctype = _http_bytes(url)
except Exception: # noqa: BLE001
return None
if not ctype.startswith("image/") or len(data) < _MIN_IMAGE_BYTES:
return None
ext = ".png" if "png" in ctype else ".jpg"
fname = f"{stem}{ext}"
cdir = cache_dir()
tmp = cdir / f".{stem}.tmp"
try:
tmp.write_bytes(data)
os.replace(tmp, cdir / fname) # atomic
except OSError:
try:
tmp.unlink()
except OSError:
pass
return None
return fname
def _download_image(obj: dict, object_id: int) -> str | None:
"""Cache the day's images to our origin. Stores the web-large display copy as
{id}.{ext} (what the page shows) and, when available, the full-resolution copy as
{id}-full.{ext} (what the lightbox opens, so zoom fills the screen). Returns the
display filename or None if even the display copy couldn't be fetched."""
display = _fetch_to_cache(obj.get("primaryImageSmall") or obj.get("primaryImage"), str(object_id))
if not display:
return None
full_url = obj.get("primaryImage")
if full_url and full_url != obj.get("primaryImageSmall"):
_fetch_to_cache(full_url, f"{object_id}-full") # best-effort hi-res for zoom
return display
def _palette(image_path: "Path", n: int = 5) -> list[str]:
"""Extract ~n representative hex colors from the cached image (for the 'colors in this
piece' strip). Best-effort: any failure → empty list (the strip just hides)."""
try:
from PIL import Image
with Image.open(image_path) as im:
im = im.convert("RGB")
im.thumbnail((120, 120)) # tiny — palette, not fidelity
# Adaptive median-cut to a small palette, then order by how much of the image each covers.
q = im.quantize(colors=max(n * 2, 8), method=Image.Quantize.MEDIANCUT)
pal = q.getpalette()
counts = sorted(q.getcolors(), reverse=True) # [(count, index), ...] most-used first
out, seen = [], set()
for _count, idx in counts:
r, g, b = pal[idx * 3], pal[idx * 3 + 1], pal[idx * 3 + 2]
hexc = f"#{r:02x}{g:02x}{b:02x}"
if hexc in seen:
continue
seen.add(hexc)
out.append(hexc)
if len(out) >= n:
break
return out
except Exception: # noqa: BLE001 — palette is decorative; never break the pick
return []
_BLURB_SYSTEM = (
"You are the calm, knowledgeable curator of a daily-art feature for a general audience — "
"people who enjoy a beautiful painting but aren't art historians. In 2 to 3 warm, plain "
"sentences, help them appreciate the piece and why it's worth a moment: its mood, the "
"artist, the era or movement, and a little real context or significance.\n"
"GROUNDING (important): the catalogue details below — especially the title and the "
"'Depicts' tags — are your only reliable guide to the SUBJECT. You cannot actually see the "
"image, so do NOT assert literal visual specifics you can't verify: do not state how many "
"figures are shown, their exact poses or actions, colors, or background details. Lean on "
"what's certain (title, tags, medium, date, artist, movement) and on feeling/significance. "
"If you don't recognize the exact work, stay general and contextual rather than inventing. "
"No preamble, no title repetition, no hype, no markdown — just the note."
)
def _blurb(client, obj: dict) -> str | None:
"""A short 'museum guide' note for the piece, grounded in the Met catalogue metadata.
Best-effort + cached by the caller; returns None on any trouble."""
tags = ", ".join(t.get("term", "") for t in (obj.get("tags") or []) if t.get("term"))[:200]
facts = "\n".join(f"{k}: {v}" for k, v in (
("Title", obj.get("title")), ("Artist", obj.get("artistDisplayName")),
("Artist bio", obj.get("artistDisplayBio")), ("Date", obj.get("objectDate")),
("Medium", obj.get("medium")), ("Type", obj.get("objectName")),
("Classification", obj.get("classification")), ("Culture", obj.get("culture")),
("Period", obj.get("period")), ("Depicts", tags),
) if v)
user = f"Catalogue details:\n{facts}\n\nWrite the note."
try:
out = client.chat_text([{"role": "system", "content": _BLURB_SYSTEM},
{"role": "user", "content": user}]) or ""
except Exception: # noqa: BLE001
return None
out = " ".join(out.replace("*", "").replace("_", " ").split()).strip()[:600] # no stray markdown
return out or None
def _candidates(conn: sqlite3.Connection, art_date: str, source: str) -> list[int]:
"""The N least-recently-shown pool IDs, rotated deterministically by the date so the
same piece shows for everyone that day and pieces don't repeat soon."""
rows = conn.execute(
"SELECT object_id FROM art_pool WHERE source=? AND blocked=0 "
"ORDER BY shown_at IS NOT NULL, shown_at, object_id LIMIT ?",
(source, _NO_REPEAT_POOL),
).fetchall()
ids = [r[0] for r in rows]
if not ids:
return ids
seed = int(hashlib.sha256(art_date.encode()).hexdigest(), 16) % len(ids)
return ids[seed:] + ids[:seed] # rotate so the daily choice is stable but varies
def pick_daily(conn: sqlite3.Connection, art_date: str | None = None, source: str = "met",
force: bool = False, client=None) -> dict | None:
"""Pick + cache the day's art. Idempotent (skips if today's already done unless force).
Tries successive candidates so a bad object/image never breaks the day; returns the
stored row, or None if nothing could be fetched (caller keeps the prior day's piece)."""
art_date = art_date or local_today()
existing = conn.execute("SELECT * FROM daily_art WHERE art_date=?", (art_date,)).fetchone()
if existing and not force:
return dict(existing)
for oid in _candidates(conn, art_date, source):
try:
obj = _object(oid)
except Exception: # noqa: BLE001
continue
if not obj.get("isPublicDomain"):
continue
fname = _download_image(obj, oid)
if not fname:
continue
# All network/LLM/compute is done up front; only then a brief write txn + commit.
palette = json.dumps(_palette(cache_dir() / fname)) or None
blurb = _blurb(client, obj) if client else None
conn.execute(
"INSERT INTO daily_art (art_date, source, object_id, title, artist, date_text, medium, "
"department, credit, source_url, image_file, image_url_full, is_public_domain, blurb, palette) "
"VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) "
"ON CONFLICT(art_date) DO UPDATE SET object_id=excluded.object_id, title=excluded.title, "
"artist=excluded.artist, date_text=excluded.date_text, medium=excluded.medium, "
"department=excluded.department, credit=excluded.credit, source_url=excluded.source_url, "
"image_file=excluded.image_file, image_url_full=excluded.image_url_full, "
"is_public_domain=excluded.is_public_domain, blurb=excluded.blurb, palette=excluded.palette",
(art_date, source, oid, obj.get("title") or "Untitled",
obj.get("artistDisplayName") or None, obj.get("objectDate") or None,
obj.get("medium") or None, obj.get("department") or None,
obj.get("creditLine") or None, obj.get("objectURL") or None, fname,
obj.get("primaryImage") or None, 1 if obj.get("isPublicDomain") else 0, blurb, palette),
)
conn.execute("UPDATE art_pool SET shown_at=? WHERE source=? AND object_id=?",
(art_date, source, oid))
conn.commit()
return dict(conn.execute("SELECT * FROM daily_art WHERE art_date=?", (art_date,)).fetchone())
return None # nothing fetched today — get_today falls back to the latest piece
def get_today(conn: sqlite3.Connection, art_date: str | None = None) -> dict | None:
"""Today's piece if present, else the most recent one cached (room is never empty)."""
if art_date:
row = conn.execute("SELECT * FROM daily_art WHERE art_date=?", (art_date,)).fetchone()
if row:
return dict(row)
row = conn.execute("SELECT * FROM daily_art ORDER BY art_date DESC LIMIT 1").fetchone()
return dict(row) if row else None
def run_daily(conn: sqlite3.Connection, source: str = "met", client=None) -> dict:
"""Cycle entry point: ensure the pool exists, then ensure today has a piece. Bounded
and non-fatal safe to call every cycle (it no-ops once the day is picked)."""
pool = conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0]
harvested = None
if pool == 0:
harvested = harvest_pool(conn, source=source)
picked = pick_daily(conn, source=source, client=client)
return {"pool": conn.execute("SELECT COUNT(*) FROM art_pool WHERE source=?", (source,)).fetchone()[0],
"harvested": harvested, "picked_object": picked.get("object_id") if picked else None}
+317
View File
@@ -0,0 +1,317 @@
"""Bloom — the daily word wheel (Center Circle / Wild Bloom).
DESIGN and ACCEPTANCE are decoupled:
DESIGN (wheel selection, tiers, pangram, the Full-Bloom target) uses the small
COMMON list only deterministic, stored in daily_puzzles, and unaffected by
curation. Tiers are scored on COMMON so "Flourishing" is always reachable with
everyday vocabulary, and "Full Bloom" = finding the whole *designed* puzzle
(the broad bonus words are extra credit beyond it, never required).
ACCEPTANCE is BROAD and DYNAMIC every valid dictionary word buildable from
the wheel, computed at RESPONSE TIME as: broad dict {allow} {block}, where
allow/block are runtime admin overrides (bloom_word_overrides). So a missed
word can be allowed (or a junk word blocked) with NO deploy or regeneration.
Accept words never sit in the network response: clients validate against salted
hashes and compute their own score/tier/pangram from the 7 letters.
"""
from __future__ import annotations
import hashlib
import json
import random
import sqlite3
from itertools import combinations
from pathlib import Path
_DATA = Path(__file__).parent / "data"
_W = json.loads((_DATA / "bloom_words.json").read_text())
ACCEPT: list[str] = _W["accept"] # broad: all valid dictionary words
_COMMON: set[str] = set(_W["common"]) # tight: design / tiers / pangrams only
_COMMON_LS: list[tuple[str, frozenset]] = [(w, frozenset(w)) for w in _COMMON]
_AVOID: set[str] = set(json.loads((_DATA / "bloom_avoid.json").read_text()))
# Broad accept words bucketed by distinct-letter set, so the accepted set for a
# 7-letter wheel is gathered by unioning its ≤127 letter-subsets (fast) — no scan
# of the whole ~68k list per request.
_BY_SET: dict[frozenset, list[str]] = {}
for _w in ACCEPT:
_BY_SET.setdefault(frozenset(_w), []).append(_w)
# Candidate wheels = letter-sets of 7-distinct-letter COMMON words (every wheel
# has ≥1 recognizable pangram). Sorted for deterministic order.
_PANGRAM_SETS: dict[frozenset, list[str]] = {}
for _w in _COMMON:
_s = frozenset(_w)
if len(_s) == 7:
_PANGRAM_SETS.setdefault(_s, []).append(_w)
_CANDIDATES: list[frozenset] = sorted(_PANGRAM_SETS, key=lambda s: "".join(sorted(s)))
MIN_COMMON_WORDS, MAX_COMMON_WORDS = 14, 45
PANGRAM_BONUS = 7
# 8 / 30 / 70 — Flourishing at 70% keeps Bloom from becoming a completionist
# grind. Do NOT raise Flourishing above 0.70 (Codex).
TIER_PCTS: tuple[tuple[str, float], ...] = (
("Sprouting", 0.0), ("Budding", 0.08), ("Blooming", 0.30), ("Flourishing", 0.70),
)
TOP_TIER_PCT = 0.70
def score_word(word: str) -> int:
"""4-letter word = 1 point; longer = its length. Pangram bonus added on top."""
return 1 if len(word) == 4 else len(word)
def score_words(payload: dict, words) -> int:
"""Score found words for a wheel (pangram = uses all 7 letters). Used for the
player's running score AND the Full-Bloom check (vs the design's max_score)."""
letters = frozenset(payload["center"]) | frozenset(payload["outer"])
total = 0
for w in words:
total += score_word(w)
if frozenset(w) == letters:
total += PANGRAM_BONUS
return total
# --- DESIGN: common-only, deterministic, stored --------------------------------
def tiers_for(common_max: int) -> list[dict]:
return [{"name": n, "score": int(p * common_max)} for n, p in TIER_PCTS]
def _design(letters: frozenset, center: str):
"""Center-mode design from the COMMON list only."""
commons = [w for (w, s) in _COMMON_LS if center in w and s <= letters]
pangrams = [w for w in commons if frozenset(w) == letters]
common_max = sum(score_word(w) for w in commons) + PANGRAM_BONUS * len(pangrams)
display = sorted((p for p in pangrams if p not in _AVOID), key=lambda p: (len(p), p))
return commons, display, common_max
def _design_wild(letters: frozenset):
"""Wild design (no required center) from the COMMON list only."""
commons = [w for (w, s) in _COMMON_LS if s <= letters]
pangrams = [w for w in commons if frozenset(w) == letters]
common_max = sum(score_word(w) for w in commons) + PANGRAM_BONUS * len(pangrams)
display = sorted((p for p in pangrams if p not in _AVOID), key=lambda p: (len(p), p))
vowels = [c for c in sorted(letters) if c in "aeiou"]
return commons, display, common_max, (vowels[0] if vowels else sorted(letters)[0])
def _payload(letters: frozenset, center: str, display, common_max: int) -> dict:
return {
"center": center,
"outer": sorted(letters - {center}),
"pangram": display[0],
"tiers": tiers_for(common_max),
# Full Bloom = finding the whole designed (common) puzzle; broad bonus
# words push score past this but are never required.
"max_score": common_max,
}
def _generate(seed_str: str, fmt: str) -> dict:
"""Deterministically pick a wheel design for a seed + format."""
rng = random.Random(int(hashlib.sha256(seed_str.encode()).hexdigest(), 16))
order = _CANDIDATES[:]
rng.shuffle(order)
for letters in order:
if fmt == "wild":
commons, display, cmax, center = _design_wild(letters)
if len(commons) >= MIN_COMMON_WORDS and display:
return _payload(letters, center, display, cmax)
else:
centers = sorted(letters)
rng.shuffle(centers)
for center in centers:
commons, display, cmax = _design(letters, center)
if MIN_COMMON_WORDS <= len(commons) <= MAX_COMMON_WORDS and display:
return _payload(letters, center, display, cmax)
raise RuntimeError("bloom: no valid wheel found") # impossible with the vendored dict
def build_puzzle(date: str) -> dict:
"""The day's shared Center Circle wheel design (deterministic by date)."""
return {"date": date, **_generate(f"bloom:{date}", "center")}
def build_free(seed: str, fmt: str = "center") -> dict:
"""A free-play wheel design (deterministic by seed) — Center Circle or Wild."""
fmt = "wild" if fmt == "wild" else "center"
return {"seed": seed, "format": fmt, **_generate(f"free:{fmt}:{seed}", fmt)}
# --- ACCEPTANCE: broad + runtime overrides, computed at response time ----------
def overrides(conn: sqlite3.Connection) -> tuple[set, set]:
allow, block = set(), set()
for r in conn.execute("SELECT word, action FROM bloom_word_overrides"):
(allow if r["action"] == "allow" else block).add(r["word"])
return allow, block
def _broad_words_for(letters: frozenset) -> list[str]:
"""Every broad-dictionary word buildable from `letters` (distinct-set ⊆ letters)."""
ls = sorted(letters)
out = []
for r in range(1, len(ls) + 1):
for combo in combinations(ls, r):
out.extend(_BY_SET.get(frozenset(combo), ()))
return out
def accepted_words(conn: sqlite3.Connection, center: str, outer, require_center: bool) -> list[str]:
"""The wheel's accepted set RIGHT NOW: broad words buildable from the letters
(optionally requiring the center), plus allow-overrides, minus block-overrides."""
letters = frozenset(outer) | {center}
allow, block = overrides(conn)
seen, out = set(), []
for w in _broad_words_for(letters):
if w in seen or w in block:
continue
if require_center and center not in w:
continue
seen.add(w)
out.append(w)
for w in allow: # allow words that may not be in the broad dict
if w in seen or w in block or len(w) < 4 or "s" in w:
continue
if not (frozenset(w) <= letters) or (require_center and center not in w):
continue
seen.add(w)
out.append(w)
return sorted(out)
# --- daily_puzzles storage -----------------------------------------------------
def generate_bloom_puzzle(conn: sqlite3.Connection, date: str) -> dict:
"""Ensure the day's Bloom DESIGN exists in daily_puzzles. Idempotent, pure code."""
existing = conn.execute(
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
).fetchone()
if existing:
return json.loads(existing["payload_json"])
payload = build_puzzle(date)
conn.execute(
"INSERT OR IGNORE INTO daily_puzzles (puzzle_date, game, variant, payload_json) VALUES (?, 'bloom', '', ?)",
(date, json.dumps(payload)),
)
conn.commit()
row = conn.execute(
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
).fetchone()
return json.loads(row["payload_json"])
def stored_payload(conn: sqlite3.Connection, date: str) -> dict | None:
"""The day's design IF it already exists — never generates (used by the state
sanitizer, which must not trigger generation)."""
row = conn.execute(
"SELECT payload_json FROM daily_puzzles WHERE puzzle_date=? AND game='bloom' AND variant=''", (date,)
).fetchone()
return json.loads(row["payload_json"]) if row else None
def word_hash(salt: str, word: str) -> str:
return hashlib.sha256(f"{salt}:{word}".encode()).hexdigest()
def _response(salt: str, p: dict, words: list[str], extra: dict) -> dict:
return {
"game": "bloom",
"center": p["center"],
"outer": p["outer"],
"accepted": [word_hash(salt, w) for w in words], # NO plaintext words leak
"max_score": p["max_score"], # Full Bloom = designed puzzle
"tiers": p["tiers"],
**extra,
}
def bloom_response(conn: sqlite3.Connection, date: str) -> dict:
"""Daily Center Circle — accepted set computed live (broad + overrides)."""
p = generate_bloom_puzzle(conn, date)
words = accepted_words(conn, p["center"], p["outer"], require_center=True)
return _response(date, p, words, {"date": date})
def bloom_free_response(conn: sqlite3.Connection, seed: str, fmt: str) -> dict:
"""Free-play wheel keyed by `seed` (resumable). Accepted set computed live."""
p = build_free(seed, fmt)
words = accepted_words(conn, p["center"], p["outer"], require_center=p["format"] != "wild")
return _response(seed, p, words, {"mode": "free", "format": p["format"], "seed": p["seed"]})
# --- runtime curation: overrides + player reports ------------------------------
def set_override(conn: sqlite3.Connection, word: str, action: str, reason: str | None = None,
by: str | None = None) -> bool:
word = (word or "").strip().lower()
if not (word.isalpha() and action in ("allow", "block")):
return False
# An ALLOW that violates Bloom's hard rules (≥4 letters, no 'S') could never
# count — reject it rather than store an inert override. BLOCK stays permissive.
if action == "allow" and (len(word) < 4 or "s" in word):
return False
conn.execute(
"INSERT INTO bloom_word_overrides (word, action, reason, created_by) VALUES (?,?,?,?) "
"ON CONFLICT(word) DO UPDATE SET action=excluded.action, reason=excluded.reason, "
"created_by=excluded.created_by, created_at=CURRENT_TIMESTAMP",
(word, action, reason, by),
)
conn.commit()
return True
def clear_override(conn: sqlite3.Connection, word: str) -> None:
conn.execute("DELETE FROM bloom_word_overrides WHERE word=?", ((word or "").strip().lower(),))
conn.commit()
def list_overrides(conn: sqlite3.Connection) -> list[dict]:
return [dict(r) for r in conn.execute(
"SELECT word, action, reason, created_by, created_at FROM bloom_word_overrides ORDER BY created_at DESC")]
def add_report(conn: sqlite3.Connection, word: str, puzzle_date, mode, fmt, letters, reason) -> bool:
word = (word or "").strip().lower()
if not (word.isalpha() and 4 <= len(word) <= 24):
return False
# Don't pile up duplicate pending reports for the same word.
dup = conn.execute(
"SELECT 1 FROM bloom_word_reports WHERE word=? AND status='pending'", (word,)).fetchone()
if dup:
return True
conn.execute(
"INSERT INTO bloom_word_reports (word, puzzle_date, mode, format, letters, reason) "
"VALUES (?,?,?,?,?,?)",
(word, str(puzzle_date or "")[:16], str(mode or "")[:8], str(fmt or "")[:8],
str(letters or "")[:16], str(reason or "")[:60]),
)
conn.commit()
return True
def list_reports(conn: sqlite3.Connection, status: str = "pending", limit: int = 100) -> list[dict]:
return [dict(r) for r in conn.execute(
"SELECT id, word, puzzle_date, mode, format, letters, reason, status, created_at "
"FROM bloom_word_reports WHERE status=? ORDER BY created_at DESC LIMIT ?", (status, limit))]
def resolve_report(conn: sqlite3.Connection, report_id: int, action: str, by: str | None = None) -> bool:
"""action: 'approve' (→ allow override) | 'block' (→ block override) | 'dismiss'."""
status = {"approve": "approved", "block": "blocked", "dismiss": "dismissed"}.get(action)
row = conn.execute("SELECT word FROM bloom_word_reports WHERE id=?", (report_id,)).fetchone()
if not row or not status:
return False
if action == "approve":
if not set_override(conn, row["word"], "allow", reason="report", by=by):
return False # can't allow (hard rule) — leave pending; dismiss instead
elif action == "block":
set_override(conn, row["word"], "block", reason="report", by=by)
conn.execute("UPDATE bloom_word_reports SET status=? WHERE id=?", (status, report_id))
conn.commit()
return True
+13 -8
View File
@@ -3,7 +3,8 @@ from __future__ import annotations
import sqlite3 import sqlite3
from .localtime import local_today from .localtime import local_today
from .paywall import is_paywalled from .paywall import is_paywalled, is_paywalled_for_source
from .queries import paywalled_source_ids
def build_daily_brief( def build_daily_brief(
@@ -15,11 +16,10 @@ def build_daily_brief(
) -> int: ) -> int:
target_date = brief_date or local_today() target_date = brief_date or local_today()
# Compose the selection first so we can tell whether anything actually # Compose the selection first so we can tell whether anything actually changed.
# changed. A calm daily brief shouldn't repeatedly hand the reader a locked # A calm daily brief never hands the reader a locked door: paywalled-source
# door: push paywalled candidates below readable ones (stable sort) first. # candidates are excluded in _candidate_articles (before LIMIT) — no unreadable news.
rows = _candidate_articles(conn, target_date, window_days) rows = _candidate_articles(conn, target_date, window_days)
rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"]))
selected = _select_diverse(rows, limit) selected = _select_diverse(rows, limit)
selected_ids = [row["id"] for row in selected] 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 `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 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 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( return conn.execute(
""" f"""
SELECT SELECT
a.id, a.id,
a.title, a.title,
@@ -121,6 +124,7 @@ def _candidate_articles(
src.name AS source_name, src.name AS source_name,
src.default_category, src.default_category,
src.trust_score, src.trust_score,
src.paywall_override AS paywall_override,
s.constructive_score, s.constructive_score,
s.cortisol_score, s.cortisol_score,
s.ragebait_score, s.ragebait_score,
@@ -151,6 +155,7 @@ def _candidate_articles(
AND b.brief_date <= date(?) AND b.brief_date <= date(?)
AND b.brief_date > date(?, '-7 days') AND b.brief_date > date(?, '-7 days')
) )
{pw_clause}
ORDER BY ORDER BY
is_today DESC, is_today DESC,
(s.constructive_score + s.agency_score + s.human_benefit_score + src.trust_score (s.constructive_score + s.agency_score + s.human_benefit_score + src.trust_score
@@ -158,7 +163,7 @@ def _candidate_articles(
COALESCE(a.published_at, a.discovered_at) DESC COALESCE(a.published_at, a.discovered_at) DESC
LIMIT 50 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() ).fetchall()
+162 -17
View File
@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import contextlib
import os import os
import sqlite3 import sqlite3
from pathlib import Path from pathlib import Path
@@ -10,8 +11,10 @@ from .db import connect, init_db
from .digest import send_due_digests from .digest import send_due_digests
from .games import generate_daily_puzzles from .games import generate_daily_puzzles
from .localtime import local_today from .localtime import local_today
from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, dedup as run_dedup from .dedup import DEFAULT_THRESHOLD, DEFAULT_WINDOW_DAYS, cluster_duplicates, dedup as run_dedup
from .enrich import enrich_brief_images, enrich_recent_images, enrich_summarized_images from .geo import tag_articles as tag_geo
from . import art, newsimg, onthisday, quote, wotd
from .enrich import enrich_brief_images, enrich_read_times, enrich_recent_images, enrich_summarized_images
from .summarize import generate_summary, get_summary from .summarize import generate_summary, get_summary
from .feeds import ( from .feeds import (
fetch_feed, fetch_feed,
@@ -39,9 +42,17 @@ DEFAULT_DB = ROOT / "data" / "goodnews.sqlite3"
DEFAULT_SOURCES = ROOT / "config" / "sources.toml" DEFAULT_SOURCES = ROOT / "config" / "sources.toml"
def _default_db() -> Path:
# Honor GOODNEWS_DB like the rest of the app (db.connect) does, so `GOODNEWS_DB=… `
# actually targets that DB instead of being silently ignored — otherwise a copy-DB
# maintenance run (e.g. dedup --force-recluster) can land on production by surprise.
return Path(os.environ.get("GOODNEWS_DB") or DEFAULT_DB)
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser(prog="goodnews") parser = argparse.ArgumentParser(prog="goodnews")
parser.add_argument("--db", type=Path, default=DEFAULT_DB, help="SQLite database path") parser.add_argument("--db", type=Path, default=_default_db(),
help="SQLite database path (defaults to $GOODNEWS_DB, else the bundled data/ DB)")
subparsers = parser.add_subparsers(dest="command", required=True) subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("init-db", help="Create or update the SQLite schema") subparsers.add_parser("init-db", help="Create or update the SQLite schema")
@@ -123,6 +134,10 @@ def main() -> None:
cycle_parser.add_argument("--classify-limit", type=int, default=40) cycle_parser.add_argument("--classify-limit", type=int, default=40)
cycle_parser.add_argument("--no-classify", action="store_true", help="Skip the LLM classify step") cycle_parser.add_argument("--no-classify", action="store_true", help="Skip the LLM classify step")
cycle_parser.add_argument("--no-dedup", action="store_true", help="Skip the embedding dedup step") cycle_parser.add_argument("--no-dedup", action="store_true", help="Skip the embedding dedup step")
cycle_parser.add_argument("--no-geo", action="store_true", help="Skip tagging article subject-geography")
cycle_parser.add_argument("--geo-limit", type=int, default=60, help="Max articles to geo-tag per cycle")
cycle_parser.add_argument("--no-art", action="store_true", help="Skip the Daily Art pick")
cycle_parser.add_argument("--no-joys", action="store_true", help="Skip the small-joys picks (On This Day, etc.)")
cycle_parser.add_argument("--no-brief", action="store_true", help="Skip rebuilding today's brief") cycle_parser.add_argument("--no-brief", action="store_true", help="Skip rebuilding today's brief")
cycle_parser.add_argument("--no-review", action="store_true", help="Skip recomputing source review flags") cycle_parser.add_argument("--no-review", action="store_true", help="Skip recomputing source review flags")
cycle_parser.add_argument("--no-digest", action="store_true", help="Skip sending due daily digests") cycle_parser.add_argument("--no-digest", action="store_true", help="Skip sending due daily digests")
@@ -138,12 +153,25 @@ def main() -> None:
) )
enrich_images_parser.add_argument("--limit", type=int, default=50, help="Max articles to fetch this batch") enrich_images_parser.add_argument("--limit", type=int, default=50, help="Max articles to fetch this batch")
art_parser = subparsers.add_parser("art", help="Daily Art: harvest the pool and/or pick today's cached piece")
art_parser.add_argument("--harvest", action="store_true", help="(Re)harvest the curated museum pool")
art_parser.add_argument("--force", action="store_true", help="Re-pick today's art even if already chosen")
geo_parser = subparsers.add_parser("geo", help="Tag article subject-geography (backfill / manual). Cycle-locked.")
geo_parser.add_argument("--limit", type=int, default=200, help="Max articles to tag this batch")
geo_parser.add_argument("--reclassify", action="store_true", help="Re-tag even rows already at the current geo version")
geo_parser.add_argument("--base-url", help="OpenAI-compatible base URL")
geo_parser.add_argument("--model", help="Local model name")
dedup_parser = subparsers.add_parser("dedup", help="Cluster near-duplicate stories via local embeddings") dedup_parser = subparsers.add_parser("dedup", help="Cluster near-duplicate stories via local embeddings")
dedup_parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD, help="Cosine similarity cutoff") dedup_parser.add_argument("--threshold", type=float, default=DEFAULT_THRESHOLD, help="Cosine similarity cutoff")
dedup_parser.add_argument("--window-days", type=int, default=DEFAULT_WINDOW_DAYS) dedup_parser.add_argument("--window-days", type=int, default=DEFAULT_WINDOW_DAYS)
dedup_parser.add_argument("--embed-limit", type=int, help="Cap how many missing embeddings to compute") dedup_parser.add_argument("--embed-limit", type=int, help="Cap how many missing embeddings to compute")
dedup_parser.add_argument("--base-url", help="OpenAI-compatible base URL") dedup_parser.add_argument("--base-url", help="OpenAI-compatible base URL")
dedup_parser.add_argument("--model", help="Chat model name (unused for embeddings)") dedup_parser.add_argument("--model", help="Chat model name (unused for embeddings)")
dedup_parser.add_argument("--force-recluster", action="store_true",
help="Re-cluster the EXISTING corpus even if no new embeddings "
"(re-applies representative policy; cycle-locked, no model needed)")
check_llm_parser = subparsers.add_parser("check-llm", help="Check local OpenAI-compatible model endpoint") check_llm_parser = subparsers.add_parser("check-llm", help="Check local OpenAI-compatible model endpoint")
check_llm_parser.add_argument("--base-url", help="OpenAI-compatible base URL, e.g. http://127.0.0.1:1234/v1") check_llm_parser.add_argument("--base-url", help="OpenAI-compatible base URL, e.g. http://127.0.0.1:1234/v1")
@@ -221,7 +249,9 @@ def main() -> None:
import json as _json import json as _json
p = _json.loads(r["preview_json"]) p = _json.loads(r["preview_json"])
line += f" (accept {round(p.get('acceptance_rate', 0) * 100)}%, sampled {p.get('sampled', 0)})" _rate = p.get("acceptance_rate")
_rate_str = f"{round(_rate * 100)}%" if _rate is not None else ""
line += f" (accept {_rate_str}, sampled {p.get('sampled', 0)})"
print(line) print(line)
elif args.command == "promote-candidate": elif args.command == "promote-candidate":
init_db(conn) init_db(conn)
@@ -284,8 +314,44 @@ def main() -> None:
elif args.command == "enrich-images": elif args.command == "enrich-images":
found = enrich_summarized_images(conn, limit=args.limit) found = enrich_summarized_images(conn, limit=args.limit)
print(f"enrich-images: {found} new image(s) for summarized articles") print(f"enrich-images: {found} new image(s) for summarized articles")
elif args.command == "art":
init_db(conn)
if args.harvest:
h = art.harvest_pool(conn)
print(f"art harvest: found={h['found']} added={h['added']} pool={h['pool']} errors={h['errors']}")
picked = art.pick_daily(conn, force=args.force, client=LocalModelClient.from_env())
if picked:
print(f"art pick: {picked['art_date']} -> #{picked['object_id']} "
f"\"{picked['title']}\"{picked['artist'] or 'Unknown'}")
else:
print("art pick: nothing fetched (kept the last piece)")
elif args.command == "geo":
init_db(conn)
# Cycle-locked so a manual backfill can't contend with the scheduled cycle.
with cycle_lock(args.db) as acquired:
if not acquired:
print("geo: a cycle is already running; try again after it finishes")
return
g = tag_geo(conn, llm_client_from_args(args), limit=args.limit, reclassify=args.reclassify)
print(f"geo: tagged={g['tagged']} errors={g['errors']} (of {g['candidates']} candidates)")
elif args.command == "dedup": elif args.command == "dedup":
init_db(conn) init_db(conn)
if args.force_recluster:
# Re-apply representative policy to the EXISTING corpus. The normal path
# fast-skips when no new embeddings exist, so it would NOT pick up a policy
# change. Cycle-locked so it can't overlap the scheduled timer; no model
# needed (pure re-cluster over stored embeddings).
with cycle_lock(args.db) as acquired:
if not acquired:
print("dedup: a cycle is already running; re-run --force-recluster after it finishes")
return
stats = cluster_duplicates(conn, threshold=args.threshold, window_days=args.window_days)
print(
f"dedup (forced recluster): articles={stats['articles']} "
f"clusters={stats['clusters']} duplicate_clusters={stats['duplicate_clusters']} "
f"duplicates_hidden={stats['duplicates']}"
)
else:
client = llm_client_from_args(args) client = llm_client_from_args(args)
stats = run_dedup( stats = run_dedup(
conn, client, threshold=args.threshold, window_days=args.window_days, embed_limit=args.embed_limit conn, client, threshold=args.threshold, window_days=args.window_days, embed_limit=args.embed_limit
@@ -368,7 +434,9 @@ def list_recent(conn: sqlite3.Connection, limit: int, accepted_only: bool) -> No
def print_preview(p: dict) -> None: def print_preview(p: dict) -> None:
mode = "model" if p["classified"] else "heuristic" mode = "model" if p["classified"] else "heuristic"
print(f"Preview of {p['url']} ({mode})") print(f"Preview of {p['url']} ({mode})")
print(f" sampled={p['sampled']} accepted={p['accepted']} ({p['acceptance_rate']*100:.0f}%)") rate = p.get("acceptance_rate")
rate_str = f"{rate * 100:.0f}%" if rate is not None else "— (all held)"
print(f" sampled={p['sampled']} accepted={p['accepted']} ({rate_str})")
print(f" freshness: newest={p['newest_published'] or 'unknown'} in_last_7d={p['recent_7d']}") print(f" freshness: newest={p['newest_published'] or 'unknown'} in_last_7d={p['recent_7d']}")
print(f" averages: cortisol={p['avg_cortisol']} ragebait={p['avg_ragebait']} pr_risk={p['avg_pr_risk']}") print(f" averages: cortisol={p['avg_cortisol']} ragebait={p['avg_ragebait']} pr_risk={p['avg_pr_risk']}")
if p["topic_mix"]: if p["topic_mix"]:
@@ -398,6 +466,28 @@ def check_feeds(conn: sqlite3.Connection, include_inactive: bool = False) -> Non
print(f"--- {ok}/{len(rows)} feeds healthy ---") print(f"--- {ok}/{len(rows)} feeds healthy ---")
@contextlib.contextmanager
def cycle_lock(db_path):
"""Exclusive, non-blocking lock shared by the scheduled cycle and any manual job
that mutates the corpus (e.g. a forced dedup re-cluster), so they can never overlap
and contend on the database/model. Yields True if acquired, False if already held."""
import fcntl
lock_path = Path(db_path).parent / ".goodnews-cycle.lock"
lock_file = open(lock_path, "w")
try:
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
lock_file.close()
yield False
return
try:
yield True
finally:
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()
def run_cycle(conn: sqlite3.Connection, args: argparse.Namespace) -> None: def run_cycle(conn: sqlite3.Connection, args: argparse.Namespace) -> None:
"""One end-to-end pass for a scheduler: poll due sources, classify the new """One end-to-end pass for a scheduler: poll due sources, classify the new
arrivals, dedup, rebuild today's brief. Each step is independent and arrivals, dedup, rebuild today's brief. Each step is independent and
@@ -406,21 +496,11 @@ def run_cycle(conn: sqlite3.Connection, args: argparse.Namespace) -> None:
Holds an exclusive lock so a manual run and the systemd timer (or two timer Holds an exclusive lock so a manual run and the systemd timer (or two timer
ticks) can never overlap and contend on the database and model. ticks) can never overlap and contend on the database and model.
""" """
import fcntl with cycle_lock(args.db) as acquired:
if not acquired:
lock_path = Path(args.db).parent / ".goodnews-cycle.lock"
lock_file = open(lock_path, "w")
try:
fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
print("cycle: another cycle is already running; skipping") print("cycle: another cycle is already running; skipping")
lock_file.close()
return return
try:
_run_cycle_locked(conn, args) _run_cycle_locked(conn, args)
finally:
fcntl.flock(lock_file, fcntl.LOCK_UN)
lock_file.close()
def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> None: def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> None:
@@ -462,6 +542,45 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
except Exception as exc: except Exception as exc:
print(f"dedup: skipped ({exc})") print(f"dedup: skipped ({exc})")
# Geo: tag newly-accepted, non-duplicate articles with subject geography (its own
# LLM pass, decoupled from scoring). Bounded per cycle; idempotent (skips rows
# already at the current GEO_VERSION). Non-fatal like every other step.
if not args.no_geo:
try:
g = tag_geo(conn, llm_client_from_args(args), limit=args.geo_limit)
print(f"geo: tagged={g['tagged']} errors={g['errors']} (of {g['candidates']} untagged)")
except Exception as exc:
print(f"geo: skipped ({exc})")
# Daily Art: ensure the pool exists, then ensure today has a cached piece. No-ops
# once the day is picked; non-fatal like every other step.
if not args.no_art:
try:
a = art.run_daily(conn, client=LocalModelClient.from_env()) # client → the guide blurb
print(f"art: pool={a['pool']} picked={a['picked_object']}")
except Exception as exc:
print(f"art: skipped ({exc})")
# Small joys: On This Day (history), Quote of the Day, Word of the Day. Each is
# bounded + non-fatal; one shared LLM client for tone/explainer/word proposals.
if not args.no_joys:
joy_client = LocalModelClient.from_env()
try:
o = onthisday.run_daily(conn, client=joy_client)
print(f"onthisday: md={o['md']} picked={'yes' if o['picked'] else 'no'}")
except Exception as exc:
print(f"onthisday: skipped ({exc})")
try:
q = quote.run_daily(conn, client=joy_client)
print(f"quote: pool={q['pool']} picked={q['picked']}")
except Exception as exc:
print(f"quote: skipped ({exc})")
try:
w = wotd.run_daily(conn, client=joy_client)
print(f"word: pool={w['pool']} picked={w['picked']}")
except Exception as exc:
print(f"word: skipped ({exc})")
if not args.no_brief: if not args.no_brief:
today = local_today() today = local_today()
try: try:
@@ -480,6 +599,26 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
except Exception as exc: except Exception as exc:
print(f"recent images: skipped ({exc})") print(f"recent images: skipped ({exc})")
# Cache downscaled display copies of those images on our own origin (so the
# hub/feed serve local files, not flaky third-party hotlinks), then enforce the
# size ceiling with LRU eviction. Both bounded + non-fatal.
try:
warmed = newsimg.warm(conn)
pr = newsimg.prune()
print(f"images: cached={warmed} size={pr['after'] // 1048576}MB/"
f"{pr['cap'] // 1048576}MB evicted={pr['removed']}")
except Exception as exc:
print(f"images: skipped ({exc})")
# Full-article read-times: count words for recent accepted articles so the
# front door can show "Full story · ~N min" next to our gist (bounded per cycle).
try:
reads = enrich_read_times(conn)
if reads:
print(f"read-times: {reads} counted")
except Exception as exc:
print(f"read-times: skipped ({exc})")
# Pre-warm summaries for today's brief so Today reads as a calm briefing. # Pre-warm summaries for today's brief so Today reads as a calm briefing.
# Idempotent: cached items are skipped, so this only hits the LLM for new ones. # Idempotent: cached items are skipped, so this only hits the LLM for new ones.
try: try:
@@ -505,6 +644,12 @@ def _run_cycle_locked(conn: sqlite3.Connection, args: argparse.Namespace) -> Non
except Exception as exc: except Exception as exc:
print(f"review: skipped ({exc})") print(f"review: skipped ({exc})")
try:
from .queries import reindex_search
print(f"search: indexed {reindex_search(conn)} articles")
except Exception as exc: # noqa: BLE001 — search index is non-critical
print(f"search: skipped ({exc})")
if not args.no_digest: if not args.no_digest:
try: try:
sent = send_due_digests(conn) # morning-gated + deduped internally sent = send_due_digests(conn) # morning-gated + deduped internally
+54
View File
@@ -0,0 +1,54 @@
"""Shared helpers for the daily "small joys" features — On This Day, Word of the Day,
Quote of the Day (and whatever calm little delights come next).
Each joy keeps its own pool + daily table, but they all share this skeleton:
harvest -> pool deterministic daily pick (date-seeded, least-recently-shown)
cache row in a daily_* table API page.
This module holds only the genuinely shared bits (network + the deterministic pick), so a
new joy is a small self-contained module, not a copy-paste of plumbing. Network calls go
through http_json so tests can monkeypatch them.
"""
from __future__ import annotations
import hashlib
import json
import urllib.request
_UA = {"User-Agent": "upbeatBytes/1.0 (+https://upbeatbytes.com)"}
def http_json(url: str, timeout: int = 20) -> dict:
req = urllib.request.Request(url, headers=_UA)
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
def seeded_order(ids: list, date_str: str) -> list:
"""Rotate a list deterministically by the date, so the day's pick is the same for
everyone and varies day to day (the same trick Daily Art uses)."""
if not ids:
return ids
seed = int(hashlib.sha256(date_str.encode()).hexdigest(), 16) % len(ids)
return ids[seed:] + ids[:seed]
def freshest(rows: list) -> list:
"""The cohort to feature today, from pool rows carrying `id` + `shown_at`: every
NEVER-shown item (shown_at NULL) while any remain, else every item tied for the
OLDEST shown_at. Guarantees each pool item is featured ONCE before any repeat, then
cycles oldest-first. Pick deterministically *within* this cohort (seeded_order)
NEVER across the whole pool, which re-feeds recent items (the QOTD/WOTD repeat bug)."""
never = [r["id"] for r in rows if r["shown_at"] is None]
if never:
return sorted(never)
if not rows:
return []
oldest = min(r["shown_at"] for r in rows)
return sorted(r["id"] for r in rows if r["shown_at"] == oldest)
def content_key(*parts) -> str:
"""A stable dedup key for a pool item (so re-harvesting never duplicates a row)."""
raw = "|".join("" if p is None else str(p) for p in parts)
return hashlib.sha256(raw.encode()).hexdigest()[:24]
+1
View File
@@ -0,0 +1 @@
["vagina", "vulva", "nipple", "rectum", "anal", "fecal", "ejaculation", "eunuch", "nude", "nudity", "butt"]
+1
View File
@@ -0,0 +1 @@
["death","dying","died","killed","killing","murder","murdered","corpse","coffin","funeral","grave","buried","burial","weapon","gunshot","warfare","violent","violence","deadly","lethal","poison","poisoned","suicide","slaughter","victim","bleeding","wound","wounded","vomit","vomiting","vomited","diarrhea","disease","diseased","cancer","tumor","illness","infection","infected","plague","disabled","lucifer","satan","demon","demonic","devil","damned","hatred","hateful","terror","terrorize","hostage","kidnap","kidnapped","abuse","abused","assault","trauma","traumatic","anxiety","depression","depressed","divorce","divorced","bankrupt","eviction","evicted","layoff","drowned","drowning","choking","suffocate","starving","famine","poverty","despair","misery","miserable","tragic","tragedy","horror","horrible","nightmare","panic","dread","grief","grieving","mourning","rotting","decay","decayed","maggot","vermin","filth","sewage","manure"]
File diff suppressed because one or more lines are too long
+268 -1
View File
@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS sources (
retry_after_at TEXT, retry_after_at TEXT,
review_flag INTEGER NOT NULL DEFAULT 0, review_flag INTEGER NOT NULL DEFAULT 0,
review_reason TEXT, review_reason TEXT,
x_handle TEXT, -- the source's own verified X handle, if known
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -48,6 +49,8 @@ CREATE TABLE IF NOT EXISTS articles (
title_hash TEXT, title_hash TEXT,
duplicate_of INTEGER REFERENCES articles(id) ON DELETE SET NULL, duplicate_of INTEGER REFERENCES articles(id) ON DELETE SET NULL,
image_checked_at TEXT, image_checked_at TEXT,
source_words INTEGER, -- full-article word count (metadata only; never the body)
read_checked_at TEXT, -- when we last tried to count words (retry guard)
FOREIGN KEY (source_id) REFERENCES sources(id) FOREIGN KEY (source_id) REFERENCES sources(id)
); );
@@ -69,6 +72,7 @@ CREATE TABLE IF NOT EXISTS article_scores (
reason_text TEXT, reason_text TEXT,
topic TEXT, topic TEXT,
flavor TEXT, flavor TEXT,
language TEXT,
model_name TEXT, model_name TEXT,
scored_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP scored_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
@@ -215,6 +219,146 @@ CREATE TABLE IF NOT EXISTS article_summaries (
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
); );
-- Where a story is ABOUT (subject geography), kept SEPARATE from article_scores so
-- durable geography isn't coupled to volatile scoring/acceptance. "local" is never
-- stored here it's relative to the reader; the UI computes "Near you" by comparing
-- these places to the visitor's chosen home. geo_version lets us re-backfill cleanly
-- when the prompt/taxonomy changes. 'global' is a real category, not a failure.
CREATE TABLE IF NOT EXISTS article_geo (
article_id INTEGER PRIMARY KEY REFERENCES articles(id) ON DELETE CASCADE,
breadth TEXT NOT NULL DEFAULT 'unknown', -- locality|regional|national|multinational|global|unknown
confidence TEXT NOT NULL DEFAULT 'low', -- high|medium|low
rationale TEXT,
geo_version TEXT,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- 0..N normalized places per article (a story can span regions). Codes are ISO
-- (country = alpha-2, state = US 2-letter / ISO-3166-2 subdivision), normalized in
-- code never trusting the model's free text.
CREATE TABLE IF NOT EXISTS article_places (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
country_code TEXT,
state_code TEXT,
locality TEXT,
ord INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_article_places_article ON article_places(article_id);
CREATE INDEX IF NOT EXISTS idx_article_places_country ON article_places(country_code);
CREATE INDEX IF NOT EXISTS idx_article_geo_breadth ON article_geo(breadth);
-- Daily Art (the /art room). art_pool = a curated set of public-domain, highlighted
-- museum object IDs (so the daily pick never hits a potsherd). daily_art = one cached
-- piece per day (metadata + a locally-cached image), so the homepage never waits on or
-- hotlinks the museum. shown_at lets us avoid repeating a piece too soon.
CREATE TABLE IF NOT EXISTS art_pool (
object_id INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'met',
shown_at TEXT,
blocked INTEGER NOT NULL DEFAULT 0, -- manual lever: skip an odd/unsuitable piece
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (source, object_id)
);
CREATE TABLE IF NOT EXISTS daily_art (
art_date TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'met',
object_id INTEGER NOT NULL,
title TEXT,
artist TEXT,
date_text TEXT,
medium TEXT,
department TEXT,
credit TEXT, -- museum credit line
source_url TEXT, -- canonical museum object page
image_file TEXT, -- our cached (web-large) image
image_url_full TEXT, -- source full-res URL, for a later richer /art view
is_public_domain INTEGER, -- license marker (CC0/public domain), stored for citizenship
blurb TEXT, -- LLM "museum guide" note: what you're looking at (cached)
palette TEXT, -- JSON array of hex colors extracted from the image
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- "Small joys" daily features. On This Day: a good/neutral thing that happened on
-- today's calendar date, harvested + tone-filtered into a pool, then one picked per day.
CREATE TABLE IF NOT EXISTS onthisday_pool (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT 'wikimedia', -- multi-source ready (wikimedia | admin | ...)
md TEXT NOT NULL, -- 'MM-DD'
year INTEGER,
ckey TEXT NOT NULL UNIQUE, -- dedup hash so re-harvest never duplicates
text TEXT NOT NULL,
summary TEXT,
image_url TEXT,
page_url TEXT,
shown_at TEXT, -- last date this was the pick (no-soon-repeat)
blocked INTEGER NOT NULL DEFAULT 0, -- admin lever: never pick this
featured INTEGER NOT NULL DEFAULT 0, -- admin lever: prefer this for its date
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS daily_onthisday (
feature_date TEXT PRIMARY KEY, -- 'YYYY-MM-DD'
pool_id INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'wikimedia',
md TEXT NOT NULL,
year INTEGER,
text TEXT,
summary TEXT,
image_url TEXT,
page_url TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Quote of the Day: curated public-domain quotes; one picked per day. `meaning` is an
-- AI explainer of the (real) quote, filled lazily the first time it's shown.
CREATE TABLE IF NOT EXISTS quote_pool (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT 'curated',
ckey TEXT NOT NULL UNIQUE,
text TEXT NOT NULL,
author TEXT,
work TEXT,
year TEXT,
meaning TEXT,
shown_at TEXT,
blocked INTEGER NOT NULL DEFAULT 0,
featured INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS daily_quote (
feature_date TEXT PRIMARY KEY,
pool_id INTEGER NOT NULL,
source TEXT NOT NULL DEFAULT 'curated',
text TEXT, author TEXT, work TEXT, year TEXT, meaning TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Word of the Day: LLM-proposed positive words, validated/enriched against a real
-- dictionary (definition, IPA, examples, cached audio clip); one picked per day.
CREATE TABLE IF NOT EXISTS wotd_pool (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT 'llm',
word TEXT NOT NULL UNIQUE,
part_of_speech TEXT,
phonetic TEXT, -- IPA
audio_file TEXT, -- our cached pronunciation clip (or null browser TTS)
audio_url TEXT, -- source clip URL
definition TEXT NOT NULL, -- raw dictionary gloss (anchor / ground truth)
examples TEXT, -- JSON array of raw dictionary example sentences (anchor)
gloss TEXT, -- LLM plain-language rewrite of the definition (for display)
usage TEXT, -- JSON array of LLM everyday example sentences (for display)
shown_at TEXT,
blocked INTEGER NOT NULL DEFAULT 0,
featured INTEGER NOT NULL DEFAULT 0,
added_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS daily_wotd (
feature_date TEXT PRIMARY KEY,
pool_id INTEGER NOT NULL,
word TEXT, part_of_speech TEXT, phonetic TEXT, audio_file TEXT,
definition TEXT, examples TEXT, gloss TEXT, usage TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Privacy-respecting, first-party analytics. NO IP / user-agent / referrer / raw -- Privacy-respecting, first-party analytics. NO IP / user-agent / referrer / raw
-- URL. visitor_hash is a hash of a random localStorage token (never email/IP). -- URL. visitor_hash is a hash of a random localStorage token (never email/IP).
-- The UNIQUE key dedups to one row per (kind, article, visitor, day) that both -- The UNIQUE key dedups to one row per (kind, article, visitor, day) that both
@@ -300,6 +444,47 @@ CREATE TABLE IF NOT EXISTS daily_puzzles (
UNIQUE (puzzle_date, game, variant) UNIQUE (puzzle_date, game, variant)
); );
-- Full-text search over the PUBLIC article corpus (title/description/source/tags).
-- Standalone FTS5 (not external-content) since the searchable text spans tables;
-- rebuilt from the accepted, non-duplicate set on each ingest cycle (+ lazily).
CREATE VIRTUAL TABLE IF NOT EXISTS article_search USING fts5(
article_id UNINDEXED, title, body, source_name, tags
);
CREATE TABLE IF NOT EXISTS game_state (
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
game TEXT NOT NULL, -- 'word' | 'wordsearch'
variant TEXT NOT NULL, -- '5'|'6' | 'small'|'med'|'large'
puzzle_date TEXT NOT NULL,
state_json TEXT NOT NULL, -- per-puzzle progress; merged server-side on save
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_id, game, variant, puzzle_date)
);
-- Bloom runtime word curation (no deploy needed). The accepted set is computed
-- live as: broad dictionary {allow} {block}. Admin-managed; one row per word.
CREATE TABLE IF NOT EXISTS bloom_word_overrides (
word TEXT PRIMARY KEY, -- lowercase
action TEXT NOT NULL, -- 'allow' | 'block'
reason TEXT,
created_by TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Player "this should count" reports admin queue (approveallow / block / dismiss).
CREATE TABLE IF NOT EXISTS bloom_word_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
word TEXT NOT NULL, -- lowercase
puzzle_date TEXT,
mode TEXT, -- 'daily' | 'free'
format TEXT, -- 'center' | 'wild'
letters TEXT, -- the wheel's 7 letters (for context)
reason TEXT, -- why it was rejected (e.g. 'not in the word list')
status TEXT NOT NULL DEFAULT 'pending', -- 'pending' | 'approved' | 'blocked' | 'dismissed'
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bloom_reports_status ON bloom_word_reports(status, created_at);
CREATE TABLE IF NOT EXISTS user_follows ( CREATE TABLE IF NOT EXISTS user_follows (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -317,6 +502,49 @@ CREATE TABLE IF NOT EXISTS digest_sends (
sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, sent_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (user_id, brief_date) UNIQUE (user_id, brief_date)
); );
-- Publishing Desk: a platform-NEUTRAL outbound-share record (X first; Bluesky /
-- Threads / newsletter later reuse this). One row per (article, platform); the
-- queue tops up without ever overwriting saved text/handles. opened != posted
-- Web Intents can't confirm a post, so the human confirms the terminal state.
CREATE TABLE IF NOT EXISTS outbound_shares (
id INTEGER PRIMARY KEY AUTOINCREMENT,
article_id INTEGER NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
platform TEXT NOT NULL DEFAULT 'x',
status TEXT NOT NULL DEFAULT 'queued', -- queued|drafting|opened|posted|skipped|snoozed
social_score INTEGER, -- LLM "stop-scrolling" interest (0-10)
rationale TEXT, -- why someone would stop scrolling
talking_points TEXT, -- JSON array of factual points
angle TEXT, -- a suggested conversational angle
entities TEXT, -- JSON array of raw named entities (LLM-extracted)
suggested_handles TEXT, -- JSON array of {handle, profile_url, via}
draft_text TEXT, -- autosaved in-progress blurb (the human writes it)
final_text TEXT, -- what was actually posted (teaches voice later)
share_url TEXT, -- the exact /a/{id}?utm... link used
post_url TEXT, -- the resulting tweet URL, if captured
snooze_until TEXT, -- 'not right now' (re-eligible after this)
opened_at TEXT,
posted_at TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (article_id, platform)
);
CREATE INDEX IF NOT EXISTS idx_outbound_shares_status ON outbound_shares(platform, status);
-- Verified handle directory the LLM only ever proposes NAMES; the @handle comes
-- only from here (or a source's own x_handle). Aliases resolve consistently by each
-- having its own row pointing at the same handle (e.g. "Johns Hopkins University"
-- and "Johns Hopkins").
CREATE TABLE IF NOT EXISTS entity_handles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_name TEXT NOT NULL, -- display name as entered
normalized_name TEXT NOT NULL, -- lowercased/stripped match key
platform TEXT NOT NULL DEFAULT 'x',
handle TEXT NOT NULL, -- e.g. @AnthropicAI
profile_url TEXT,
verified_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (normalized_name, platform)
);
""" """
@@ -349,7 +577,7 @@ def _migrate(conn: sqlite3.Connection) -> None:
need an explicit, idempotent ALTER guarded by the current column set. need an explicit, idempotent ALTER guarded by the current column set.
""" """
score_cols = {row["name"] for row in conn.execute("PRAGMA table_info(article_scores)")} score_cols = {row["name"] for row in conn.execute("PRAGMA table_info(article_scores)")}
for column in ("topic", "flavor"): for column in ("topic", "flavor", "language"):
if column not in score_cols: if column not in score_cols:
conn.execute(f"ALTER TABLE article_scores ADD COLUMN {column} TEXT") conn.execute(f"ALTER TABLE article_scores ADD COLUMN {column} TEXT")
@@ -371,6 +599,10 @@ def _migrate(conn: sqlite3.Connection) -> None:
) )
if "image_checked_at" not in article_cols: if "image_checked_at" not in article_cols:
conn.execute("ALTER TABLE articles ADD COLUMN image_checked_at TEXT") conn.execute("ALTER TABLE articles ADD COLUMN image_checked_at TEXT")
if "source_words" not in article_cols: # full-article read-time (count only, no body)
conn.execute("ALTER TABLE articles ADD COLUMN source_words INTEGER")
if "read_checked_at" not in article_cols:
conn.execute("ALTER TABLE articles ADD COLUMN read_checked_at TEXT")
# Created here (not in SCHEMA) so it runs after the column exists on upgrades. # Created here (not in SCHEMA) so it runs after the column exists on upgrades.
conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_duplicate_of ON articles(duplicate_of)") conn.execute("CREATE INDEX IF NOT EXISTS idx_articles_duplicate_of ON articles(duplicate_of)")
@@ -382,10 +614,14 @@ def _migrate(conn: sqlite3.Connection) -> None:
"consecutive_failures": "INTEGER NOT NULL DEFAULT 0", "consecutive_failures": "INTEGER NOT NULL DEFAULT 0",
"review_flag": "INTEGER NOT NULL DEFAULT 0", "review_flag": "INTEGER NOT NULL DEFAULT 0",
"review_reason": "TEXT", "review_reason": "TEXT",
"paywall_override": "TEXT", # NULL = use domain rule · 'free' · 'paywalled'
} }
for column, decl in health_columns.items(): for column, decl in health_columns.items():
if column not in source_cols: if column not in source_cols:
conn.execute(f"ALTER TABLE sources ADD COLUMN {column} {decl}") conn.execute(f"ALTER TABLE sources ADD COLUMN {column} {decl}")
# Publishing Desk: the source's own verified X handle (suggested when sharing).
if "x_handle" not in source_cols:
conn.execute("ALTER TABLE sources ADD COLUMN x_handle TEXT")
# Lifecycle: status (active/paused/retired) + content_visible. `active` is # Lifecycle: status (active/paused/retired) + content_visible. `active` is
# kept as a synced mirror so legacy code (scheduler/CLI) keeps working. # kept as a synced mirror so legacy code (scheduler/CLI) keeps working.
@@ -396,12 +632,35 @@ def _migrate(conn: sqlite3.Connection) -> None:
conn.execute("ALTER TABLE sources ADD COLUMN content_visible INTEGER NOT NULL DEFAULT 1") conn.execute("ALTER TABLE sources ADD COLUMN content_visible INTEGER NOT NULL DEFAULT 1")
if "retry_after_at" not in source_cols: if "retry_after_at" not in source_cols:
conn.execute("ALTER TABLE sources ADD COLUMN retry_after_at TEXT") conn.execute("ALTER TABLE sources ADD COLUMN retry_after_at TEXT")
# Image rights policy per source: 'cache' (cleared to re-host a downscaled copy),
# 'remote' (hotlink the publisher's image — the conservative DEFAULT), 'none' (no
# image). Caching is opt-in; unknown/new sources are never re-hosted.
if "image_policy" not in source_cols:
conn.execute("ALTER TABLE sources ADD COLUMN image_policy TEXT NOT NULL DEFAULT 'remote'")
# Daily Art columns added after the tables first shipped.
pool_cols = {row["name"] for row in conn.execute("PRAGMA table_info(art_pool)")}
if pool_cols and "blocked" not in pool_cols:
conn.execute("ALTER TABLE art_pool ADD COLUMN blocked INTEGER NOT NULL DEFAULT 0")
art_cols = {row["name"] for row in conn.execute("PRAGMA table_info(daily_art)")}
if art_cols and "image_url_full" not in art_cols:
conn.execute("ALTER TABLE daily_art ADD COLUMN image_url_full TEXT")
if art_cols and "is_public_domain" not in art_cols:
conn.execute("ALTER TABLE daily_art ADD COLUMN is_public_domain INTEGER")
for column in ("blurb", "palette"): # richer /art page: guide note + extracted colors
if art_cols and column not in art_cols:
conn.execute(f"ALTER TABLE daily_art ADD COLUMN {column} TEXT")
# feedback.read_at (admin inbox read/unread) added later. # feedback.read_at (admin inbox read/unread) added later.
fb_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback)")} fb_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback)")}
if fb_cols and "read_at" not in fb_cols: if fb_cols and "read_at" not in fb_cols:
conn.execute("ALTER TABLE feedback ADD COLUMN read_at TEXT") 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. # feedback_replies.message_html (rendered Markdown subset) added later.
rep_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback_replies)")} rep_cols = {row["name"] for row in conn.execute("PRAGMA table_info(feedback_replies)")}
if rep_cols and "message_html" not in rep_cols: if rep_cols and "message_html" not in rep_cols:
@@ -412,3 +671,11 @@ def _migrate(conn: sqlite3.Connection) -> None:
for column in ("what_happened", "why_matters", "why_belongs"): for column in ("what_happened", "why_matters", "why_belongs"):
if sum_cols and column not in sum_cols: if sum_cols and column not in sum_cols:
conn.execute(f"ALTER TABLE article_summaries ADD COLUMN {column} TEXT") conn.execute(f"ALTER TABLE article_summaries ADD COLUMN {column} TEXT")
# WOTD display polish: LLM plain-language gloss + everyday example sentences, kept
# alongside the raw dictionary def/examples (which stay the anchor / ground truth).
for tbl in ("wotd_pool", "daily_wotd"):
cols = {row["name"] for row in conn.execute(f"PRAGMA table_info({tbl})")}
for column in ("gloss", "usage"):
if cols and column not in cols:
conn.execute(f"ALTER TABLE {tbl} ADD COLUMN {column} TEXT")
+35 -3
View File
@@ -102,7 +102,8 @@ def cluster_duplicates(
(COALESCE(s.constructive_score,0) + COALESCE(s.agency_score,0) (COALESCE(s.constructive_score,0) + COALESCE(s.agency_score,0)
+ COALESCE(s.human_benefit_score,0) + src.trust_score + COALESCE(s.human_benefit_score,0) + src.trust_score
- COALESCE(s.cortisol_score,0) - COALESCE(s.ragebait_score,0) - COALESCE(s.cortisol_score,0) - COALESCE(s.ragebait_score,0)
- COALESCE(s.pr_risk_score,0)) AS rank_score - COALESCE(s.pr_risk_score,0)) AS rank_score,
COALESCE(s.accepted, 0) AS accepted
FROM articles a FROM articles a
JOIN article_embeddings e ON e.article_id = a.id JOIN article_embeddings e ON e.article_id = a.id
JOIN sources src ON src.id = a.source_id JOIN sources src ON src.id = a.source_id
@@ -114,7 +115,8 @@ def cluster_duplicates(
items = [] items = []
for r in rows: for r in rows:
vec = _unit(array("f", r["vector"]).tolist()) vec = _unit(array("f", r["vector"]).tolist())
items.append({"id": r["id"], "ord": _day_ordinal(r["dt"]), "vec": vec, "score": r["rank_score"]}) items.append({"id": r["id"], "ord": _day_ordinal(r["dt"]), "vec": vec,
"score": r["rank_score"], "accepted": bool(r["accepted"])})
clusters: list[dict] = [] # {anchor_vec, anchor_ord, members:[item]} clusters: list[dict] = [] # {anchor_vec, anchor_ord, members:[item]}
for it in items: for it in items:
@@ -130,6 +132,14 @@ def cluster_duplicates(
if not placed: if not placed:
clusters.append({"anchor_vec": it["vec"], "anchor_ord": it["ord"], "members": [it]}) clusters.append({"anchor_vec": it["vec"], "anchor_ord": it["ord"], "members": [it]})
# Which articles are CURRENTLY a representative (something points at them)? Captured
# BEFORE we reset, so we can keep an established canonical stable across runs.
prior_reps = {
row[0] for row in conn.execute(
"SELECT DISTINCT duplicate_of FROM articles WHERE duplicate_of IS NOT NULL"
)
}
# Reset prior decisions for everything we considered, then re-apply. # Reset prior decisions for everything we considered, then re-apply.
considered = [it["id"] for it in items] considered = [it["id"] for it in items]
conn.executemany( conn.executemany(
@@ -142,7 +152,19 @@ def cluster_duplicates(
if len(cl["members"]) < 2: if len(cl["members"]) < 2:
continue continue
dup_clusters += 1 dup_clusters += 1
rep = max(cl["members"], key=lambda m: (m["score"], -m["id"])) # Representative priority (highest wins), in order:
# 1. accepted/serveable — an accepted page must never be retired to a REJECTED
# rep (that page would 404 with nothing to redirect to).
# 2. established rep — if a member is already the cluster's canonical, keep it,
# so an indexed URL doesn't churn when a newer twin arrives.
# 3. quality score — decides genuinely-new clusters.
# 4. -id — deterministic final tiebreak (older wins).
rep = max(cl["members"], key=lambda m: (
1 if m["accepted"] else 0,
1 if m["id"] in prior_reps else 0,
m["score"],
-m["id"],
))
for m in cl["members"]: for m in cl["members"]:
if m["id"] != rep["id"]: if m["id"] != rep["id"]:
conn.execute( conn.execute(
@@ -166,6 +188,16 @@ def dedup(
embed_limit: int | None = None, embed_limit: int | None = None,
) -> dict: ) -> dict:
embedded = ensure_embeddings(conn, client, limit=embed_limit) embedded = ensure_embeddings(conn, client, limit=embed_limit)
if embedded == 0:
# Nothing new entered the corpus → the clusters and duplicate_of links are
# unchanged, so skip the full re-cluster. It was re-running an O(n²) cosine
# pass over EVERY article and rewriting duplicate_of for all ~3.7k of them
# every cycle (~53s + a large WAL commit), which starved live API reads
# (/api/brief 2-7s). Most cycles find no new articles, so this makes the
# cycle near-instant and keeps reads fast. A real new article re-runs it.
dups = conn.execute("SELECT COUNT(*) FROM articles WHERE duplicate_of IS NOT NULL").fetchone()[0]
return {"embedded": 0, "articles": 0, "clusters": 0, "duplicate_clusters": 0,
"duplicates": dups, "skipped": True}
stats = cluster_duplicates(conn, threshold=threshold, window_days=window_days) stats = cluster_duplicates(conn, threshold=threshold, window_days=window_days)
stats["embedded"] = embedded stats["embedded"] = embedded
return stats return stats
+21 -16
View File
@@ -16,7 +16,8 @@ from html import escape
from . import email_send from . import email_send
from .localtime import local_now, local_today from .localtime import local_now, local_today
from .paywall import is_paywalled 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_HOUR = int(os.environ.get("GOODNEWS_DIGEST_HOUR", "7"))
DIGEST_WINDOW_HOURS = 4 # send between DIGEST_HOUR and +4h, site-local DIGEST_WINDOW_HOURS = 4 # send between DIGEST_HOUR and +4h, site-local
@@ -29,25 +30,27 @@ def _base_url() -> str:
def digest_items(conn: sqlite3.Connection, brief_date: str, limit: int = 7) -> list[dict]: 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).""" """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( rows = conn.execute(
""" f"""
SELECT a.id, a.title, a.canonical_url, s.name AS source, sc.reason_text, 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 (SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
FROM daily_briefs b FROM daily_briefs b
JOIN daily_brief_items bi ON bi.brief_id = b.id JOIN daily_brief_items bi ON bi.brief_id = b.id
JOIN articles a ON a.id = bi.article_id JOIN articles a ON a.id = bi.article_id
JOIN sources s ON s.id = a.source_id JOIN sources s ON s.id = a.source_id
LEFT JOIN article_scores sc ON sc.article_id = a.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 ORDER BY bi.rank
LIMIT ? LIMIT ?
""", """,
(brief_date, limit), (brief_date, *pwx, limit),
).fetchall() ).fetchall()
items = [] items = []
for r in rows: for r in rows:
d = dict(r) d = dict(r)
d["paywalled"] = is_paywalled(d["canonical_url"]) d["paywalled"] = is_paywalled_for_source(d["canonical_url"], d.get("paywall_override"))
items.append(d) items.append(d)
return items return items
@@ -72,19 +75,21 @@ def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, l
f"AND at.tag IN ({','.join('?' * len(ftags))}))" f"AND at.tag IN ({','.join('?' * len(ftags))}))"
) )
params += 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( rows = conn.execute(
f""" f"""
SELECT a.id, a.title, a.canonical_url, s.name AS source, a.source_id, sc.reason_text, SELECT a.id, a.title, a.canonical_url, s.name AS source, s.paywall_override, a.source_id, sc.reason_text,
(SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary (SELECT summary FROM article_summaries WHERE article_id = a.id) AS summary
FROM articles a FROM articles a
JOIN sources s ON s.id = a.source_id JOIN sources s ON s.id = a.source_id
JOIN article_scores sc ON sc.article_id = a.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)}) AND ({' OR '.join(ors)})
ORDER BY COALESCE(a.published_at, a.discovered_at) DESC ORDER BY COALESCE(a.published_at, a.discovered_at) DESC
LIMIT 30 LIMIT 30
""", """,
params, [*pwx, *params],
).fetchall() ).fetchall()
exclude, per_source, out = set(exclude_ids), {}, [] exclude, per_source, out = set(exclude_ids), {}, []
for r in rows: for r in rows:
@@ -92,7 +97,7 @@ def followed_digest_items(conn: sqlite3.Connection, user_id: int, exclude_ids, l
if d["id"] in exclude or per_source.get(d["source_id"], 0) >= 1: if d["id"] in exclude or per_source.get(d["source_id"], 0) >= 1:
continue continue
per_source[d["source_id"]] = 1 per_source[d["source_id"]] = 1
d["paywalled"] = is_paywalled(d["canonical_url"]) d["paywalled"] = is_paywalled_for_source(d["canonical_url"], d.get("paywall_override"))
out.append(d) out.append(d)
if len(out) >= limit: if len(out) >= limit:
break break
@@ -127,7 +132,7 @@ def _item_html(it: dict, base: str) -> str:
f'<div style="color:#5d6b78;font-size:13px;margin:3px 0 8px">{escape(it["source"])}</div>' f'<div style="color:#5d6b78;font-size:13px;margin:3px 0 8px">{escape(it["source"])}</div>'
f'{summary}{why}' f'{summary}{why}'
'<div style="margin-top:10px;font-size:14px">' '<div style="margin-top:10px;font-size:14px">'
f'<a href="{base}/a/{it["id"]}" style="color:#0083ad;text-decoration:none">Read on Upbeat Bytes</a>' f'<a href="{base}/a/{it["id"]}" style="color:#0083ad;text-decoration:none">Read on upbeatBytes</a>'
f' &nbsp;·&nbsp; <a href="{escape(it["canonical_url"])}" style="color:#5d6b78;text-decoration:none">Full story at source{lock}</a>' f' &nbsp;·&nbsp; <a href="{escape(it["canonical_url"])}" style="color:#5d6b78;text-decoration:none">Full story at source{lock}</a>'
'</div></div>' '</div></div>'
) )
@@ -145,12 +150,12 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str |
followed = followed or [] followed = followed or []
n = len(items) n = len(items)
weekday = _weekday(brief_date) weekday = _weekday(brief_date)
subject = f"{weekday}'s Upbeat Bytes · {n} calm read{'' if n == 1 else 's'}" subject = f"{weekday}'s upbeatBytes · {n} calm read{'' if n == 1 else 's'}"
if weekday == "today": if weekday == "today":
subject = f"Today's Upbeat Bytes · {n} calm reads" subject = f"Today's upbeatBytes · {n} calm reads"
text_lines = [ text_lines = [
f"Upbeat Bytes — Daily Highlights", f"upbeatBytes — Daily Highlights",
f"{n} calm read{'' if n == 1 else 's'} for {weekday}.\n", f"{n} calm read{'' if n == 1 else 's'} for {weekday}.\n",
"Good morning. A small, hopeful handful of what's going right — and there's", "Good morning. A small, hopeful handful of what's going right — and there's",
"always more waiting on the site whenever you want it.\n", "always more waiting on the site whenever you want it.\n",
@@ -181,7 +186,7 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str |
'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">' 'font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#16263a">'
# The real logo as a small hosted PNG (SVG isn't email-safe); alt text # The real logo as a small hosted PNG (SVG isn't email-safe); alt text
# keeps the brand when a client blocks remote images. # keeps the brand when a client blocks remote images.
f'<img src="{base}/logo-email.png" alt="Upbeat Bytes" width="180" ' f'<img src="{base}/logo-email.png" alt="upbeatBytes" width="180" '
'style="display:block;border:0;outline:none;text-decoration:none;height:auto;margin:0 0 2px">' 'style="display:block;border:0;outline:none;text-decoration:none;height:auto;margin:0 0 2px">'
'<div style="font-size:11px;letter-spacing:0.14em;text-transform:uppercase;color:#0083ad;' '<div style="font-size:11px;letter-spacing:0.14em;text-transform:uppercase;color:#0083ad;'
f'margin:5px 0 0">Daily Highlights · {escape(weekday)}</div>' f'margin:5px 0 0">Daily Highlights · {escape(weekday)}</div>'
@@ -192,7 +197,7 @@ def build_digest(items: list[dict], brief_date: str, unsub_url: str, base: str |
+ main_blocks + main_blocks
+ followed_html + followed_html
+ '<p style="font-size:15px;color:#3f7048;margin:8px 0 0">Thats todays highlights — more good news is ' + '<p style="font-size:15px;color:#3f7048;margin:8px 0 0">Thats todays highlights — more good news is '
f'always <a href="{base}" style="color:#3f7048">waiting on Upbeat Bytes</a>. See you tomorrow.</p>' f'always <a href="{base}" style="color:#3f7048">waiting on upbeatBytes</a>. See you tomorrow.</p>'
f'<p style="font-size:12px;color:#9aa6b2;margin-top:24px">Youre getting this because you turned on ' f'<p style="font-size:12px;color:#9aa6b2;margin-top:24px">Youre getting this because you turned on '
f'the daily digest. <a href="{unsub_url}" style="color:#9aa6b2">Unsubscribe</a>.</p>' f'the daily digest. <a href="{unsub_url}" style="color:#9aa6b2">Unsubscribe</a>.</p>'
'</div>' '</div>'
+10 -10
View File
@@ -24,7 +24,7 @@ def _cfg() -> dict:
"port": int(os.environ.get("GOODNEWS_SMTP_PORT", "587")), "port": int(os.environ.get("GOODNEWS_SMTP_PORT", "587")),
"user": os.environ.get("GOODNEWS_SMTP_USER", ""), "user": os.environ.get("GOODNEWS_SMTP_USER", ""),
"password": os.environ.get("GOODNEWS_SMTP_PASSWORD", ""), "password": os.environ.get("GOODNEWS_SMTP_PASSWORD", ""),
"sender": os.environ.get("GOODNEWS_SMTP_FROM", "Upbeat Bytes <hello@upbeatbytes.com>"), "sender": os.environ.get("GOODNEWS_SMTP_FROM", "upbeatBytes <hello@upbeatbytes.com>"),
# Where a reader's reply should land; falls back to the From address. # Where a reader's reply should land; falls back to the From address.
"reply_to": os.environ.get("GOODNEWS_REPLY_TO_EMAIL", ""), "reply_to": os.environ.get("GOODNEWS_REPLY_TO_EMAIL", ""),
} }
@@ -59,7 +59,7 @@ def send_email(to: str, subject: str, text: str, html: str | None = None, reply_
def send_feedback(to: str, category: str, message: str, contact: str | None, who: str) -> None: 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).""" """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)" reply = contact or "(none given)"
text = ( text = (
f"New feedback ({category})\n" 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 """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 text/plain + text/html (the HTML is the pre-sanitized Markdown render). Quotes
their original note for context; exposes no analytics/account details.""" 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()) quoted = "\n".join("> " + line for line in (original_message or "").splitlines())
text = ( text = (
f"{reply_text}\n\n" f"{reply_text}\n\n"
"\n" "\n"
"In reply to your note to Upbeat Bytes:\n" "In reply to your note to upbeatBytes:\n"
f"{quoted}\n\n" f"{quoted}\n\n"
"Thanks for reaching out.\nUpbeat Bytes\n" "Thanks for reaching out.\nupbeatBytes\n"
) )
body_html = None body_html = None
if reply_html: 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;' '<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
'color:#16263a;font-size:15px;line-height:1.5">' 'color:#16263a;font-size:15px;line-height:1.5">'
f"{reply_html}" 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>' 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). # Route the reader's reply to our chosen inbox (never back to the reader).
cfg = _cfg() 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: def send_magic_link(to: str, link: str) -> None:
"""Send a calm, single-purpose sign-in email.""" """Send a calm, single-purpose sign-in email."""
subject = "Your Upbeat Bytes sign-in link" subject = "Your upbeatBytes sign-in link"
text = ( text = (
"Welcome back to Upbeat Bytes.\n\n" "Welcome back to upbeatBytes.\n\n"
f"Tap to sign in:\n{link}\n\n" f"Tap to sign in:\n{link}\n\n"
"This link works once and expires in 15 minutes.\n" "This link works once and expires in 15 minutes.\n"
"If you didn't request it, you can safely ignore this email." "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 = ( html = (
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;' '<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
'color:#16263a;line-height:1.6">' '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;' 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">' 'text-decoration:none;padding:11px 20px;border-radius:999px;font-weight:600">'
"Sign in</a></p>" "Sign in</a></p>"

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