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>
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>
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>
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>
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>
- 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>
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>
- 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>
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>
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>
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>
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>
- 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>
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>
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>
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>
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>
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>
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>
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>
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>
- 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>
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>
<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>
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>
- 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>
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>
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>
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>
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>
~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>
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>
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>
/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>
- 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>
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>
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>
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>
- 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>
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>
- 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>
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>
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>
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>
- 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>
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>