/* ============================================================
   Andrew Aquino — portfolio

   Contents
     1. Tokens      design palette, type stacks, scroller geometry
     2. Base        reset, typography, links
     3. Layout      fixed split over a virtual scroll
     4. Rail        left column: name, work history, colophon
     5. Viewer      full-height frames that flash between assets
     6. Scroller    section pills + ticks + progress wipe
     7. Mobile      static single-column fallback

   Geometry tokens in :root are read by js/core.js at init —
   tune them here, never in the JS.
   ============================================================ */

/* Untitled Serif and Untitled Sans, Klim Type Foundry: web licence (order
   26100559, aaquino.me, 5,000 monthly unique users). Regular only; the
   site sets nothing heavier or italic. */
@font-face {
  font-family: "Untitled Serif";
  src: url("assets/fonts/untitled-serif-regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Untitled Sans";
  src: url("assets/fonts/untitled-sans-regular.woff2") format("woff2");
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* 1. Tokens ------------------------------------------------- */
/* Colour tokens are registered so a theme switch eases them once, on :root
   (.theme-fade, below), and every element just follows. Per-element colour
   transitions chain down the tree (children chase their parents' easing)
   and lag, which flashed text in the old colour. */
@property --bg { syntax: "<color>"; inherits: true; initial-value: #fcfcfb; }
@property --text { syntax: "<color>"; inherits: true; initial-value: #1a1a1a; }
@property --muted { syntax: "<color>"; inherits: true; initial-value: #8a8a86; }
@property --muted-mix { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --faint { syntax: "<color>"; inherits: true; initial-value: #b5b5b0; }
@property --line { syntax: "<color>"; inherits: true; initial-value: rgba(0, 0, 0, 0.05); }
@property --line-strong { syntax: "<color>"; inherits: true; initial-value: rgba(0, 0, 0, 0.1); }
@property --hover-bg { syntax: "<color>"; inherits: true; initial-value: rgba(0, 0, 0, 0.02); }
@property --frame-bg { syntax: "<color>"; inherits: true; initial-value: #f7f7f7; }

:root {
  /* Page, text and secondary colours per theme. js/shader-look.js reads
     these (the switch's destination, never mid-ease); paint with the
     eased --bg / --text / --muted. */
  --theme-bg: #fcfcfb;
  --theme-text: #1a1a1a;
  --theme-muted: #8a8a86;
  --bg: var(--theme-bg);
  --text: var(--theme-text);
  /* Secondary text sits a set share of the way from the page to the text
     colour, so it fades with them during a theme switch. As a fixed grey
     (it's nearly the same in both themes) it held still while the page
     swept through that grey, and blinked out mid-switch. --theme-muted is
     the same grey written out, for js/shader-look.js. */
  --muted-mix: 50%;
  --muted: color-mix(in srgb, var(--text) var(--muted-mix), var(--bg));
  --faint: #b5b5b0;
  /* Hairline border (contact pills, work-history rules, hover outlines):
     the text colour's base at --line-alpha. dev/dials.js drives the alpha. */
  --line-alpha: 0.05;
  --line: rgba(0, 0, 0, var(--line-alpha));
  --line-strong: rgba(0, 0, 0, 0.1);   /* hovered scroller pill */
  --hover-bg: rgba(0, 0, 0, 0.02);   /* a whisper of fill on hovered pills */
  --frame-bg: #f7f7f7;   /* every portfolio item's backdrop */
  /* Secondary copy on text pages (lede, outline button, back arrow). */
  --soft: color-mix(in srgb, var(--text) 60%, transparent);

  --serif: "Untitled Serif", Georgia, "Times New Roman", serif;
  --sans: "Untitled Sans", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
  /* The "mono" slot (card captions) is set in Untitled Sans; the name
     stays for the role (dev/dials.js swaps it for font tests). */
  --mono: var(--sans);
  /* Footnote size: the small labels (footer, contact
     pills). dev/dials.js drives it live (Type dials, ?dials). */
  --small-size: 12px;
  --small-lh: 16px;     /* footnote leading: colophon, captions, time scrubber */
  --meta-size: 13px;    /* text-page secondary copy, time scrubber, logo badge */
  /* Footnote labels (footer, contact pills) are set in the
     serif, in their own case; the card captions keep the sans caps. */
  --footnote-font: var(--serif);
  /* Card captions: their own size, centred along the card's bottom edge;
     x is the least room kept to either side (dev/dials.js Type dials). */
  --caption-size: 9px;
  --caption-x: 16px;
  --caption-y: 12px;
  --caption-gap: 4px;   /* between the company and the feature name */

  /* Scroller geometry (read by js/core.js) */
  --seg-w: 44px;        /* section pill width */
  --seg-idle-h: 24px;   /* idle pill height; also the focused minimum */
  --seg-radius: 12px;
  --seg-cap: 14px;      /* padding above first / below last tick in a focused pill */
  --tick-w: 12px;       /* selected tick width */
  --tick-min-w: 4px;    /* width the falloff eases down to */
  --tick-h: 2px;
  --tick-gap: 12px;

  /* Asset sizing inside a frame: the media renders this much smaller than
     its frame on each axis. */
  --media-inset: 200px;     /* horizontal */
  --media-inset-y: 200px;   /* vertical — larger on mobile, where the header/footer overlay the strip */

  /* Carousel column: one width shared by cards, the lead slot, and the
     resume text cap, so they can never drift apart. dev/dials.js drives
     these live when the DialKit panel is open. */
  --col: clamp(320px, 30vw, 870px);
  --col-gap: 32px;
  /* Space above and below the bio and the cards (shared, so they top-align);
     dev/dials.js Layout dials. */
  --stage-top: 32px;
  --stage-bottom: 32px;
  /* The info (blurb + history) column reads better narrower than the
     cards: ~60ch at the body size. Dialable independently of --col. */
  --info-col: clamp(280px, 25vw, 480px);

  /* Shape */
  --radius-pill: 999px;
  --radius-lg: 16px;    /* theme toggle thumb + icons, meadow frame */

  /* Motion: colour/hover changes use plain ease; movement uses --ease-out. */
  --dur-fast: 150ms;
  --dur: 200ms;
  --ease-out: cubic-bezier(0.23, 1, 0.32, 1);

  /* --spring is injected by js/core.js: a generated spring curve as linear() */
  color-scheme: light;
}

:root[data-theme="dark"] {
  --theme-bg: #101010;
  --theme-text: #e6e6e6;
  --theme-muted: #8c8c8c;
  --muted-mix: 58%;
  --faint: #5c5c58;
  --line-alpha: 0.05;
  --line: rgba(255, 255, 255, var(--line-alpha));
  --line-strong: rgba(255, 255, 255, 0.1);
  --hover-bg: rgba(255, 255, 255, 0.02);
  --frame-bg: #171716;   /* sits between the page bg and the pills */
  color-scheme: dark;
}

/* 2. Base ---------------------------------------------------- */
* { margin: 0; padding: 0; box-sizing: border-box; }

html { scrollbar-width: none; }
html::-webkit-scrollbar, body::-webkit-scrollbar { display: none; }

body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--serif);
  /* Body type; dev/dials.js drives these live (Type dials, ?dials). */
  font-size: var(--body-size, 17px);
  line-height: var(--body-lh, 22.6px);   /* 133% */
  -webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
/* Hover system, one for everything hoverable:
   - colour moves one step on the ink scale: faint → muted → text; links
     already in the text colour soften to muted instead. Never an underline.
   - pills add the --hover-bg fill; borders hold still.
   - press is scale(0.97) on :active.
   - one timing: colour and fill --dur-fast ease, press 160ms --ease-out.
   Hover rules are gated to fine pointers so taps don't leave them stuck. */
a:hover { text-decoration: none; }
.blurb a, .socials a, .resume li a.co, #theme-toggle, #theme-toggle svg, .head .back, .button {
  transition: color var(--dur-fast) ease, background-color var(--dur-fast) ease, transform 160ms var(--ease-out);
}

/* 3. Layout -------------------------------------------------- */
/* Header / strip / footer as a fixed-height column; the page itself never
   scrolls — all movement is the strip's own horizontal overflow. */
body {
  height: 100vh;
  height: 100dvh;   /* mobile browser chrome */
  display: flex;
  flex-direction: column;
  overflow: hidden;
}

.head { padding: 32px 32px 0; }

.colophon {
  position: relative;
  padding: 0 32px 24px;
  color: var(--muted);
  font-family: var(--footnote-font);
  font-size: var(--small-size);
  line-height: var(--small-lh); /* pinned: must not inherit the larger mobile body leading */
  text-align: left;
}
#clock { font-variant-numeric: tabular-nums; }   /* the seconds tick without the line jiggling */
/* Mono set to "same as serif" (dev/dials.js Fonts): the caps labels read as
   serif text, in their own case and untracked, not as caps. */
.mono-serif .colophon, .mono-serif .socials a, .mono-serif .cardnote,
.mono-serif .applogo.badge, .mono-serif .scroll-label { text-transform: none; letter-spacing: 0; }
/* The footer bust sits left of the place and time. It's a little taller
   than the two text lines; negative margins keep it from changing the
   footer's height (the theme toggle is centred on those lines). */
.colophon { display: flex; align-items: center; gap: var(--footer-gap, 16px); }   /* head ↔ text; dialled in dev/dials.js */
/* 41px: an odd number of 1px dither cells, so the ground dots meet every
   edge cleanly (js/footer-head.js). */
.footer-head { position: relative; flex: none; width: 41px; height: 41px; margin: -4px 0 -5px; color: var(--text); }
.footer-head canvas { display: block; width: 100%; height: 100%; }
.footer-head canvas.dithered { opacity: 0; }
.footer-head canvas.dither { position: absolute; inset: 0; pointer-events: none; opacity: 0; transition: opacity 0.6s ease; }
.footer-head.ready canvas.dither { opacity: 1; }
/* Loading shimmer (js/footer-head.js): under the bust, fades as it lands. */
.footer-head canvas.shimmer { position: absolute; inset: 0; pointer-events: none; image-rendering: pixelated; transition: opacity 0.6s ease; }
.footer-head.ready canvas.shimmer { opacity: 0; }

/* 5. Viewer: a horizontal strip of cards ---------------------- */
.stage {
  position: relative;
  flex: 1;
  min-height: 0;   /* let the flex column actually size it */
  display: flex;
  /* The peel's blurred bio copies (js/main.js) slide past the right edge as
     the strip scrolls; unclipped they widen the page, and phones zoom out
     to fit it. clip, not hidden: no scroll container. Sideways only: the
     blur spreads up past the stage's top, toward the name, and clipping
     that edge cut it off in a hard line. */
  overflow-x: clip;
  overflow-y: visible;
}

.strip {
  position: relative;
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: stretch;
  gap: var(--col-gap);
  /* Vertical padding: --stage-top / --stage-bottom (the captions sit inside
     the frames, so the bottom can go tight). */
  padding: var(--stage-top) 32px var(--stage-bottom);
  overflow-x: auto;
  overflow-y: hidden;
  scroll-snap-type: none;   /* desktop scrolls freely; mobile snaps (below) */
  overscroll-behavior-x: contain;
  scrollbar-width: none;
}
.strip::-webkit-scrollbar { display: none; }

.card {
  flex: 0 0 var(--col);
  scroll-snap-align: center;
  display: flex;
  align-items: center;
  justify-content: center;
  position: relative;
  z-index: 1;   /* cards travel OVER the pinned resume panel */
}
.card.wide { flex-basis: calc(var(--col) * 2 + var(--col-gap)); }   /* two columns of the grid */

/* Lead spacer: an empty flow item holding the carousel's first snap slot
   open where the resume panel shows through. Sized like a card. */
.lead {
  flex: 0 0 var(--info-col);   /* the resume's slot tracks the info width, not the cards */
  scroll-snap-align: center;
}

/* Resume panel: fixed behind the strip in the lead slot's position — no
   frame, no background. Cards (z-index 1) slide across on top of it. */
/* No z-index here: the panel must NOT form a stacking context, so its
   anchors can raise themselves above the strip's hit-testing (the .lead
   spacer otherwise swallows every hover and click on the bio links). */
.resume {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  padding: var(--stage-top) 32px var(--stage-bottom);   /* matches .strip's, for top alignment */
}
/* The inner box is exactly as tall as the frames beside it, so the blurb's
   first line top-aligns with the cards; content flows from its top. */
.resume-inner { position: relative; width: 100%; max-width: var(--info-col); height: 100%; }
/* The peel's blurred copies of the text column (js/main.js): laid over it
   absolutely, sized and masked from script; each mask is a crossfade ramp
   intersected with the overall fade. */
/* [hidden] must win over .resume-inner's display: flex, or the copies sit
   unpositioned over the bio at rest, as ghosted double text. */
.resume-inner.peel-copy[hidden] { display: none; }
.resume-inner.peel-copy {
  position: absolute;
  max-width: none;
  margin: 0;
  pointer-events: none;
  -webkit-mask-composite: source-in;
  mask-composite: intersect;
}
.resume .blurb { margin-bottom: 1lh; }           /* one leading between paragraphs */
.resume .blurb:last-of-type { margin-bottom: 2lh; }
/* Bio links read as links by their muted color alone — no underline,
   not even on hover; hover restores the full text color. */
.blurb a { color: var(--muted); }
@media (hover: hover) and (pointer: fine) { .blurb a:hover { color: var(--text); } }
/* Contact links close the bio: a row of 28px outline pills in the
   footnote serif, muted; on hover the text goes full colour over a 2% fill. */
.socials { display: flex; gap: 8px; }
.socials a {
  display: flex;
  align-items: center;
  height: 28px;
  padding: 0 10px;
  border: 1px solid var(--line);
  border-radius: var(--radius-pill);
  color: var(--muted);
  font-family: var(--footnote-font);
  font-size: var(--small-size);
}
.socials a:active { transform: scale(0.97); }
@media (hover: hover) and (pointer: fine) { .socials a:hover { color: var(--text); background: var(--hover-bg); } }

/* Bio links sit above the strip's lead spacer, so they stay hoverable and clickable. */
.blurb a { position: relative; z-index: 2; }
/* Work history hugs the column's bottom edge (level with the cards'),
   the bio stays at the top. */
.resume-inner { display: flex; flex-direction: column; }
.resume ul { list-style: none; width: 100%; margin-top: auto; }
.resume li {
  display: flex;
  gap: 12px;
  align-items: baseline;
  padding: 10px 0 9px;
  border-bottom: 1px solid var(--line);
}
.resume .co { flex: 1; }
/* Linked rows (each company → its site, Rectangle Supply Co → rectangle/):
   clickable above the strip's lead spacer, like the bio links; muted on hover. */
.resume li a.co { position: relative; z-index: 2; }
@media (hover: hover) and (pointer: fine) { .resume li a.co:hover { color: var(--muted); } }
.resume .years { color: var(--muted); white-space: nowrap; }

/* Holder: sizes the frame and anchors the footnote to its bottom edge. */
.holder {
  position: relative;
  width: 100%;
  height: 100%;
}
.cardnote {
  position: absolute;
  /* centred along the frame's bottom; the insets are dialled in dev/dials.js */
  bottom: var(--caption-y);
  left: 50%;
  transform: translateX(-50%);
  max-width: calc(100% - 2 * var(--caption-x));
  font-family: var(--mono);
  font-size: var(--caption-size);   /* captions run smaller than the footnote labels */
  line-height: var(--small-lh);
  color: var(--text);
  white-space: nowrap;
  letter-spacing: 0.55px;
  text-transform: uppercase;   /* caps, like the footer and contact pills */
  display: flex;   /* one line: company, then feature, year */
  gap: var(--caption-gap);   /* company ↔ feature */
}
.cardnote-title { flex: none; }
/* Title keeps the primary text color; the desc truncates first, and within
   it the feature name gives way before the year. */
.cardnote-desc { display: flex; min-width: 0; color: var(--muted); }
.cardnote-year { flex: none; }
.cardnote-title, .cardnote-name { overflow: hidden; text-overflow: ellipsis; }

.frame {
  position: relative;
  width: 100%;
  height: 100%;
  border-radius: 4px;
  overflow: hidden;
  background: var(--frame-bg);
}

.frame img:not(.applogo),
.frame video {
  position: absolute;
  inset: 0;
  margin: auto;
  max-width: calc(100% - var(--media-inset));
  max-height: calc(100% - var(--media-inset-y));
  width: auto;
  height: auto;
  display: block;
  /* border-radius is set by js/render.js: 56dp (or the media's own radius) at 393px wide, scaled to rendered size */
}
/* Device bezel: a 1px ring outside the screen. A spread shadow, not a
   border, so it follows the screen's corner radius outward and leaves the
   image's size alone. Flat media (not a phone screen) goes without,
   unless it has its own rounded corners to follow (data-corner). */
.frame img:not(.applogo):not([data-flat]),
.frame video:not([data-flat]),
.frame img[data-corner] {
  box-shadow: 0 0 0 1px var(--line);
}

.card.slot .frame {
  display: grid;
  place-items: center;
  border: 1px dashed var(--faint); /* placeholder outline; filled frames are borderless */
}
.card.slot .frame p { color: var(--muted); font-family: var(--serif); font-size: var(--small-size); }

.applogo {
  position: absolute;
  top: 16px;
  left: 16px;
  width: 28px;
  height: 28px;
  border-radius: 8px;   /* keeps the 44/12 squircle ratio at the smaller size */
  z-index: 2;
  overflow: hidden;
  display: grid;
  place-items: center;
  object-fit: cover;
  display: none;   /* hidden for now — delete this line to bring the logos back */
}
.applogo.badge {
  background: var(--text);
  color: var(--bg);
  font-family: var(--mono);
  font-size: var(--meta-size);
  text-transform: uppercase;
}

/* 6. Scroller ------------------------------------------------ */
/* Scroller + its name label: parked for now — delete the two display:none
   lines to bring the whole apparatus back (see also .scroll-label below). */
.scroller {
  position: absolute;
  left: 16px;
  top: 50%;
  transform: translateY(-50%);
  z-index: 5;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  touch-action: none;
  display: none;   /* parked: overrides the flex above */
}

.section {
  position: relative;
  width: var(--seg-w);
  height: var(--seg-idle-h);
  border-radius: var(--seg-radius);
  background: var(--hover-bg);
  box-shadow: 0 0 0 0.5px var(--line);   /* outer hairline, doesn't shift layout */
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0;
  overflow: hidden;
  will-change: transform;   /* elastic overscroll animates transform per frame */
  transition: height 0.5s var(--spring), gap 0.5s var(--spring), background var(--dur-fast) ease;
}
@media (hover: hover) and (pointer: fine) { .section:not(.active):hover { background: var(--line-strong); } }
.section.active { gap: var(--tick-gap); background: var(--line); }

/* Progress wipe: fills the focused pill top-to-bottom as you scroll its slides */
.wipe {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 0;
  background: var(--bg);
  z-index: 1;
}

/* Ticks: one per slide. Idle pills collapse to a single dot;
   focused pills fan them out (widths set by js/viewer.js falloff). */
.tick {
  position: relative;
  z-index: 2;
  width: var(--tick-min-w);
  height: var(--tick-h);
  flex: 0 0 0px;
  border-radius: var(--radius-pill);
  background: var(--faint);
  opacity: 0;
  cursor: pointer;
  transition: background var(--dur-fast) ease, opacity var(--dur) ease, transform var(--dur-fast) var(--ease-out),
              width 0.5s var(--spring), flex-basis 0.5s var(--spring);
}
.tick::after {   /* hit area: one full-width band per tick, exactly the tick pitch tall */
  content: "";
  position: absolute;
  inset: calc(var(--tick-gap) / -2) -22px;
}
@media (hover: hover) and (pointer: fine) { .section.active .tick:hover { transform: scale(1.1, 2); } }
.wipe + .tick { opacity: 1; flex-basis: var(--tick-h); }  /* idle pills show their first tick as a dot */
.section:not(.active) .tick { background: var(--line); width: var(--tick-h); }
.section.active .tick { opacity: 1; flex: 0 0 var(--tick-h); }
.tick.cur { background: var(--text); }
.section.pulse .tick.cur { animation: tick-pulse 0.18s ease; }
@keyframes tick-pulse { 50% { transform: scale(1.25); } }

/* Project name pill, anchored beside the current tick while scrolling */
.scroll-label {
  display: none;   /* parked with .scroller above */
  position: absolute;
  left: 66px;   /* scroller left (16) + width (44) + 6 */
  z-index: 6;
  font-family: var(--mono);
  font-size: var(--small-size);
  letter-spacing: 0.55px;
  text-transform: uppercase;
  background: var(--text);
  color: var(--bg);
  padding: 4px 12px;
  border-radius: var(--radius-pill);
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transform-origin: left center;
  transform: translateY(-50%) translateX(-8px) scale(0.92);
  transition: opacity var(--dur) ease, transform var(--dur) var(--ease-out), top 0.5s var(--spring);
}
.scroll-label.show { opacity: 1; transform: translateY(-50%) translateX(0) scale(1); }

/* 7. Mobile -------------------------------------------------- */
@media (max-width: 900px) {
  /* The desktop inset assumes wide frames; at phone size it would swallow
     the media. */
  /* Vertical inset clears the caption in the frame's bottom corner (the
     media is centered, so it's split top and bottom). */
  :root { --media-inset: 32px; --media-inset-y: 120px; }
  .head { padding: 20px 20px 0; }
  .colophon { padding: 0 20px calc(20px + env(safe-area-inset-bottom)); }
  /* Cards fill the strip's height between header and footer. A centered card
     shares the text's 20px edge margins; the gap is smaller than that
     margin, so the next card still peeks in by the difference (12px).
     Snap is firm because a swipe should always land on a card. */
  .strip { gap: 8px; padding: 20px; scroll-snap-type: x mandatory; }
  .card, .card.wide { flex-basis: 100%; }   /* wide cards flatten to one screen on mobile */
  .resume { padding: 20px; }
  /* Full-height frames, so the info column's top edge aligns with theirs. */
  .resume-inner { max-width: none; }
  .resume .blurb:last-of-type { margin-bottom: 32px; }
  .lead { flex-basis: 100%; }
}

/* Sun | moon pill: both modes stay visible; the active one is highlighted. */
#theme-toggle {
  position: absolute;
  /* The track is the element's visual edge — align it with the
     32px (desktop) / 20px (mobile) text margin. */
  right: 32px;
  /* Center on the footer's two 16px text lines, not its padded box — the
     bottom padding (incl. mobile safe-area) would drag a 50% center down. */
  top: 16px;
  transform: translateY(-50%);
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 3px;   /* + the 1px border: 90 x 40 */
  background: none;
  border: 1px solid var(--line);   /* the hairline the pills and rules use */
  border-radius: var(--radius-pill);
  cursor: pointer;
  color: var(--faint);
}
#theme-toggle svg {
  width: 40px;   /* 32 tall + 3px pad + 1px border each side = 40px-tall control */
  height: 32px;
  padding: 9px 13px;   /* 14px icons */
  border-radius: var(--radius-lg);
}
/* Hover: the track takes the pills' --hover-bg fill and the icon under the
   pointer goes to the text colour. [data-theme] lifts it over the selected
   icon's muted rule below. */
@media (hover: hover) and (pointer: fine) {
  #theme-toggle:hover { background: var(--hover-bg); }
  :root[data-theme] #theme-toggle svg:hover { color: var(--text); }
}
/* Theme switch (js/main.js): for its moment, the colour tokens ease on
   :root from the old theme to the new (registered with @property, top of
   file), and everything painted with them follows in lockstep. Elements'
   own transitions are off meanwhile, so none of them chases the moving
   tokens and lags behind. A class rather than a View Transition, which
   would freeze the toggle's jelly thumb under a snapshot. */
:root.theme-fade {
  transition: --bg 0.45s ease, --text 0.45s ease, --muted-mix 0.45s ease, --faint 0.45s ease,
              --line 0.45s ease, --line-strong 0.45s ease, --hover-bg 0.45s ease, --frame-bg 0.45s ease;
}
.theme-fade *, .theme-fade *::before, .theme-fade *::after { transition: none !important; }
/* After the base rule so it actually wins (same specificity). */
@media (max-width: 900px) {
  #theme-toggle { right: 20px; }
}

/* The selected side: one thumb in the secondary surface that slides
   between the icons (js/main.js gives the move its jelly squash). */
#theme-toggle .thumb {
  position: absolute;
  left: 3px;
  top: 3px;
  width: 40px;
  height: 32px;
  border-radius: var(--radius-lg);
  background: var(--frame-bg);
  transition: background-color var(--dur-fast) ease;
}
/* On hover the track fills, so the thumb drops to the page colour to keep
   the selected side reading as distinct from the hover fill. */
@media (hover: hover) and (pointer: fine) { #theme-toggle:hover .thumb { background: var(--bg); } }
:root[data-theme="dark"] #theme-toggle .thumb { transform: translateX(42px); }   /* 40 + the 2px gap */
#theme-toggle svg { position: relative; }   /* icons over the thumb */
:root[data-theme="light"] #theme-toggle .icon-sun,
:root[data-theme="dark"] #theme-toggle .icon-moon {
  color: var(--muted);   /* selected: softer than the text, a step up from the faint one */
}

/* Text pages (rectangle/): one centered column, set large — 30/35 like
   jacquelinepifer.com, 26/32 on phones. */
.page { overflow: auto; }
/* Back: the homepage footer's dithered bust (js/footer-head.js) in a
   hairline circle. On hover the ring darkens; no fill, as the bust's canvas
   is painted in the page colour and would show as a disc against it.
   (Plain .back stays a text link.) */
@media (hover: hover) and (pointer: fine) { .head .back:hover { color: var(--muted); } }
.head .back.round {
  display: inline-flex;
  padding: 3px;
  border: 1px solid var(--line);
  border-radius: 50%;
  transition: border-color var(--dur-fast) ease, transform 160ms var(--ease-out);
}
.head .back.round .footer-head { margin: 0; border-radius: 50%; overflow: hidden; }
.head .back.round:active { transform: scale(0.97); }
@media (hover: hover) and (pointer: fine) { .head .back.round:hover { border-color: var(--line-strong); } }
.page-body {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 24px;
  width: 100%;
  /* The title centres in the space above; the lede and button sit at the
     bottom, the button 32px off the bottom edge (20px on phones). */
  padding: 48px 32px 32px;
  text-align: center;
  letter-spacing: -0.3px;
}
/* Title size and measure: dev/rectangle-dials.js drives these live
   (localhost); bake picks into the fallbacks. */
.page-body h1 {
  margin-block: auto;   /* centres the title; pushes the lede + button down */
  max-width: var(--title-width, 660px);
  font-weight: inherit;
  font-size: var(--title-size, 60px);
  line-height: 1.33;
  text-wrap: balance;
}
/* The lede is small, softened Untitled Sans; the button below carries
   body type in the text colour. */
.page-body .lede {
  max-width: 400px;
  color: var(--soft);
  font-family: var(--sans);
  font-size: var(--meta-size);
  line-height: 1.44;
  letter-spacing: 0;
  text-wrap: balance;
}
/* The meadow's frame above the copy: the portfolio frames' surface, with
   a rounder 16px corner that clips the canvas and its haze. */
.placeholder {
  width: min(100%, 480px);
  aspect-ratio: 3 / 2;
  border-radius: var(--radius-lg);
  background: var(--frame-bg);
  overflow: hidden;   /* clips the canvas and haze; the shadow sits outside */
  box-shadow: 0 0 64px rgba(0, 0, 0, 0.04);
}
.placeholder canvas { display: block; width: 100%; height: 100%; }
/* Filter overlay (experiments/meadow/filters.js): over the meadow, under the haze,
   and transparent to the pointer so brushing still reaches the meadow. */
.meadow-filter { position: absolute; inset: 0; pointer-events: none; }
.meadow-filter[hidden] { display: none; }
/* Light haze from the sun's corner over the meadow, coloured by the
   time-of-day slider (experiments/meadow/index.html sets the two stops). Sized like
   the radial gradient it replaced in rive/grass/scene.rml. */
.placeholder { position: relative; }
.haze {
  position: absolute;
  inset: 0;
  pointer-events: none;
  mix-blend-mode: screen;
  background: radial-gradient(113% 169% at -6% -12%,
    var(--haze-in, transparent) 0%, var(--haze-mid, transparent) 45%, transparent 100%);
}
/* Time-of-day slider under the meadow. */
.time {
  display: flex;
  align-items: center;
  gap: 12px;
  width: min(100%, 320px);
  color: var(--muted);
  font-size: var(--meta-size);
  line-height: var(--small-lh);
  letter-spacing: 0;
}
/* Endless time scrubber: a fixed centre mark over a sliding strip of
   repeating Night / Morning labels and ticks, faded at both ends. */
.scrub {
  position: relative;
  flex: 1;
  height: 40px;
  overflow: hidden;
  cursor: grab;
  touch-action: pan-y;
  user-select: none;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 22%, #000 78%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 22%, #000 78%, transparent);
}
.scrub.dragging { cursor: grabbing; }
.scrub:focus-visible { outline: 2px solid var(--text); outline-offset: 2px; border-radius: 8px; }
.scrub-strip {
  position: absolute;
  left: 50%;
  top: 0;
  bottom: 0;
  /* ticks every tenth of a stretch; 150px stretches → 15px apart */
  background: repeating-linear-gradient(90deg, var(--faint) 0 1px, transparent 1px 15px) 0 26px / 100% 8px no-repeat;
}
.scrub-strip span {
  position: absolute;
  top: 4px;
  transform: translateX(-50%);
  white-space: nowrap;
}
.scrub-mark {
  position: absolute;
  left: 50%;
  top: 22px;
  width: 2px;
  height: 16px;
  margin-left: -1px;
  border-radius: 1px;
  background: var(--text);
}
/* The frame plus its look switcher, one item in the page's stagger. */
.art {
  width: min(100%, 480px);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 16px;
}
.art .placeholder { width: 100%; }
/* Solid pill: filled with the text colour, the label in the page colour —
   black on light, white on dark. On hover the fill eases a step toward the
   page, the filled-button version of the pills' --hover-bg. */
.button {
  display: inline-flex;
  align-items: center;
  height: 56px;
  padding: 0 28px;
  font-size: var(--body-size, 17px);
  color: var(--bg);
  background: var(--text);
  border-radius: var(--radius-pill);
}
.page-body .button:active { transform: scale(0.97); }
@media (hover: hover) and (pointer: fine) { .page-body .button:hover { background: color-mix(in srgb, var(--text) 85%, var(--bg)); } }
@media (max-width: 900px) {
  .page-body { padding: 32px 20px calc(20px + env(safe-area-inset-bottom)); }
  .page-body h1 { font-size: var(--title-size-sm, 40px); }
}
/* Stagger in: title, copy, button, then the back link. "backwards" (not "both")
   so the button's hover opacity still works once the animation ends. */
@keyframes page-in {
  from { opacity: 0; transform: translateY(12px); filter: blur(6px); }
}
.page-body > *, .page .head {
  animation: page-in 0.8s var(--ease-out) backwards;
}
.page-body > :nth-child(2) { animation-delay: 0.1s; }
.page-body > :nth-child(3) { animation-delay: 0.2s; }
.page .head { animation-delay: 0.4s; }
@media (prefers-reduced-motion: reduce) {
  .page-body > *, .page .head { animation: none; }
}
