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