/* ---------------------------------------------------------------------------
   Folio Circular — styles
   Soft, light, neomorphic. Colour ink/bg driven live from JS:
     --bg       current background (active project's brand colour)
     --ink      darkened brand colour, body text
     --ink-soft translucent ink
   --------------------------------------------------------------------------- */

/* Self-hosted Switzer (no CDN dependency) */
@font-face { font-family: "Switzer"; src: url("./fonts/switzer-400.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; }
@font-face { font-family: "Switzer"; src: url("./fonts/switzer-500.woff2") format("woff2"); font-weight: 500; font-style: normal; font-display: swap; }
@font-face { font-family: "Switzer"; src: url("./fonts/switzer-600.woff2") format("woff2"); font-weight: 600; font-style: normal; font-display: swap; }
@font-face { font-family: "Switzer"; src: url("./fonts/switzer-700.woff2") format("woff2"); font-weight: 700; font-style: normal; font-display: swap; }
/* self-hosted (was Google Fonts — a render-blocking third-party request). Google serves this as
   TWO unicode-range-scoped files (latin + latin-ext); an earlier pass only saved the latin-ext
   subset, which has zero basic ASCII glyphs (no a-z/0-9/punctuation) — every caption silently
   fell back to the generic cursive font. Both subsets restored below, matching Google's split. */
@font-face { font-family: "Indie Flower"; src: url("./fonts/indieflower-latin.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; }
@font-face { font-family: "Indie Flower"; src: url("./fonts/indieflower-latin-ext.woff2") format("woff2"); font-weight: 400; font-style: normal; font-display: swap; unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; }

:root {
  color-scheme: light;       /* site is hard-coded light — keep scrollbars/form chrome light in dark mode */
  --bg: #ffffff;
  --ink: #1b1b1b;
  --ink-soft: rgba(27, 27, 27, 0.5);
  /* iOS-Safari toolbar-tint sample colour (see .chrome-edge). Init to the loader's dark field
     so the always-topmost edge strips are invisible against the black loader during load; the
     chrome system (setChrome/tweenChrome) then drives them to the live project colour. */
  --chrome-top: #1b1b1b;
  --chrome-bot: #1b1b1b;
  /* corner radius scale — stepped down slightly from the original literals;
     paired with corner-shape smoothing on the big surfaces further below */
  --r-card: 14px;   /* card sheet edges (intro / pv panel)  was 20 → crispier */
  --r-lg:   7px;    /* dial tiles, gallery shots — tighter, sharper (was 12) */
  --r-md:   6px;    /* glassbar, reports, bleeds (was 11) */
  --r-sm:   6px;    /* media tiles, cards, frames, sites (was 10) */
  --r-xs:   6px;    /* skill cards, buttons (was 10) */
  --pad: clamp(22px, 4.4vw, 118px);    /* was capped 76 — lifted so huge (2560/3840) viewports keep proportional insets, not a small design floating in space */
  --title-gap: clamp(190px, 20vh, 300px);   /* vertical clearance below the big morphed project title (was a hard 212px); height-responsive so 4K gets more room, ~216 at a normal 1080 desktop */
  --card-w: min(620px, 46vw);          /* persistent left card (fits one-line work rows) */
  --card-pad: clamp(28px, 3vw, 44px);  /* card's inner padding (tighter than --pad) */
  --content-x: calc(var(--card-w) + 72px);  /* left edge of the right content zone (more breathing room) */
  /* Switzer (self-hosted) FIRST so every OS renders the same face. Previously the OS system
     font led, so macOS showed SF Pro and Windows showed Segoe UI — inconsistent per platform.
     System fonts stay only as a fallback if the woff2 fails to load. */
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, system-ui,
               "Helvetica Neue", Arial, sans-serif;
}

* { box-sizing: border-box; margin: 0; padding: 0; }

/* iOS/Figma-style continuous corners ("corner smoothing") on the big STATIC surfaces.
   corner-shape reshapes the border, the overflow clip AND every box-shadow /
   inset hairline ring in one declaration — unlike clip-path, which would kill
   the shadows. Chromium 139+; Safari/Firefox simply keep plain round corners.
   TWO HARD RULES:
   1. NEVER on animated surfaces (.pv__shot, .tile, .gbtn, .glassbar) — the squircle
      clip path costs extra raster work on every transformed frame (measured), and a
      smoothed corner is invisible on a moving element anyway.
   2. corner-shape does NOT inherit — any `border-radius: inherit` pane inside a
      squircled element (sheens, rims, inset rings) must be listed here too, or it
      draws a mismatched round pane on top (the glassbar bug of 2026-07-11). */
@supports (corner-shape: squircle) {
  .intro, .pv__panel,
  .pv__tile, .pv__card,
  .pv__video-frame, .pv__video-frame::after,
  .pv__site, .pv__report, .lay-bleed, .surf-glass,
  .skill-card, .pv__g-cta, .cv__act {
    corner-shape: squircle;
  }
}

html, body {
  height: 100%;
  overflow: hidden;
  background: var(--bg);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overscroll-behavior: none;
  touch-action: none;
}
body { position: relative; cursor: default; }

.bg { position: fixed; inset: 0; background: var(--bg); z-index: -2; }
/* Safari 26 toolbar tinting: iOS 26 Safari ignores theme-color and instead samples the
   background-color of a fixed element near a viewport edge (it beats <body>, and WebKit
   live-updates as the colour changes). These bars must be VISIBLE and high z-index to win
   the sample over our full-screen fixed layers (.bg / .pv). They're kept invisible to the
   user by matching the page colour and hugging the extreme edges (top bar under the status
   bar; bottom bar mostly off-screen under the address bar). No backdrop-filter (disqualifies). */
.chrome-edge {
  position: fixed; left: 0; right: 0;
  /* MUST be the topmost fixed element at each edge: Safari samples the TOPMOST fixed element,
     so if anything sits over the bars it samples that (no colour) and freezes. This includes the
     preload loader (z2000) — before this bump the loader occluded the strips and iOS stopped
     re-tinting once the loader had appeared. 2001 keeps them above the grain (z20) AND the loader.
     Kept plain solid + thin (subtle enough); the grain-match experiment didn't help. */
  z-index: 2001; pointer-events: none;
  display: none;   /* only iOS Safari has the toolbar-tint sampling; hide everywhere else */
  /* NO opacity:0 — an invisible fixed element is NOT sampled for the toolbar tint on-device (Round 21
     killed the tint entirely). The strips stay opaque so they're sampled; the pokeChrome display-toggle
     flash is masked instead by the same-colour .chrome-edge--*-back backer strips one z-index below
     (created in JS at init), so the toggle's brief display:none gap reveals the matching colour, not --bg. */
}
.ios-safari .chrome-edge { display: block; }
/* The BOTTOM strip works (it straddles the address-bar edge). The TOP tint was NOT updating
   because a 5px strip at top:0 sits BEHIND the status bar, above where Safari samples the top
   tint. Fill the whole status-bar region (safe-area-inset-top) so the sample lands on the strip.
   The drawer handle sits at inset+8px (below this), so nothing interactive is covered; the strip
   matches --chrome-top so it reads seamlessly against the page. */
/* no CSS transition here: the home dial rewrites --chrome-top every frame (must track in
   sync), and jumps ease the colour in JS via tweenChrome so Safari re-samples. */
.chrome-edge--top { top: 0; height: max(6px, env(safe-area-inset-top, 0px)); background: var(--chrome-top, var(--bg)); }
.chrome-edge--bot { bottom: -8px; height: 12px; background: var(--chrome-bot, var(--bg)); }
/* Same-colour BACKER strips, one z-index BELOW the samplers. pokeChrome display-toggles the samplers to
   force iOS to re-sample the toolbar tint; during that brief display:none the sampler vanishes and --bg
   shows through behind it — a one-frame flash on every project switch. These backers are the SAME colour
   and are NEVER toggled, so the gap reveals the matching colour instead of --bg. iOS-Safari only; the
   elements are created in JS at init (index.html is owned elsewhere). */
.chrome-edge--top-back, .chrome-edge--bot-back {
  position: fixed; left: 0; right: 0; z-index: 2000; pointer-events: none; display: none;
}
.chrome-edge--top-back { top: 0; height: max(6px, env(safe-area-inset-top, 0px)); background: var(--chrome-top, var(--bg)); }
.chrome-edge--bot-back { bottom: -8px; height: 12px; background: var(--chrome-bot, var(--bg)); }
.ios-safari .chrome-edge--top-back, .ios-safari .chrome-edge--bot-back { display: block; }

/* Fullscreen preload loader — the real dial mechanic, spun through numbers 1–100 as assets load,
   then it hands off to the actual project dial. Nodes are positioned by JS (bootLoader); the loader
   rAF is cancelled and the element removed on dismiss, so nothing lingers. */
.loader {
  position: fixed; inset: 0; z-index: 2000;
  background: #1b1b1b;   /* dark field for the white count; dismiss() tints it to the live page colour before the fade */
  /* On dismiss the bg eases to the resting project colour (JS) WHILE the overlay fades — so the
     reveal is same-colour-over-same-colour (no dark→bright peel), not a dark overlay lifting off a
     bright page. The old build hardcoded a dark bg + assumed --bg was dark; the dial actually rests
     on a project hue, so the fade brightened the whole screen in one jolt. */
  transition: opacity 0.7s ease, background-color 0.5s ease;
}
.loader.is-done { opacity: 0; pointer-events: none; }
.loader__wheel { position: absolute; inset: 0; overflow: hidden; }
/* nodes anchor by their RIGHT edge + vertical centre so numbers ride the arc like the dial words */
.loader__num { position: absolute; left: 0; top: 0; transform-origin: 0 0; will-change: transform, opacity; }
.loader__num > span {
  position: absolute; right: 0; top: 0; transform: translateY(-50%);
  font-weight: 500; letter-spacing: -0.04em; line-height: 1; white-space: nowrap;
  color: #fff; font-variant-numeric: tabular-nums;
  font-size: clamp(72px, 15vw, 220px);
}

/* Text selection picks up the live project colour (set per-scroll in JS).
   Left card uses the project colour; the project view (which already IS that
   colour) uses the contrasting ink so the highlight still reads. */
::selection      { background: var(--sel-bg, #1b1b1b); color: var(--sel-ink, #fff); }
::-moz-selection { background: var(--sel-bg, #1b1b1b); color: var(--sel-ink, #fff); }
.pv ::selection      { background: var(--sel-bg-alt, #1b1b1b); color: var(--sel-ink-alt, #fff); }
.pv ::-moz-selection { background: var(--sel-bg-alt, #1b1b1b); color: var(--sel-ink-alt, #fff); }

/* Light-fall: a soft top-light + gentle base shade over the colour field, so the
   right side reads as a LIT, dimensional surface rather than flat paint. Pure
   white/black alpha, so it lightens/darkens whatever project colour is beneath.
   On home it rides .bg (the card covers the left); in a project it rides .pv. */
.bg::after, .pv::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background:
    radial-gradient(130% 90% at 75% -10%, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0) 55%),
    radial-gradient(120% 120% at 50% 130%, rgba(0, 0, 0, 0.10), rgba(0, 0, 0, 0) 60%);
}

/* ----------------------------------------------- persistent left identity card */
/* Always present (home + project). Full-height, flush-left, rounded right edge
   + a line + soft shadow → reads as a separate card. Never covered (z18). */
.intro {
  position: fixed;
  top: 0; bottom: 0; left: 0;
  width: var(--card-w);
  z-index: 18;
  padding: var(--card-pad);
  background: linear-gradient(168deg, #ffffff, #f3f3ee);
  border-right: 1px solid rgba(0, 0, 0, 0.1);
  border-radius: 0 var(--r-card) var(--r-card) 0;
  box-shadow:
    inset -1.5px 0 0 rgba(255, 255, 255, 0.85),   /* lit inner right edge */
    22px 0 60px rgba(0, 0, 0, 0.07);
  /* flex column: a scrolling body + the credit pinned as a sticky footer below it.
     The card itself never scrolls (overflow:hidden) — only .intro__body does, so its
     content can never slide over the credit. */
  display: flex;
  flex-direction: column;
  overflow: hidden;
}
/* scrolling content region (avatar … bits). Hidden scrollbar; the wheel/drag handlers
   exempt .intro so this never fights the dial. */
.intro__body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overflow-x: hidden;
  scrollbar-width: none;
  overscroll-behavior: contain;
  /* breathing room so the .plastic drop-shadows (skill cards etc.) aren't clipped by the
     scroll container's edge; the negative margin keeps content in the same place. */
  padding: 6px 18px;
  margin: -6px -18px;
}
.intro__body::-webkit-scrollbar { display: none; }
/* soft fold at the BOTTOM edge always (so the card's cut-off dissolves, not a hard line) */
.intro__body {
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 30px), transparent 100%);
          mask-image: linear-gradient(to bottom, #000 calc(100% - 30px), transparent 100%);
}
/* once scrolled, also dissolve the TOP edge */
.intro__body.is-scrolled {
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 28px, #000 calc(100% - 30px), transparent 100%);
          mask-image: linear-gradient(to bottom, transparent 0, #000 28px, #000 calc(100% - 30px), transparent 100%);
}

/* cursor-tracked specular sheen — light catching the card surface (liquid glass).
   z-index:-1 keeps it above the card's gradient (which it blends with) but below
   all content, so the text stays crisp. */
.intro__sheen {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: var(--sheen, 0);
  transition: opacity 0.4s ease;
  background: radial-gradient(220px circle at var(--mx, 50%) var(--my, 0%),
    rgba(255, 255, 255, 0.55), rgba(255, 255, 255, 0) 70%);
  mix-blend-mode: soft-light;
}
/* very subtle wash of the current project colour spilling onto the right edge */
.intro::after {
  content: '';
  position: absolute;
  top: 0; right: 0; bottom: 0;
  width: 72px;
  z-index: -1;
  pointer-events: none;
  background: linear-gradient(to left,
    color-mix(in srgb, var(--proj, transparent) 12%, transparent), transparent);
}

/* back control — lives on the card, only shown when a project is open */
.card-back {
  position: fixed;                 /* body-level now; pinned to the card's top-right via --card-w */
  z-index: 30;                     /* above the card (z18) + project view (z15) */
  top: var(--card-pad);
  left: calc(var(--card-w) - var(--card-pad) - 42px);
  width: 42px;
  height: 42px;
  display: grid;
  place-items: center;
  border: 1px solid rgba(255, 255, 255, 0.35);
  border-radius: 50%;
  cursor: pointer;
  color: var(--ink);
  isolation: isolate;
  background: rgba(255, 255, 255, 0.12);   /* liquid glass — blurs the content beneath, takes its tone */
  -webkit-backdrop-filter: blur(9px) saturate(160%);
  backdrop-filter: blur(9px) saturate(160%);
  box-shadow:
    0 8px 22px rgba(0, 0, 0, 0.16),
    inset 0 1px 1px rgba(255, 255, 255, 0.5),
    inset 0 -2px 4px rgba(255, 255, 255, 0.12);
  transform: scale(0);           /* pops + scales in when a project opens */
  transform-origin: center;
  pointer-events: none;
  transition: transform 0.42s cubic-bezier(0.34, 1.56, 0.64, 1), background 0.25s ease;
}
.card-back.is-open { transform: scale(1); pointer-events: auto; }
.card-back:hover { background: rgba(255, 255, 255, 0.32); }

/* ---- reveal-drawer affordances (shown only in drawer mode, ≤1535px) ---- */
/* avatar spine tab: a slim handle on the left edge that reads as "profile" */
.card-tab { display: none; }
.card-scrim { display: none; }
.intro__grab { display: none; }   /* mobile-only bottom grab strip (shown in the ≤767px block) */

.hi-row { position: relative; width: 104px; height: 104px; margin-bottom: 22px; }

.hi-name {
  margin: 0 0 18px;
  font-weight: 600;
  font-size: clamp(26px, 2.4vw, 34px);
  letter-spacing: -0.03em;
  line-height: 1;
  color: var(--ink);
}

.avatar {
  width: 104px;
  height: 104px;
  border-radius: 50%;
  border: none;
  padding: 0;
  cursor: pointer;
  position: relative;
  overflow: hidden;
  background: var(--ink-soft);
  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.12);
  transition: transform 0.5s cubic-bezier(0.22, 0.61, 0.36, 1);
}
.avatar::after { content: ''; position: absolute; inset: 0; z-index: 2; border-radius: 50%; pointer-events: none; box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); }   /* 1px inset ring (10% black), circle kept */
/* no hover scale — it resized the circle and caused enter/leave flicker (image spam) */
.avatar.pop { transform: scale(1.03); }
.avatar__img {
  position: absolute; inset: 0;
  background-repeat: no-repeat;
  /* face crop = one predictable knob, set per-image in JS:
     background-size (zoom) + background-position (centres on the face). */
  background-size: cover;
  background-position: center;
  will-change: transform;
  transition: transform 0.28s ease-out;   /* smooth parallax follow + return */
}
/* memoji spam Easter egg. The wrapper (.avatar__egg) IS the circle — it owns the border-radius,
   the overflow clip and the bounce animation. The video inside (.avatar__egg-vid) only frames the
   memoji. Putting the circle on the wrapper (sized exactly like the parent .avatar) means ONE clean
   circular edge: no 1px seam from the video carrying its own slightly-larger, misaligned circle.
   Sits above the photo AND above the inset ring (.avatar::after, z-index 2) — that ring is a 1px 10%
   black rim that's invisible on the photo but reads as a grey ring on the flat-white egg, so the egg
   covers it (z-index 3). Pure-CSS in/out, no rAF. */
.avatar__egg {
  position: absolute; inset: -2px;   /* overfill the parent by 2px so the parent circle is the ONLY
                                        clipped edge — kills the 1–2px seam/gap ring at the rim */
  z-index: 3;
  border-radius: 50%;
  overflow: hidden;                 /* the circle mask — clips the framed video; stays circular as it scales */
  background: #fff;                 /* same white as the clip's baked bg — fills right to the rim so no
                                       pixel of the photo shows through at the edge, ever */
  transform-origin: 50% 50%;
  /* gentle, natural bounce-in (settle from a small overshoot — no rotation, nothing showy) */
  animation: avatar-egg-in 340ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.avatar__egg-vid {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  object-position: center;          /* horizontal centre; vertical framing is the transform below */
  /* framing only (static): overscale covers the circle edge, +4.5px drops his face to sit right.
     The clip already has a clean white background baked from the memoji's true alpha (and the wrapper
     is white too), so the video needs no border-radius of its own — the wrapper does all the clipping,
     and any area the memoji doesn't cover just reads as the shared white. translateZ(0) + will-change
     put the video on its OWN compositing layer the instant it's created, so starting playback doesn't
     force ancestors to re-composite — that mid-play re-composite is what flashed the drawer card's
     right-edge box-shadow over the scrim. */
  transform: scale(1.08) translateY(4.5px) translateZ(0);
  will-change: transform;
  backface-visibility: hidden;
}
/* While the egg is showing, remove the grey inset rim (.avatar::after) and make the backing white,
   so the white circle reads perfectly clean to the edge — no 1px ring. The egg is above the rim
   anyway (z-index 3), but hiding it outright is bulletproof against sub-pixel bleed. */
.avatar:has(.avatar__egg)::after { box-shadow: none; }
.avatar:has(.avatar__egg) { background: #fff; }
.avatar__egg.is-out { animation: avatar-egg-out 300ms cubic-bezier(0.4, 0, 0.4, 1) both; }
@keyframes avatar-egg-in {
  0%   { opacity: 0; transform: scale(0.86); }
  60%  { opacity: 1; transform: scale(1.03); }   /* small overshoot */
  100% { opacity: 1; transform: scale(1); }
}
@keyframes avatar-egg-out {
  0%   { opacity: 1; transform: scale(1); }
  30%  { opacity: 1; transform: scale(1.03); }   /* tiny anticipation */
  100% { opacity: 0; transform: scale(0.9); }
}

.bio {
  font-weight: 400;
  font-size: clamp(15px, 1.35vw, 17px);
  line-height: 1.55;
  letter-spacing: -0.006em;
  max-width: min(520px, 100%);
  margin-bottom: 32px;
  color: var(--ink);
  text-wrap: pretty;         /* no single-word orphan lines */
}

/* keyboard focus — visible ring on every interactive custom control */
.avatar:focus-visible, .card-tab:focus-visible, .gbtn:focus-visible,
.exp__co:focus-visible, .card-back:focus-visible, .cv__act:focus-visible,
.pv__site:focus-visible, .pv__book:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}
/* the open drawer's click-to-close scrim should read as clickable */
body.card-open .card-scrim { cursor: pointer; }

/* hairline divider — ends roughly where the furthest experience pill does */
.rule {
  height: 1px;
  width: min(535px, 100%);
  /* grey lives in the colour, not element opacity — the card-line entrance
     animation ends at opacity:1 (fill-mode:both) and would override it */
  background: rgba(27, 27, 27, 0.14);
  margin-bottom: 30px;
}

/* global reusable subheader */
.subhead {
  font-weight: 600;
  font-size: 19px;
  letter-spacing: -0.015em;
  color: var(--ink);
  margin: 0 0 16px;
}

/* work experience — period · role at · brand pill */
.exp__title { margin-bottom: 22px; }
.exp { margin-bottom: 34px; }   /* space before the next divider */

/* "plastic" white surface — subtle gradient + lit edge, reused across cards/tiles */
.plastic {
  background: linear-gradient(165deg, #ffffff, #f5f5f1);
  box-shadow:
    inset 0 0 0 1px rgba(0, 0, 0, 0.1),          /* 1px inset ring (10% black) instead of a border — sharper edge */
    inset 0 -8px 18px rgba(0, 0, 0, 0.025),
    0 14px 32px rgba(0, 0, 0, 0.07),
    0 1px 4px rgba(0, 0, 0, 0.04);
}

/* skills — label cards (no thumbnails this round) */
.skills { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin-bottom: 28px; }  /* breathing room before the divider */
.skill-card {
  border-radius: var(--r-xs);
  padding: 12px 14px;
  display: flex;
  align-items: center;
  justify-content: flex-start;   /* icon + label, left-aligned — reads more considered */
  gap: 11px;
  min-height: 52px;
  cursor: default;
  transition: transform 0.26s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.26s ease;
}
.skill-card__icon {
  display: inline-flex;
  flex: none;
  color: var(--ink-soft);
  transition: color 0.26s ease, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.skill-card__icon svg { width: 19px; height: 19px; display: block; }
.skill-card__label {
  font-weight: 500;
  font-size: 14px;
  letter-spacing: -0.012em;
  line-height: 1.15;
  color: var(--ink);
}
/* hover: gentle lift, the icon picks up the card's accent + a faint accent ring */
.skill-card:hover {
  transform: translateY(-2px);
  box-shadow:
    0 7px 16px rgba(0, 0, 0, 0.08),
    0 0 0 1px color-mix(in srgb, var(--accent) 22%, transparent);
}
.skill-card:hover .skill-card__icon { color: var(--accent); transform: scale(1.06); }
.exp { list-style: none; display: flex; flex-direction: column; gap: 21px; }
.exp__row {
  display: grid;
  grid-template-columns: 116px 1fr;
  align-items: center;
  column-gap: 18px;
}
.exp__when {
  font-weight: 450;
  font-size: 14px;
  letter-spacing: -0.005em;
  color: var(--ink-soft);
}
.exp__main { display: flex; align-items: center; gap: 11px; flex-wrap: nowrap; }
.exp__role {
  font-weight: 500;
  font-size: 16px;
  letter-spacing: -0.014em;
  color: var(--ink);
  white-space: nowrap;
}
/* brand pills — stroked + a little depth; now real links that lift on hover */
.exp__co {
  display: inline-block;
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-weight: 500;                /* Switzer Medium */
  font-size: 15px;
  letter-spacing: -0.006em;
  padding: 6px 11px;               /* tighter — the text fills more of the button */
  border-radius: var(--r-xs);
  white-space: nowrap;
  text-decoration: none;
  cursor: pointer;
  border: 1px solid transparent;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.4);
  transition: transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.22s ease;
}
.exp__co:hover {
  transform: translateY(-2px);
  box-shadow: 0 6px 14px rgba(0, 0, 0, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
.exp__co--tfp      { background: linear-gradient(165deg, #ffffff, #f5f5f1); color: #1b1b1b; border-color: rgba(0, 0, 0, 0.12); }
.exp__co--centuria { background: #1b1b1b; color: #ffffff; border-color: rgba(255, 255, 255, 0.16); }
.exp__co--sunday   { background: #1c3d18; color: #F5FA6B; border-color: rgba(245, 250, 107, 0.4); }
.exp__co--apple    { background: linear-gradient(165deg, #f6f6f6, #efefef); color: #1b1b1b; border-color: rgba(0, 0, 0, 0.12); }
/* dark pills: the shared white inset highlight reads as a second, thicker top
   stroke on dark fills — keep only a whisper of it so the ring stays even */
.exp__co--centuria, .exp__co--sunday {
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.exp__co--centuria:hover, .exp__co--sunday:hover {
  box-shadow: 0 6px 14px rgba(0, 0, 0, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.1);
}

/* skill icon micro-animations — super subtle, each with its OWN mechanic (not just scale):
   circles ORBIT a third-turn (trade places) · diamonds SPIN individually · layers SPREAD ·
   a bright segment SWEEPS around the infinity on a loop. Hover pointers only. */
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  .skill-card svg { overflow: visible; }
  .skill-card svg, .skill-card svg * { transform-box: fill-box; transform-origin: center; }
  /* Product Design: the trio orbits one position clockwise (whole-glyph rotation reads as
     the circles trading places; 120° = visually identical start/end) */
  .skill-card:hover svg:has(.ic-breathe) { animation: icOrbit 0.7s cubic-bezier(0.34, 1.3, 0.5, 1); }
  /* Brand & Identity: each diamond does a quick quarter-spin in place, one after another */
  .skill-card:hover .ic-beat { animation: icSpin 0.55s ease-in-out; }
  .skill-card:hover .ic-beat:nth-of-type(2) { animation-delay: 0.08s; }
  .skill-card:hover .ic-beat:nth-of-type(3) { animation-delay: 0.16s; }
  .skill-card:hover .ic-beat:nth-of-type(4) { animation-delay: 0.24s; }
  /* Design Systems: the stack breathes apart and settles */
  .skill-card:hover .ic-plane { animation: icLiftUp 0.5s ease-out; }
  .skill-card:hover .ic-chev2 { animation: icLiftDown 0.5s ease-out; }
  /* Motion: a bright accent segment sweeps continuously around the infinity loop
     (stroke-dash on a normalised pathLength=100 overlay; the base path dims to a track) */
  .ic-sweep { stroke: var(--accent); stroke-dasharray: 20 80; stroke-dashoffset: 0; opacity: 0; transition: opacity 0.2s ease; }
  .skill-card:hover .ic-inf { opacity: 0.3; }
  .skill-card:hover .ic-sweep { opacity: 1; animation: icSweep 1.3s linear infinite; }
}
@keyframes icOrbit { to { transform: rotate(120deg); } }
@keyframes icSpin { 50% { transform: rotate(90deg); } to { transform: rotate(90deg); } }
@keyframes icLiftUp { 50% { transform: translateY(-9px); } }
@keyframes icLiftDown { 50% { transform: translateY(9px); } }
@keyframes icSweep { to { stroke-dashoffset: -100; } }

/* ----------------------------------------- bits of my life — polaroid carousel */
/* horizontal scroll of polaroid cards; drop a photo onto .polaroid__img later via
   background-image (each is wired with background-size:cover). */
.bits {
  display: flex;
  gap: 16px;
  overflow-x: auto;
  overflow-y: hidden;
  padding: 8px 2px 12px;       /* vertical room for the rotated cards */
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
  cursor: grab;
  touch-action: pan-y;
}
.bits.grabbing { cursor: grabbing; }
.bits::-webkit-scrollbar { display: none; }
.polaroid {
  flex: 0 0 auto;
  width: 150px;
  margin: 0;
  padding: 6px 6px 0;          /* thin even frame; the image fills more of the card */
  border-radius: 5px;
  background: linear-gradient(165deg, #ffffff, #f4f4ef);
  border: 1px solid rgba(255, 255, 255, 0.9);
  /* tight, soft shadow so it isn't visibly clipped by the card's overflow:hidden */
  box-shadow: 0 5px 12px rgba(0, 0, 0, 0.08), 0 1px 2px rgba(0, 0, 0, 0.05);
  scroll-snap-align: start;
  transition: transform 0.32s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.polaroid { transform: rotate(var(--rot, 0deg)); }   /* --rot is set per-card in JS so clones keep their tilt */
.polaroid:hover { transform: rotate(0deg) translateY(-5px); }
.polaroid__img {
  display: block;
  width: 100%;
  aspect-ratio: 1 / 1.04;      /* a touch taller — takes more of the top */
  border-radius: 2px;
  background: linear-gradient(135deg, #e8e8e2, #d7d7cf);
  background-size: cover;
  background-position: center;
}
.polaroid__cap {
  font-family: "Indie Flower", "Segoe Script", cursive;
  font-size: 16px;
  font-weight: 400;
  line-height: 1.1;
  text-align: left;            /* bottom-left, handwritten */
  color: var(--ink-soft);
  padding: 6px 5px 9px;        /* the deeper bottom lip */
}

/* --------------------------------------------------- the carousel (on its side) */
/* The circular track is invisible. Words ride along it and stay upright —
   they translate along the arc, they never rotate. */
.ring { position: fixed; inset: 0; z-index: 3; }
/* credit lives on the identity card (bottom-right, across from the Contact pill); the card is
   always light, so it's just black. z19 to sit above the card + persist like the glassbar. */
.credit {
  flex: none;              /* sticky footer of the card — sits below the scrolling body */
  margin: 0; padding-top: 16px;
  text-align: right; font-size: 11.5px; font-weight: 500; line-height: 1.5;
  letter-spacing: -0.01em; color: #1b1b1b;
}
.credit__me { position: relative; font-weight: 700; cursor: pointer; }
.credit__tip {
  position: absolute; bottom: calc(100% + 8px); right: 0; white-space: nowrap;
  display: inline-flex; align-items: center; gap: 6px;
  padding: 6px 11px; border-radius: var(--r-lg); background: #1f1b18; color: #fff;
  font-weight: 600; font-size: 11px; letter-spacing: -0.01em;
  border: 1px solid rgba(217, 119, 87, 0.55);
  box-shadow: 0 0 18px rgba(217, 119, 87, 0.5), 0 8px 20px rgba(0, 0, 0, 0.3);
  opacity: 0; transform: translateY(4px); pointer-events: none;
  transition: opacity 0.2s ease, transform 0.2s ease;
}
.credit__tip-ic {                      /* the fun bit: the Claude mark in Claude orange */
  width: 13px; height: 13px; flex: none; background: #d97757;
  -webkit-mask: url("https://cdn.jsdelivr.net/npm/simple-icons@13/icons/claude.svg") center / contain no-repeat;
  mask: url("https://cdn.jsdelivr.net/npm/simple-icons@13/icons/claude.svg") center / contain no-repeat;
}
.credit__me:hover .credit__tip, .credit__me:focus .credit__tip { opacity: 1; transform: none; }

/* Each word is a spoke: a 0-size anchor sits on the wheel rim and the label
   hangs off to the left, its RIGHT edge pinned to the anchor (so the words
   right-align along the arc). */
.project {
  position: absolute;
  top: 0; left: 0;
  transform-origin: 0 0;            /* pivot at the rim point (the word's right edge) */
  will-change: transform;
}
.project__label {
  position: absolute;
  right: 0; top: 0;
  transform: translateY(-50%);
  white-space: nowrap;
  font-weight: 500;
  letter-spacing: -0.04em;
  line-height: 1;
  cursor: pointer;
  will-change: opacity, color;
  user-select: none;
  -webkit-user-select: none;
}

/* -------------------------------------------------- per-project floating tiles */
.tiles {
  position: fixed;
  inset: 0;
  z-index: 1;                /* behind the words (so text stays readable) */
  pointer-events: none;
}
.tile {
  position: absolute;
  top: 0; left: 0;
  width: clamp(124px, 13vw, 200px);
  aspect-ratio: 1 / 1.08;
  border-radius: var(--r-lg);
  box-shadow: 0 22px 44px rgba(0, 0, 0, 0.18), 0 3px 8px rgba(0, 0, 0, 0.09),
              inset 0 1px 0 rgba(255, 255, 255, 0.5);
  will-change: transform, opacity;
}

/* --------------------------------------------------------------- scroll hint */
.hint {
  position: fixed;
  bottom: var(--pad);
  left: var(--pad);
  z-index: 5;
  font-size: 11px; font-weight: 500;
  letter-spacing: 0.22em; text-transform: uppercase;
  color: var(--ink-soft);
  display: flex; align-items: center; gap: 10px;
  transition: opacity 0.6s ease;
}
.hint__bar {
  display: inline-block; width: 1px; height: 24px;
  background: currentColor; transform-origin: top;
  animation: hintPulse 2s ease-in-out infinite;
}
@keyframes hintPulse {
  0%, 100% { transform: scaleY(0.35); opacity: 0.35; }
  50%      { transform: scaleY(1);    opacity: 0.9; }
}
.hint { display: none; }   /* the looping wheel doesn't need a scroll cue */

/* ------------------------------------------------------ liquid-glass link bar */
.glassbar {
  position: fixed;
  will-change: transform;   /* the bar itself morphs with the button springs (glassLoop) */
  left: var(--card-pad);     /* aligned with the card's content edge — part of the left system */
  bottom: var(--card-pad);
  z-index: 19;   /* above the identity card + project view so the pill persists everywhere */
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 7px;
  border-radius: var(--r-md);   /* rounded-rect to match the cards/tiles (not a full pill) */
  isolation: isolate;
  background: rgba(255, 255, 255, 0.14);
  -webkit-backdrop-filter: blur(9px) saturate(160%);
  backdrop-filter: blur(9px) saturate(160%);
  box-shadow:
    0 18px 44px rgba(0, 0, 0, 0.18),
    0 2px 6px rgba(0, 0, 0, 0.08),
    inset 0 1px 1px rgba(255, 255, 255, 0.55),
    inset 0 -6px 14px rgba(255, 255, 255, 0.12),
    inset 0 -1px 2px rgba(0, 0, 0, 0.06);
}
/* Contact copy-to-clipboard confirmation — a small dark pill just above the glassbar. A single
   element is reused + removed after it dismisses (app.js wireContactCopy). */
.toast {
  position: fixed;
  /* left/bottom are set live by JS (wireContactCopy) to sit centred above the Contact button;
     CSS stays transform-only + center-anchored so the two never fight. */
  z-index: 21;   /* above the glassbar (19) */
  padding: 8px 14px;
  border-radius: var(--r-md);
  background: #1b1b1b;
  color: #fff;
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
  font-weight: 500; font-size: 13.5px; line-height: 1; letter-spacing: -0.01em;
  white-space: nowrap;
  box-shadow: 0 12px 30px rgba(0, 0, 0, 0.26), 0 2px 6px rgba(0, 0, 0, 0.14);
  opacity: 0;
  transform-origin: bottom center;
  transform: translateX(-50%) translateY(8px);
  transition: opacity 0.24s ease, transform 0.24s cubic-bezier(0.22, 1, 0.36, 1);
  pointer-events: none;
}
.toast.is-on { transform: translateX(-50%) translateY(0); opacity: 1; }
/* glossy highlight that follows the cursor (driven from JS via --hx/--hy/--glow) */
.glassbar__hl {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  opacity: calc(var(--glow, 0) * 0.5);   /* softer — the cursor sheen was a bit strong */
  transition: opacity 0.35s ease;
  background: radial-gradient(120px circle at var(--hx, 50%) var(--hy, 120%),
    rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0) 62%);
  mix-blend-mode: screen;
}
/* specular sheen — an even top highlight (not a left-biased diagonal, which read as a
   white smear on one side over coloured backgrounds) */
.glassbar::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(to bottom, rgba(255, 255, 255, 0.28), rgba(255, 255, 255, 0) 58%);
}
/* bright refractive rim — gradient border via mask */
.glassbar::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  padding: 1.2px;
  background: linear-gradient(135deg,
    rgba(255, 255, 255, 0.95),
    rgba(255, 255, 255, 0.3) 38%,
    rgba(0, 0, 0, 0.06) 72%,
    rgba(255, 255, 255, 0.5) 100%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
}
.gbtn {
  position: relative;
  z-index: 1;               /* above the bar's ::before sheen / ::after rim, always */
  will-change: transform;   /* the cursor-spring squish — keep the layer promoted so hover onset never janks */
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  height: 44px;
  padding: 0 19px;
  border-radius: var(--r-md);   /* matches the glassbar's token so every gbtn (incl. --primary) reads consistent */
  font-size: 14px;
  font-weight: 500;
  letter-spacing: -0.01em;
  color: var(--ink);
  text-decoration: none;
  white-space: nowrap;
  transform-origin: center;
  will-change: transform;     /* transform is driven live from JS (cursor squish) */
  transition: background 0.25s ease, color 0.25s ease;
}
.gbtn:hover { background: rgba(255, 255, 255, 0.5); }
/* icon buttons follow the background contrast (dark bg -> white, light bg -> black),
   driven by --glass-ink from JS. The primary Contact button keeps its own colours. */
.gbtn--icon { width: 44px; padding: 0; color: var(--glass-ink, #1b1b1b); }
.gbtn--primary {
  color: #fcfcfa;
  background: #1b1b1b;
  padding: 0 18px;
  box-shadow: 0 3px 10px rgba(0, 0, 0, 0.24), inset 0 1px 0 rgba(255, 255, 255, 0.14);
}
.gbtn--primary:hover { background: #000; color: #fff; }

/* hover tooltip above the icon buttons */
.gbtn[data-tip]::after {
  content: attr(data-tip);
  position: absolute;
  bottom: calc(100% + 12px);
  left: 50%;
  transform: translateX(-50%) translateY(5px);
  padding: 5px 10px;
  border-radius: var(--r-lg);
  background: rgba(26, 26, 28, 0.94);
  color: #fff;
  font-size: 12px;
  font-weight: 500;
  letter-spacing: -0.01em;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  z-index: 5;
  transition: opacity 0.18s ease, transform 0.18s ease;
}
.gbtn[data-tip]::before {
  content: '';
  position: absolute;
  bottom: calc(100% + 7px);
  left: 50%;
  transform: translateX(-50%) translateY(5px);
  border: 5px solid transparent;
  border-top-color: rgba(26, 26, 28, 0.94);
  opacity: 0;
  pointer-events: none;
  z-index: 5;
  transition: opacity 0.18s ease, transform 0.18s ease;
}
.gbtn[data-tip]:hover::after,
.gbtn[data-tip]:hover::before { opacity: 1; transform: translateX(-50%) translateY(0); }

/* ----------------------------------------------------- project view (wipe) */
/* only the wheel fades under the wipe — the identity card never moves/fades */
.ring { transition: opacity 0.5s ease; }

/* mustard field; the persistent card (z18) covers the left, so it shows in the
   right zone only. */
/* No wipe: the right section is already the project's colour, so the pv just
   shows/hides. (Removing the clip-path also un-clips the genie canvas inside it.) */
.pv {
  position: fixed;
  inset: 0;
  z-index: 15;
  background: var(--pv-bg, #ffcc00);
  color: var(--pv-ink, #1b1b1b);
  --foldH: clamp(86px, 12vh, 140px);   /* editorial fold height (fade + blur band) */
}
.pv[hidden] { display: none; }

/* morph clone — the wheel word that becomes the title (created in JS) */
.pv__title-clone {
  position: fixed;
  margin: 0;
  font-weight: 500;
  letter-spacing: -0.04em;
  line-height: 1;
  white-space: nowrap;
  z-index: 16;
  pointer-events: none;
  will-change: transform;
}
/* Additional Projects: the title is LIQUID GLASS TEXT — the letterforms themselves, not a pill.
   It flies in as near-solid white, then "converts to glass" when .is-lit is added at doReveal:
   the fill goes translucent (sky shows faintly through), a crisp glass rim appears, and layered
   shadows give a bevel + keep it legible over bright cloud. All properties are transitionable. */
.pv__title-clone--glass {
  color: rgba(255, 255, 255, 0.98);
  -webkit-text-stroke: 0.6px rgba(255, 255, 255, 0);
  text-shadow: 0 2px 12px rgba(16, 48, 74, 0.35);
  transition: color 0.7s ease, -webkit-text-stroke-color 0.7s ease, text-shadow 0.7s ease;
}
.pv__title-clone--glass.is-lit {
  color: rgba(255, 255, 255, 0.66);                       /* translucent glass fill */
  -webkit-text-stroke: 0.6px rgba(255, 255, 255, 0.42);   /* crisp refractive rim */
  text-shadow:
    0 1px 0 rgba(255, 255, 255, 0.6),                     /* top specular highlight */
    0 2px 4px rgba(16, 48, 74, 0.28),                     /* near depth */
    0 10px 26px rgba(16, 48, 74, 0.34);                   /* soft drop → legible over clouds */
}
@media (prefers-reduced-motion: reduce) {
  .pv__title-clone--glass { transition: none; }
}

/* right content zone — lead + media bento, to the right of the card */
.pv__media {
  position: absolute;
  left: var(--content-x);
  right: var(--pad);
  top: calc(var(--pad) + var(--title-gap));   /* clear gap below the big title */
  bottom: var(--pad);
  display: flex;
  flex-direction: column;
  gap: 26px;
  overflow: hidden;
}
.pv__lead {
  flex: none;
  max-width: min(1040px, 100%);   /* span most of the zone — don't break early at "motion" */
  font-size: clamp(18px, 1.6vw, 22px);
  font-weight: 500;
  line-height: 1.45;
  letter-spacing: -0.01em;
  color: var(--pv-ink, #1b1b1b);
}
/* bento: portrait report (left) + 2×2 social tiles (right) */
.pv__bento {
  flex: none;
  height: clamp(300px, 50vh, 520px);   /* defined height so the page can scroll cleanly */
  display: flex;
  gap: 22px;
}
.pv__report {                    /* portrait report / PDF viewer, fills the height */
  position: relative;
  flex: 0 0 38%;
  border-radius: var(--r-md);
}
.pv__grid {                      /* 2×2 tiles — slight column asymmetry reads as intentional */
  flex: 1;
  display: grid;
  grid-template-columns: 1.25fr 1fr;
  grid-template-rows: 1fr 1fr;
  gap: 22px;
}
.pv__tile { position: relative; border-radius: var(--r-sm); }
/* tile/report caption — a small label pinned bottom-left (the "authored" signal) */
.tile-cap { position: absolute; left: 14px; right: 14px; bottom: 12px; }
.media-tag { position: absolute; left: 16px; top: 14px; }

/* ---- project-page text blocks ---- */
.pv__meta {
  flex: none;
  font-size: 12.5px; font-weight: 600; letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--pv-ink, #1b1b1b); opacity: 0.5;
}
.pv__body {
  flex: none;
  font-size: clamp(14px, 1.15vw, 16px);
  line-height: 1.58; letter-spacing: -0.004em;
  color: var(--pv-ink, #1b1b1b); opacity: 0.9;
}
.pv__body a {
  color: inherit;
  text-decoration: underline; text-decoration-thickness: 1px;
  text-underline-offset: 2px; text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
  transition: text-decoration-color 0.18s ease;
}
.pv__body a:hover { text-decoration-color: currentColor; }
.pv__outcome {
  flex: none;
  font-size: clamp(15px, 1.35vw, 18px); font-weight: 500;
  line-height: 1.4; letter-spacing: -0.012em;
  color: var(--pv-ink, #1b1b1b);
}
/* FAQ: widen the gap from an answer to the NEXT question (body → outcome) so the page reads as
   clearly separated Q/A pairs. Only fires between an answer and the following question — the
   first question and every question → answer keep their tighter spacing. */
.pv__body + .pv__outcome { margin-top: 18px; }
.pv__quote {
  flex: none; max-width: 22ch;
  font-size: clamp(20px, 2.2vw, 30px); font-weight: 500;
  line-height: 1.18; letter-spacing: -0.02em;
  color: var(--pv-ink, #1b1b1b);
}
.pv__caption { flex: none; font-size: 13px; color: var(--pv-ink, #1b1b1b); opacity: 0.6; }
.pv__divider {
  flex: none; min-height: 1px;
  display: flex; align-items: center; gap: 12px;
  border-top: 1px solid var(--pv-ink, #1b1b1b);
  border-color: color-mix(in srgb, var(--pv-ink, #1b1b1b) 16%, transparent);
  padding-top: 12px;
}
.pv__bleed { flex: 1; min-height: 200px; }

/* ---- pills (project meta; reusable) ---- */
.pv__pills { flex: none; display: flex; gap: 9px; flex-wrap: wrap; }
.pill {
  display: inline-block;
  font-family: "Switzer", -apple-system, BlinkMacSystemFont, sans-serif;
  font-weight: 500; font-size: 14px; letter-spacing: -0.005em;
  padding: 7px 15px; border-radius: var(--r-xs);
  color: var(--pv-ink, #1b1b1b);
  background: color-mix(in srgb, var(--pv-ink, #1b1b1b) 9%, transparent);
  border: 1px solid color-mix(in srgb, var(--pv-ink, #1b1b1b) 16%, transparent);
}

/* ---- two-pane page: text column (left, in the media box) + carousel OVERLAY (right) ---- */
/* the text container just narrows to the left ~half; the carousel is a separate layer */
.pv__media.pv--split { right: 44%; gap: 22px; }
.pv--split .pv__synced { display: flex; flex-direction: column; gap: 12px; margin-top: 10px; }
/* synced card title — the SITE font (Indie Flower is reserved for captions later) */
.pv__card-title {
  font-weight: 600;
  font-size: clamp(20px, 2vw, 27px); line-height: 1.15; letter-spacing: -0.02em;
  color: var(--pv-ink, #1b1b1b);
}
.pv__card-blurb {
  font-size: clamp(14px, 1.2vw, 16px); line-height: 1.5; max-width: 36ch;
  color: var(--pv-ink, #1b1b1b); opacity: 0.88;
}
/* the carousel overlay — its own top layer, spanning full height to the screen's right
   edge, NOT constrained by the text/media box (so cards never get clipped by margins) */
.pv__wheel {
  position: absolute; top: 0; bottom: 0; right: 0; left: 56%;
  z-index: 2; overflow: hidden; cursor: grab; touch-action: none;
  perspective: 1500px;            /* rolodex depth — cards tilt away toward the edges */
}
.pv__wheel.grabbing { cursor: grabbing; }
/* top & bottom blur fringe — same soft falloff as the home arc */
.pv__wheel::before, .pv__wheel::after {
  content: ''; position: absolute; left: 0; right: 0; z-index: 300;
  height: clamp(70px, 13vh, 150px); pointer-events: none;
  -webkit-backdrop-filter: blur(7px); backdrop-filter: blur(7px);
}
.pv__wheel::before {
  top: 0;
  -webkit-mask-image: linear-gradient(to bottom, #000 0, #000 12%, transparent 100%);
          mask-image: linear-gradient(to bottom, #000 0, #000 12%, transparent 100%);
}
.pv__wheel::after {
  bottom: 0;
  -webkit-mask-image: linear-gradient(to top, #000 0, #000 12%, transparent 100%);
          mask-image: linear-gradient(to top, #000 0, #000 12%, transparent 100%);
}
.pv__card-slot {
  position: absolute; left: 0; top: 0;
  width: clamp(420px, 80%, 800px);   /* fixed width — cards vary in HEIGHT (rolling bento) */
  will-change: transform, opacity;
}
.pv__card { width: 100%; border-radius: var(--r-sm); }   /* softer rounding; .plastic gives the edge/shadow */
.pv__card--wide   { aspect-ratio: 16 / 10; }
.pv__card--tall   { aspect-ratio: 4 / 5; }     /* portrait */
.pv__card--square { aspect-ratio: 1 / 1; }     /* design work is often 1:1 */
/* a photo sits MATTED inside the plastic card (the mat grounds it — no hard stroke) */
.pv__card--photo { padding: 12px; }
.pv__photo { width: 100%; height: 100%; border-radius: var(--r-sm); background-size: cover; background-position: center; }

/* ===================== Editorial scroll page (Concept B) ===================== */
.pv__media.pv--editorial {
  right: 0; bottom: 0;
  display: block;
  overflow-y: auto; overflow-x: hidden;
  overscroll-behavior: contain;
  /* top padding = fold height, so the first section rests CLEAR below the blur.
     left gap comes from --content-x; right gap = --pad → visually even margins. */
  padding: var(--foldH) var(--pad) 24vh 0;
  scrollbar-width: none;
  /* dissolve, not a line: content fades out as it reaches the top (no hard clip edge) */
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 var(--foldH));
          mask-image: linear-gradient(to bottom, transparent 0, #000 var(--foldH));
}
.pv__media.pv--editorial::-webkit-scrollbar { display: none; }

/* fixed header (lead + pills), right-aligned, sits above the fold — never blurred */
.pv__edhead {
  position: absolute; top: var(--pad); right: var(--pad);
  width: min(460px, 42%); z-index: 4;
  display: flex; flex-direction: column; align-items: flex-end; gap: 16px;
  text-align: right;
}
.pv__edhead .pv__lead { max-width: 100%; }
.pv__edhead .pv__pills { justify-content: flex-end; }

.ed { position: relative; padding-block: clamp(56px, 8vh, 120px); }
.ed-heading {
  font-weight: 600; font-size: clamp(26px, 3.4vw, 46px);
  letter-spacing: -0.03em; line-height: 1.05; max-width: 18ch;
  color: var(--pv-ink, #1b1b1b); margin-bottom: clamp(20px, 3vh, 38px);
}
.ed-body { font-size: clamp(15px, 1.3vw, 17px); line-height: 1.55; max-width: 46ch; color: var(--pv-ink, #1b1b1b); opacity: 0.9; }

/* media surfaces — crisp low rounding, soft shadow from .plastic, NO stroke */
.ed-bleed-media, .ed-tile, .ed-twoup-media, .ed-focal, .ed-strip-card, .ed-pdf {
  position: relative; overflow: hidden; border-radius: var(--r-sm); border: 0;
}
.ed-photo { position: absolute; inset: 0; background-size: cover; background-position: center; }

/* full-bleed: runs to the screen's right edge (cancels the container's right padding) */
.ed--bleed { margin-right: calc(-1 * var(--pad)); }
.ed-bleed-media { aspect-ratio: 16 / 9; }
.ed-bleed-media .ed-photo { top: -8%; height: 116%; }      /* parallax headroom */

/* embedded PDF / document viewer — tall portrait with a subtle viewer bar */
.ed-pdf { width: min(560px, 78%); aspect-ratio: 3 / 4; }
.ed-pdf-bar { position: absolute; inset: 0 0 auto 0; height: 30px; z-index: 1; background: color-mix(in srgb, var(--pv-ink, #1b1b1b) 6%, #fff); border-bottom: 1px solid color-mix(in srgb, var(--pv-ink, #1b1b1b) 8%, transparent); }
.ed-pdf-page { position: absolute; inset: 30px 0 0 0; }
.ed-pdf-page .ed-photo { inset: 0; }
.ed-caption { font-size: 13px; color: var(--pv-ink, #1b1b1b); opacity: 0.6; margin-top: 14px; }

.ed-quote {
  font-weight: 600; font-size: clamp(34px, 5.5vw, 76px);
  letter-spacing: -0.035em; line-height: 1.04; max-width: 16ch;
  color: var(--pv-ink, #1b1b1b);
}

.ed-grid { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(14px, 1.6vw, 24px); }
.ed-tile { aspect-ratio: 1 / 1; }
.ed-cell .ed-caption { margin-top: 10px; }   /* captioned grid tiles (e.g. rejected directions) */

/* annotated final image — Indie Flower notes over the work (retrospective storytelling) */
.ed-annotated { position: relative; overflow: hidden; border-radius: var(--r-sm); aspect-ratio: 16 / 10; }
.ed-note {
  position: absolute; z-index: 2; max-width: 20ch;
  font-family: 'Indie Flower', cursive; font-size: clamp(15px, 1.5vw, 20px); line-height: 1.15;
  color: var(--pv-ink, #1b1b1b);
  text-shadow: 0 1px 0 color-mix(in srgb, var(--pv-bg, #fff) 60%, transparent);   /* lifts off busy art */
  pointer-events: none;
}

.ed--twoup { display: grid; grid-template-columns: 1.1fr 1fr; gap: clamp(28px, 4vw, 64px); align-items: center; }
.ed--twoup-left { grid-template-columns: 1fr 1.1fr; }
.ed--twoup-left .ed-twoup-text { order: 2; }
.ed-twoup-media { aspect-ratio: 4 / 3; }

.ed-blocks { display: flex; gap: clamp(12px, 1.4vw, 20px); }
.ed-block { flex: 1; aspect-ratio: 3 / 4; border-radius: var(--r-xs); }

.ed--strip { margin-right: calc(-1 * var(--pad)); }   /* carousel runs edge to edge */
.ed-strip { overflow: hidden; }
.ed-strip-track { display: flex; gap: clamp(16px, 1.8vw, 26px); width: max-content; }
.ed-strip-card { flex: 0 0 clamp(220px, 22vw, 320px); aspect-ratio: 3 / 4; }

.ed-focal { aspect-ratio: 4 / 3; max-width: 720px; margin-inline: auto; }

.ed-outro { padding-top: clamp(20px, 3vh, 44px); }
.ed-outro-title {
  font-weight: 600; font-size: clamp(40px, 7vw, 96px);
  letter-spacing: -0.04em; line-height: 1; color: var(--pv-ink, #1b1b1b); margin-top: 8px;
}

/* progressive-blur "fold" — stacked layers (blur values + masks set inline in JS),
   spanning the scroll zone width so there's no visible cut-off */
.pv__fold {
  position: absolute; left: var(--content-x); right: 0;
  top: calc(var(--pad) + var(--title-gap)); height: var(--foldH);
  z-index: 3; pointer-events: none;
}
.pv__fold-layer { position: absolute; inset: 0; }

/* pre-hide one-shot reveals so there's no flash before GSAP wires them (fromTo overrides).
   scrub elements aren't pre-hidden (they start faint and scale in with scroll). */
.ed-armed [data-ed="rise"], .ed-armed [data-ed="pop"] { opacity: 0; }
.ed-armed [data-ed="grid"] > *, .ed-armed [data-ed="blocks"] > * { opacity: 0; }

/* designed scrollbar for an overflowing content column — slim, tracks the brand ink, fades
   in with the text (JS drives the thumb; the track overlays the column's right edge) */
.pv__sb {
  position: absolute; width: 3px; border-radius: 3px; z-index: 6; pointer-events: none;
  background: color-mix(in srgb, var(--pv-ink, #000) 10%, transparent); opacity: 0;
  transition: opacity 380ms cubic-bezier(0.22, 1, 0.36, 1);   /* JS toggles opacity for the fade in/out */
}
@media (prefers-reduced-motion: reduce) { .pv__sb { transition: none; } }
.pv__sb-thumb {
  position: absolute; left: 0; right: 0; top: 0; border-radius: 3px;
  background: color-mix(in srgb, var(--pv-ink, #000) 42%, transparent);
}

/* ===================== Concept C: layered gallery (panel slides to reveal) ===================== */
/* text column — sits ON the yellow panel (above every layer except the title clone) */
.pv__media.pv--gallery {
  right: 36%;                         /* text ends at the panel's right edge (64%) */
  display: flex; flex-direction: column; gap: 18px; justify-content: flex-start;
  overflow: visible; z-index: 4;
}
/* hybrid vw+px fluid term (not pure vw): same endpoints — ~20/15px on mobile, 30/18px on
   desktop (≥1536) — but a much gentler mid-range slope so tablet/small-laptop widths don't
   collapse to their floor and read cramped as you drag-resize. */
.pv__g-heading { margin: 0; font-weight: 500; font-size: clamp(20px, 1.04vw + 14px, 30px); letter-spacing: -0.02em; line-height: 1.15; color: var(--pv-ink, #1b1b1b); }
.pv__g-desc { font-size: clamp(15px, 0.33vw + 13px, 18px); line-height: 1.55; color: var(--pv-ink, #1b1b1b); opacity: 0.82; max-width: 62ch; text-wrap: pretty; }
.pv__g-cta {
  align-self: flex-start; margin-top: 6px;
  display: inline-flex; align-items: center; gap: 8px;
  padding: 10px 16px; border-radius: var(--r-xs);
  font-size: 14px; font-weight: 600; letter-spacing: -0.01em; text-decoration: none;
  color: var(--pv-ink, #1b1b1b);
  background: color-mix(in srgb, var(--pv-ink, #000) 9%, transparent);
  border: 1px solid color-mix(in srgb, var(--pv-ink, #000) 24%, transparent);
  transition: background 0.2s ease, transform 0.2s ease;
}
.pv__g-cta::after { content: '\2197'; font-size: 0.95em; opacity: 0.75; }
.pv__g-cta:hover { background: color-mix(in srgb, var(--pv-ink, #000) 17%, transparent); transform: translateY(-1px); }
.pv__g-cta.is-hidden { display: none; }
/* full-screen (video / sites) pages: the 26px .pv__media gap spaced heading→body as far apart
   as body→media, so they read as three loose blocks. Tighten the column and pull the body up
   under the heading so header+body group as a pair; the larger gap stays before the media. */
.pv__media:not(.pv--gallery) { gap: 20px; }
.pv__media:not(.pv--gallery) .pv__g-heading + .pv__g-desc { margin-top: -12px; }
/* project footer: tools used + optional drawn logo, pinned to the bottom of the column */
.pv__g-foot { margin-top: auto; padding-top: 26px; }
.pv__g-tools-row { display: flex; flex-wrap: wrap; gap: 10px; }
.pv__tool {                            /* crisp white-outline chip by default; wipe-fills on hover */
  position: relative; overflow: hidden; isolation: isolate;
  display: inline-flex; align-items: center; gap: 9px;
  font-size: 15px; font-weight: 600; letter-spacing: -0.01em;
  padding: 9px 16px; border-radius: var(--r-xs); color: var(--pv-ink, #1b1b1b); cursor: default;
  background: color-mix(in srgb, var(--pv-ink, #000) 6%, transparent);
  border: 1.5px solid color-mix(in srgb, var(--pv-ink, #000) 68%, transparent);
  transition: color 0.3s ease;
}
.pv__tool::before {
  content: ''; position: absolute; inset: 0; z-index: -1; background: var(--pv-ink, #1b1b1b);
  transform: scaleX(0); transform-origin: left center;
  transition: transform 0.36s cubic-bezier(0.22, 1, 0.36, 1);
}
.pv__tool:hover { color: var(--pv-bg, #ffcc00); }
.pv__tool:hover::before { transform: scaleX(1); }
.pv__tool-ic {                         /* CDN brand glyph, tinted to the chip's currentColor */
  width: 16px; height: 16px; flex: none; background: currentColor;
  -webkit-mask: var(--m) center / contain no-repeat; mask: var(--m) center / contain no-repeat;
}
/* big background spark — brand texture anchored to the panel's bottom-right corner, cropped */
.pv__bgspark {
  position: absolute; left: 0; right: 36%; top: 0; bottom: 0;
  z-index: 3; overflow: hidden; pointer-events: none;
  color: var(--pv-ink, #fff); opacity: 0.12;
  transition: opacity 0.5s ease;
}
.pv__bgspark svg { position: absolute; right: -8%; bottom: -14%; width: clamp(420px, 50vw, 760px); height: auto; }

/* ---- video project page: sits ON the page (brand bleed) — overview, a left-aligned 16:9 player, tools ---- */
.pv__media.pv--video { display: flex; flex-direction: column; gap: 30px; overflow: visible; }   /* don't clip the player's shadow into a line */
.pv__video {
  align-self: flex-start; flex: 0 1 auto;     /* left-aligned, big, with room above + below */
  width: min(100%, 1040px); aspect-ratio: 16 / 9; position: relative;
}
.pv__video-frame {
  position: absolute; inset: 0; z-index: 1; border-radius: var(--r-sm); overflow: hidden; background: #000;
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);   /* biggest object on the page carries the deepest shadow */
}
/* faint lit ring over the video so the player still reads as a framed object when the film's
   own background matches the page colour (e.g. Plum purple on the purple page) */
.pv__video-frame::after {
  content: ''; position: absolute; inset: 0; z-index: 2; border-radius: inherit; pointer-events: none;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);   /* 10% white ring — the film is dark content */
}
/* studio-mockup video page: the scene image IS the frame (its bg matches --pv-bg, shadow is
   painted in the render) — drop the player chrome so the scene sits ON the page seamlessly */
.pv__video-frame--mockup { background: transparent; box-shadow: none; }
.pv__video-frame--mockup::after { box-shadow: none; }
.pv__video-frame--mockup .ed-photo { position: absolute; inset: 0; background-size: cover; background-position: center; }
.pv__video-frame--mockup video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; cursor: pointer; }
/* bleed 1px past the frame so no sub-pixel sliver of background shows on the left/top */
.pv__video-frame iframe { position: absolute; inset: -1px; width: calc(100% + 2px); height: calc(100% + 2px); border: 0; }
.pv__video-frame video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border: 0; cursor: pointer; }
/* hover-only mute toggle for a self-hosted player — invisible until you mouse over the video,
   so it never competes with the footage; the existing tap-anywhere-to-toggle still works under
   it, this is just a discoverable affordance. No hover on touch, so it never shows there either
   — fine, since tapping the video itself already toggles mute. */
.pv__mute-btn {
  position: absolute; z-index: 4; right: 14px; bottom: 14px;
  width: 34px; height: 34px; border-radius: 50%; border: 0; padding: 0;
  display: flex; align-items: center; justify-content: center;
  background: rgba(10, 10, 12, 0.55); color: #fff; cursor: pointer;
  opacity: 0; pointer-events: none; transform: scale(0.9);
  transition: opacity 0.18s ease, transform 0.18s ease, background 0.18s ease;
}
.pv__mute-btn svg { width: 16px; height: 16px; }
.pv__mute-btn:hover { background: rgba(10, 10, 12, 0.78); }
.pv__video-frame:hover :is(.pv__mute-btn, .pv__expand-btn), .pv__shot:hover .pv__mute-btn {
  opacity: 1; pointer-events: auto; transform: scale(1);
}
/* enlarge toggle — same geometry as the mute button, one slot to its left */
.pv__expand-btn {
  position: absolute; z-index: 4; right: 56px; bottom: 14px;
  width: 34px; height: 34px; border-radius: 50%; border: 0; padding: 0;
  display: flex; align-items: center; justify-content: center;
  background: rgba(10, 10, 12, 0.55); color: #fff; cursor: pointer;
  opacity: 0; pointer-events: none; transform: scale(0.9);
  transition: opacity 0.18s ease, transform 0.18s ease, background 0.18s ease;
}
.pv__expand-btn svg { width: 16px; height: 16px; }
.pv__expand-btn:hover { background: rgba(10, 10, 12, 0.78); }
/* enlarged video page: heading/desc/CTA/tools drop out so the player owns the whole column */
.pv__media.pv--max .pv__g-heading,
.pv__media.pv--max .pv__g-desc,
.pv__media.pv--max .pv__g-cta,
.pv__media.pv--max .pv__g-foot { display: none; }
/* enlarged "theater": a fixed fullscreen stage — JS sizes the player to fit within
   inset(var(--pad)); the page title fades out (JS). Overrides the absolute-positioned
   .pv__media box entirely (position/inset/margin/padding), so the scroll-tier margins
   below never leak in (see the two-class overrides in the ≤1535 + mobile blocks). */
/* .pv--max.pv--max (0,3,0) so the four insets beat `body.project-open .pv__media { bottom: … }`
   (0,2,1, ≥1536) + the ≤1535/mobile scroll-tier .pv__media overrides — otherwise the theater's
   bottom stayed at var(--pad)+76 at 2560 and the video poked out top / a colour strip showed. */
.pv__media.pv--max.pv--max {
  position: fixed; inset: 0; z-index: 16; margin: 0; padding: var(--pad);
  display: flex; align-items: center; justify-content: center;
  overflow: visible;
  background: color-mix(in srgb, var(--pv-bg, #1b1b1b) 86%, black);
}
/* ---- sites project page: overview + link cards (sites that block embedding) ---- */
.pv__media.pv--sites { display: flex; flex-direction: column; gap: 30px; overflow: visible; }
.pv__sites { display: flex; flex-wrap: wrap; gap: 24px; }
.pv__site {
  flex: 1 1 0; min-width: 220px; max-width: 460px; display: flex; flex-direction: column;
  text-decoration: none; color: var(--pv-ink, #1b1b1b);
  border-radius: var(--r-sm); overflow: hidden; background: #fff;
  /* the hairline is an INSET ring (drawn inside the clip), not a real border — a 1px border
     races the corner clip and leaves an antialiased seam; an inset shadow follows the same
     radius exactly, so the image fills the corner cleanly. */
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--pv-ink, #000) 10%, transparent), 0 14px 34px rgba(0, 0, 0, 0.12);
  transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.26s ease;
}
.pv__site:hover { transform: translateY(-4px); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--pv-ink, #000) 10%, transparent), 0 22px 48px rgba(0, 0, 0, 0.18); }
.pv__site-shot {
  display: block; width: 100%; aspect-ratio: 16 / 10;
  object-fit: cover; object-position: top center;   /* the card's overflow:hidden does all the corner rounding — no radius on the image (avoids a 16-vs-15 mismatch at the corner) */
  background-color: color-mix(in srgb, var(--pv-ink, #000) 6%, transparent);
}
.pv__site-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; }
.pv__site-name { font-size: 15px; font-weight: 600; letter-spacing: -0.02em; }
.pv__site-go { font-size: 13px; font-weight: 600; opacity: 0.62; }
.pv__site:hover .pv__site-go { opacity: 1; }
.pv__video-ph {
  position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
  color: var(--card-ink, #fff); opacity: 0.5; font-weight: 600; letter-spacing: -0.01em;
}
/* report books — small covers; cover tilts up-left, the inside page peeks up-right (own class to
   avoid the legacy .pv__report { flex:0 0 38% } collision higher up in this file) */
.pv__reports { display: flex; gap: 18px; margin-bottom: 18px; }
.pv__book { flex: 1 1 0; min-width: 0; max-width: 232px; display: flex; flex-direction: column; gap: 11px; text-decoration: none; color: var(--pv-ink, #1b1b1b); }
.pv__book-stack { position: relative; width: 100%; aspect-ratio: 1 / 1.41; }
.pv__book-page, .pv__book-cover {
  position: absolute; inset: 0; border-radius: 1.5px 4px 4px 1.5px;
  background-size: cover; background-position: center;
  transition: transform 0.34s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.34s ease;
}
/* the page sits TUCKED inside the cover (left/bottom inset) so the spring overshoot on settle
   can never expose a white sliver on the left — it only ever emerges at the top-right */
.pv__book-page { background-color: #fff; inset: 2px 2px 6px 9px; transform-origin: bottom right; box-shadow: 0 4px 10px rgba(0, 0, 0, 0.2); }
.pv__book-cover { transform-origin: bottom left; box-shadow: 0 10px 22px rgba(0, 0, 0, 0.32); }
.pv__book-cover::before {                  /* subtle spine on the hinge side */
  content: ''; position: absolute; inset: 0 auto 0 0; width: 9%;
  background: linear-gradient(to right, rgba(0, 0, 0, 0.3), transparent);
  border-radius: 1.5px 0 0 1.5px; pointer-events: none;
}
.pv__book:hover .pv__book-cover { transform: rotate(-2deg) translateY(-2px); }
.pv__book:hover .pv__book-page { transform: rotate(1.5deg) translate(6px, -6px); }
.pv__book-cap { font-size: 10.5px; font-weight: 600; letter-spacing: -0.01em; opacity: 0.78; }
.pv__book-cap::after { content: ' \2197'; opacity: 0.6; }
.pv__g-foot--inline { margin-top: 0; padding-top: 8px; }

/* MIDDLE layer: the yellow panel. Runs WAY off-screen left so its left edge never
   sweeps into view during the cover slide. Rounded right edge + plasticky stroke
   to match the identity card. Slides off to reveal the gallery. */
.pv__panel {
  position: absolute; top: -2px; bottom: -2px; left: -80vw; right: 36%;
  z-index: 2; background: var(--pv-bg, #ffcc00);
  border-radius: 0 var(--r-card) var(--r-card) 0;
  box-shadow:
    inset 0 0 0 1px rgba(0, 0, 0, 0.06),          /* dark hairline — a full ring, so it wraps the corners (quietened 0.12→0.06) */
    inset 0 0 0 2.5px rgba(255, 255, 255, 0.22),  /* lit bevel just inside it (also follows the radius) */
    24px 0 56px rgba(0, 0, 0, 0.16);
  will-change: transform;
  transition: background 0.45s ease;   /* recolours in place on a gallery-to-gallery jump */
}

/* BOTTOM layer: the gallery — no rounding, flush to the screen edges; revealed by the panel */
.pv__carousel {
  position: absolute; top: 0; bottom: 0; right: 0; left: 62%;
  z-index: 1; overflow: hidden; background: var(--card-surf, #191a1d);
  transition: background 0.45s ease;
}
.pv__carousel-grid {                  /* very faint static grid for depth; shots move over it */
  position: absolute; inset: 0; z-index: 0; pointer-events: none;
  background-image:
    linear-gradient(to right, color-mix(in srgb, var(--card-ink, #fff) 1.5%, transparent) 1px, transparent 1px),
    linear-gradient(to bottom, color-mix(in srgb, var(--card-ink, #fff) 1.5%, transparent) 1px, transparent 1px);
  background-size: 48px 48px;
}
.pv__carousel-track {
  position: absolute; inset: 0; z-index: 1;
  display: flex; flex-direction: column; align-items: center; gap: clamp(22px, 3.5vh, 52px);
  padding: 0 clamp(72px, 6vw, 112px);   /* even L/R padding; looped copies fill vertically (infinite) */
  overflow-y: auto; overflow-x: hidden; scrollbar-width: none;
  overscroll-behavior: contain; cursor: grab; touch-action: pan-y;
  perspective: 1300px; perspective-origin: 50% 50%;
}
.pv__carousel-track::-webkit-scrollbar { display: none; }
.pv__carousel-track.grabbing { cursor: grabbing; }
.pv__shot {                            /* rounded image card */
  position: relative; flex: 0 0 auto;
  width: 100%; aspect-ratio: 16 / 10;
  border-radius: var(--r-lg); overflow: hidden;
  background: color-mix(in srgb, var(--card-ink, #fff) 8%, transparent);
  will-change: transform, opacity;
}
/* 1px inset ring over the shot, keyed to the page ink (10%) — reads black on light pages, white on dark */
.pv__shot::after { content: ''; position: absolute; inset: 0; z-index: 5; border-radius: inherit; pointer-events: none; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--pv-ink, #1b1b1b) 10%, transparent); }
.pv__shot--paper::after { display: none; }   /* paper/slate process cards keep their own frame */
.pv__shot .ed-photo { position: absolute; inset: 0; background-size: cover; background-position: center; }
.pv__shot .ed-video { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
.pv__shot video.ed-video { object-fit: cover; cursor: pointer; }   /* native clip fills the card; tap to unmute */
/* mockup shots: the real media clips inside the painted panel's flat screen area
   (the box is %-positioned inline from the measured generation — see addShot) */
.pv__shot-screen { position: absolute; overflow: hidden; }
/* perspective variant: full-size box warped onto the painted quad via matrix3d (applyScreenQuad) */
.pv__shot-screen--quad { left: 0; top: 0; transform-origin: 0 0; will-change: transform; backface-visibility: hidden; -webkit-backface-visibility: hidden; }
/* glass: a faint sky-light glare over the composited footage. It lives INSIDE the
   (possibly matrix3d-warped) screen box, so it follows the screen's plane — the video
   stops reading as a flat decal and starts reading as a screen in the scene. */
.pv__shot-screen::after {
  content: ''; position: absolute; inset: 0; z-index: 3; pointer-events: none;
  background:
    linear-gradient(112deg, rgba(255, 255, 255, 0.17) 0%, rgba(255, 255, 255, 0.05) 24%, rgba(255, 255, 255, 0) 40%),
    radial-gradient(130% 80% at 85% -12%, rgba(255, 255, 255, 0.10), transparent 55%);
  mix-blend-mode: screen;
}

/* ---------------- process chapter cards — "the reel continues" onto paper stock ----------------
   Finished work = plastic; the making-of = paper. Tilt/tape/stamp all live on .pv__paper
   (warpShots owns the outer .pv__shot transform). */
.pv__shot--paper { background: transparent; }
.pv__paper {
  position: absolute; inset: 2.5% 3%;
  transform: rotate(var(--rot, 0deg));
  background: #f6f3ea url('./grain-fine.png') repeat 0 0 / 256px;
  background-blend-mode: multiply;
  border-radius: 3px;
  box-shadow: 0 10px 26px rgba(0, 0, 0, 0.22), 0 1px 3px rgba(0, 0, 0, 0.12);
  display: flex; align-items: center; justify-content: center;
}
.pv__tape {
  position: absolute; top: -9px; width: 74px; height: 22px; z-index: 3;
  background: linear-gradient(180deg, rgba(255, 255, 250, 0.55), rgba(240, 236, 220, 0.45));
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
  border-left: 1px dashed rgba(0, 0, 0, 0.05); border-right: 1px dashed rgba(0, 0, 0, 0.05);
}
.pv__tape--l { left: 9%; transform: rotate(-5deg); }
.pv__tape--r { right: 9%; transform: rotate(4deg); }
/* the taped-in artefact: photo inset with a paper margin all round */
.pv__paper-photo { position: absolute; inset: 7% 8%; overflow: hidden; border-radius: 2px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.16); }
/* china-marker ✕ over a killed direction */
.pv__kill { position: absolute; inset: 4%; width: 92%; height: 92%; z-index: 2; pointer-events: none; opacity: 0.85; }
.pv__kill path { stroke: #c23a2e; stroke-width: 3.4; stroke-linecap: round; fill: none; vector-effect: non-scaling-stroke; }
/* honesty stamp on retrospective reconstructions */
.pv__stamp {
  position: absolute; right: 5%; bottom: 5%; z-index: 4;
  padding: 3px 8px; border: 1.6px solid rgba(194, 58, 46, 0.75); border-radius: 3px;
  color: rgba(194, 58, 46, 0.8); font-size: 11px; font-weight: 700;
  letter-spacing: 0.12em; text-transform: uppercase;
  transform: rotate(-6deg);
  background: rgba(246, 243, 234, 0.5);
}
/* a mostly-typographic card (the insight) */
.pv__paper-quote {
  max-width: 78%; text-align: center;
  font-family: 'Indie Flower', cursive;
  font-size: clamp(20px, 2.2vw, 30px); line-height: 1.25;
  color: #2c2a24;
}
/* the slate / chapter divider */
.pv__shot--slate .pv__paper { background-color: #efeadd; }
.pv__slate-label {
  font-family: 'Indie Flower', cursive;
  font-size: clamp(26px, 2.8vw, 40px); color: #2c2a24; text-align: center;
}
.pv__slate-sub {
  position: absolute; bottom: 12%; left: 0; right: 0; text-align: center;
  font-size: 13px; letter-spacing: 0.04em; color: rgba(44, 42, 36, 0.55);
}
.pv__paper .ed-note { color: #2c2a24; text-shadow: none; }

/* handwritten cue in the text column — same language as the CV's note */
.pv__mess {
  align-self: flex-start; display: inline-flex; align-items: flex-start; gap: 6px;
  margin-top: 2px; padding: 0; border: 0; background: none; cursor: pointer;
  font-family: 'Indie Flower', cursive; font-size: clamp(16px, 1.5vw, 20px); line-height: 1.2;
  color: var(--pv-ink, #1b1b1b); opacity: 0.75;
  transition: opacity 0.2s ease, transform 0.25s ease;
}
.pv__mess:hover { opacity: 1; transform: translateY(1px); }
.pv__mess-arrow { width: 34px; height: 28px; margin-top: 8px; opacity: 0.8; }
/* ambient chapter tag over the heading, only while a process card is centred */
.pv__proc-tag {
  display: none;
  font-family: 'Indie Flower', cursive; font-size: 16px;
  color: var(--pv-ink, #1b1b1b); opacity: 0.65; margin-bottom: -14px;
}
.pv--in-process .pv__proc-tag { display: block; }
.pv__shot-screen .ed-photo, .pv__shot-screen .ed-video { position: absolute; inset: 0; width: 100%; height: 100%; transform: translateZ(0); backface-visibility: hidden; -webkit-backface-visibility: hidden; will-change: transform; }
.pv__shot-screen video.ed-video { object-fit: cover; }
/* square/tall shots (e.g. MLC's 1:1 clips mixed into an otherwise 16:9 reel): width:100% would
   make them as TALL as the track is wide, eating far more of the carousel's own height than a
   16:9 shot does — cramped against the fold/contact-pill on short viewports. Cap the width so
   the resulting height stays proportionate to what a 16:9 shot occupies. */
.pv__carousel.is-square .pv__shot { aspect-ratio: 1 / 1; }
.pv__carousel.is-square .pv__shot, .pv__shot--tall { width: min(100%, 56vh); }
/* progressive-blur folds — stacked layers, blur ramps toward the edge (no hard line) */
.pv__carousel-fold { position: absolute; left: 0; right: 0; height: clamp(56px, 9vh, 120px); z-index: 2; pointer-events: none; }
.pv__carousel-fold--t { top: 0; }
.pv__carousel-fold--b { bottom: 0; }
.pv__fold-layer { position: absolute; inset: 0; }

/* ---- Additional Projects: the "daydream" — an infinite-zoom canvas of cards
   drifting through a sky, clouds floating over the top. See makeDaydream() in app.js. */
/* fullscreen sky — above the card + media column, below the title (z16) & back button (z30) */
.pv__dd { position: absolute; inset: 0; z-index: 12; overflow: hidden; }
.pv__media.pv--dd { display: none; }   /* fullscreen abstract experience: the copy is never shown (kept in DOM only for repairPv's health probe) */
.pv__dd-sky {
  position: absolute; inset: 0; background-size: cover; background-position: center;
  /* placeholder gradient sky until the generated sky.webp lands */
  background-image: linear-gradient(180deg,
    color-mix(in srgb, var(--pv-bg, #9AD1F0) 60%, #fff) 0%,
    var(--pv-bg, #9AD1F0) 46%,
    color-mix(in srgb, var(--pv-bg, #9AD1F0) 78%, #eaf6ff) 100%);
}
.pv__dd-canvas { position: absolute; inset: 0; overflow: hidden; cursor: grab; touch-action: none; z-index: 2; }
.pv__dd-canvas.grabbing { cursor: grabbing; }
.pv__dd-oct { position: absolute; left: 50%; top: 50%; width: 0; height: 0; }
.pv__dd-tile {
  position: absolute; left: 0; top: 0; transform-origin: 0 0;
  border-radius: var(--r-sm); overflow: hidden; background-size: cover; background-position: center;
  background-color: color-mix(in srgb, var(--pv-ink, #10304a) 8%, #fff);
  /* drop shadow (elevation) + a 1px inset ring keyed to the page ink (10% → reads black on the light sky) */
  box-shadow: 0 10px 26px rgba(16, 48, 74, 0.18), inset 0 0 0 1px color-mix(in srgb, var(--pv-ink, #10304a) 10%, transparent);
}
/* procedural fbm cloud layer (a <canvas>, drawn in makeDaydream) — soft, low-res, upscaled by CSS */
.pv__dd-clouds { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 5; pointer-events: none; opacity: 0.95; transition: opacity 1.3s ease; }
.pv__dd-clouds.is-pending { opacity: 0; }
.pv__dd-dim {
  position: absolute; inset: 0; z-index: 6; background: rgba(16, 48, 74, 0.6);
  opacity: 0; pointer-events: none;
  -webkit-backdrop-filter: blur(0); backdrop-filter: blur(0);   /* progressive bloom, not a pop */
  transition: opacity 0.55s ease, -webkit-backdrop-filter 0.55s ease, backdrop-filter 0.55s ease;
}
.pv__dd-dim.is-on { opacity: 1; pointer-events: auto; -webkit-backdrop-filter: blur(5px); backdrop-filter: blur(5px); }
/* card close: fade the dim FAST (0.2s vs the 0.55s open transition) so the returning card can't
   sweep through the still-lit backdrop. Applied via JS at close start, dropped on the next open. */
.pv__dd-dim.is-closing { transition-duration: 0.2s; }
.pv__dd-focus { position: absolute; inset: 0; z-index: 7; pointer-events: none; perspective: 1200px; }
.pv__dd-tile.is-focus { box-shadow: 0 40px 90px rgba(16, 48, 74, 0.5); pointer-events: auto; cursor: pointer; will-change: transform; }
/* floating copy — positioned in JS (right of the card on desktop, below on mobile); title+body rise/fade in, staggered */
.pv__dd-desc { position: absolute; z-index: 8; color: #fff; pointer-events: none; text-align: center; text-shadow: 0 1px 18px rgba(16, 48, 74, 0.5); }
.pv__dd-desc--side { text-align: left; }
.pv__dd-desc.is-on { pointer-events: auto; }
.pv__dd-desc-title, .pv__dd-desc-body { opacity: 0; transform: translateY(16px); transition: opacity 0.55s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1); }
.pv__dd-desc.is-on .pv__dd-desc-title { opacity: 1; transform: none; transition-delay: 0.04s; }
.pv__dd-desc.is-on .pv__dd-desc-body { opacity: 0.92; transform: none; transition-delay: 0.14s; }
/* card close: fade the copy out FAST (0.15s, no stagger) so the returning card lands after the
   text is gone — the card sits left, the copy to its right on desktop. Dropped on the next open. */
.pv__dd-desc.is-closing .pv__dd-desc-title,
.pv__dd-desc.is-closing .pv__dd-desc-body { transition-duration: 0.15s; transition-delay: 0s; }
.pv__dd-desc-title { font-size: clamp(20px, 1.5vw, 24px); font-weight: 500; letter-spacing: -0.01em; margin: 0 0 10px; }
.pv__dd-desc-body { font-size: 15px; line-height: 1.6; max-width: 36ch; margin: 0; }
/* optional per-card link + note (Chaos cards). .pv__dd-desc is pointer-events:none until .is-on,
   so the link opts itself back in to stay clickable; fades in with the body copy. */
.pv__dd-desc-link { display: inline-block; margin-top: 10px; font-size: 13px; color: currentColor; text-decoration: underline; text-underline-offset: 3px; cursor: pointer; pointer-events: auto; opacity: 0; transform: translateY(16px); transition: opacity 0.55s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1); }
.pv__dd-desc.is-on .pv__dd-desc-link { opacity: 0.92; transform: none; transition-delay: 0.2s; }
.pv__dd-desc.is-closing .pv__dd-desc-link { transition-duration: 0.15s; transition-delay: 0s; }
.pv__dd-desc-note { display: block; margin-top: 6px; font-size: 11.5px; opacity: 0; transform: translateY(16px); transition: opacity 0.55s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1); }
.pv__dd-desc.is-on .pv__dd-desc-note { opacity: 0.6; transform: none; transition-delay: 0.24s; }
.pv__dd-desc.is-closing .pv__dd-desc-note { transition-duration: 0.15s; transition-delay: 0s; }
/* centred layout (mobile, below the card): centre the measure box so its centred text reads
   centred under the centred title. Desktop --side (beside the card, left-aligned) unchanged. */
.pv__dd-desc:not(.pv__dd-desc--side) .pv__dd-desc-body { margin: 0 auto; }

/* quick-jump switcher — angled spokes on the right edge, generous hit targets */
.pv__jump {
  position: absolute; right: 0; top: 50%; transform: translateY(-50%);
  z-index: 15; display: flex; flex-direction: column; align-items: flex-end;   /* above the daydream sky (.pv__dd z-12); below the title clone (z-16) and back button (z-30) */
}
/* deprecated on touch: the whole point of the switcher is the hover-preview tooltip, which
   doesn't exist on touch input — without it it's just a cramped column of unlabelled tap
   targets. Gate on actual input capability (not a width breakpoint) so this also catches
   tablets and landscape phones, not just narrow portrait. Users close + re-select from the
   dial instead. JS that touches .pv__jump early-returns on a missing element, and listeners
   on a hidden node never fire, so no code path needs to know this is off — just keep it. */
@media (hover: none), (pointer: coarse) {
  .pv__jump { display: none; }
}
.pv__tick {                            /* generous invisible hit area, tightly stacked */
  position: relative; display: flex; align-items: center; justify-content: flex-end;
  width: 70px; height: 21px; border: 0; padding: 0 16px 0 0; background: none; cursor: pointer;
}
.pv__tick-line {
  display: block; width: var(--len, 26px); height: 2px; border-radius: 2px;
  background: color-mix(in srgb, var(--jump-ink, var(--pv-ink, #fff)) 58%, transparent);
  transform: translateX(var(--arc, 0)) rotate(var(--ang, 0deg));
  transform-origin: right center;
  transition: width 0.3s cubic-bezier(0.22, 1, 0.36, 1), background 0.3s ease;
}
.pv__tick:hover .pv__tick-line, .pv__tick.is-current .pv__tick-line { width: 46px; background: var(--jump-ink, var(--pv-ink, #fff)); }
.pv__tick-prev {                       /* bigger image tooltip (image + name) */
  position: absolute; right: calc(100% + 4px); top: 50%;
  width: 210px; border-radius: var(--r-lg); overflow: hidden;
  background: rgba(18, 18, 20, 0.72); color: #fff;
  -webkit-backdrop-filter: blur(16px) saturate(160%); backdrop-filter: blur(16px) saturate(160%);
  box-shadow: 0 22px 54px rgba(0, 0, 0, 0.34);
  opacity: 0; pointer-events: none; transform-origin: right center;
  transform: translateY(-50%) translateX(12px) scale(0.96);
  transition: opacity 0.24s ease, transform 0.24s cubic-bezier(0.22, 1, 0.36, 1);
}
.pv__tick:hover .pv__tick-prev { opacity: 1; transform: translateY(-50%) translateX(0) scale(1); }
.pv__tick-img { display: block; width: 100%; aspect-ratio: 16 / 10; position: relative; overflow: hidden; background-size: cover; background-position: center; }
/* media-less preview glyph (e.g. FAQ "?") — soft centred mark over the flat/gradient preview */
.pv__tick-img[data-glyph]::after {
  content: attr(data-glyph); position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  font-family: 'Switzer', sans-serif; font-weight: 500; font-size: 2.2em;
  color: #26221c; opacity: 0.32;
}
/* the Livid player letterboxes to the iframe box; make the iframe 16/9 (111.2% width in a
   16/10 box) and let overflow:hidden crop the sides — no black bars. */
.pv__tick-img iframe { position: absolute; top: -1px; left: 50%; transform: translateX(-50%); width: 113%; height: calc(100% + 2px); border: 0; }
.pv__tick-img video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; pointer-events: none; }   /* self-hosted clip plays in the tooltip; clicks fall through to the tick */
.pv__tick-name { display: block; padding: 9px 13px 11px; font-size: 13px; font-weight: 600; letter-spacing: -0.01em; }
/* the centred card is pushed forward — a lifted shadow on top of .plastic */
.pv__card-slot.is-focus .pv__card {
  box-shadow: 0 36px 70px rgba(0, 0, 0, 0.22), 0 8px 18px rgba(0, 0, 0, 0.12),
    inset 0 1px 0 rgba(255, 255, 255, 0.9);
}

/* ---- reusable layout primitives (compose any bespoke page) ---- */
.lay-inset { max-width: 62ch; }
.lay-bleed { width: 100%; border-radius: var(--r-md); overflow: hidden; }
.lay-twoup { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
.lay-grid { display: grid; gap: 18px; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); }
.lay-grid > .span2 { grid-column: span 2; }
.lay-grid > .tall { grid-row: span 2; }
.lay-row { display: flex; gap: 18px; overflow-x: auto; scroll-snap-type: x mandatory; scrollbar-width: none; cursor: grab; }
.lay-row > * { flex: 0 0 62%; scroll-snap-align: center; }
.lay-label {
  font-size: 11px; font-weight: 600; letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--pv-ink, #1b1b1b); opacity: 0.55;
}

/* tinted-glass surface — auto on-brand from the live --pv-bg */
.surf-glass {
  background: color-mix(in srgb, var(--pv-bg, #fff) 18%, #fff);
  border: 1px solid color-mix(in srgb, #fff 60%, transparent);
  -webkit-backdrop-filter: blur(6px); backdrop-filter: blur(6px);
  border-radius: var(--r-sm);
}

/* per-page vertical scroll (longform / grab-bag) */
.pv__media.is-scroll { overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
.pv__media.is-scroll::-webkit-scrollbar { display: none; }
/* soft fringe — fades ONLY where content is actually hidden (set in JS): the bottom fades while
   there's more below, the top fades only once scrolled. So the heading is crisp at rest and the
   last answer is never cut off at the end. Driven by --mask; variants just swap it. */
.pv__media.is-overflow {
  --mask: linear-gradient(to bottom, #000 0, #000 calc(100% - 22px), transparent 100%);   /* more below → bottom fades */
  -webkit-mask-image: var(--mask); mask-image: var(--mask);
}
.pv__media.is-overflow.has-scrolled { --mask: linear-gradient(to bottom, transparent 0, #000 22px, #000 calc(100% - 22px), transparent 100%); }   /* scrolled → top + bottom */
.pv__media.is-overflow.at-bottom { --mask: linear-gradient(to bottom, #000 0, #000 100%); }   /* at the end → no bottom cut */
.pv__media.is-overflow.has-scrolled.at-bottom { --mask: linear-gradient(to bottom, transparent 0, #000 22px, #000 100%); }   /* scrolled + at end → top only */
.pv__media.pv--max { -webkit-mask-image: none !important; mask-image: none !important; }   /* the fullscreen theater never inherits the scroll fade */

/* FAQ → Contact arrow. JS appends <span class="pv__faq-arrow-wrap"><svg class="pv__faq-arrow2">
   inline at the end of the "How do I get in touch?" question and REBUILDS the curved path every
   layout: the shaft exits the sentence horizontally then bends to point at the bottom-left
   Contact pill. The wrap is only a small inline anchor; the svg overflows it. No animation. */
.pv__faq-arrow-wrap {
  display: inline-block; width: 0.7em; height: 0.55em; margin-left: 12px;
  position: relative; vertical-align: -0.05em;
}
.pv__faq-arrow2 {
  position: absolute; left: 0; top: 0; width: 160px; height: 160px;
  overflow: visible; display: block; color: var(--pv-ink, #26221c); pointer-events: none;
}

/* ------------------------------------------------- carousel fringe blur (premium) */
/* Masked backdrop-blur on the dial's top & bottom: strongest at the very edge,
   fading to clear toward the centre. Sits just above the wheel (z3) and below the
   card/pill/pv, so it only softens the carousel — never the project view. */
.fringe {
  position: fixed;
  left: var(--card-w);
  right: 0;
  height: clamp(88px, 15vh, 160px);
  z-index: 4;
  pointer-events: none;
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
}
.fringe-top {
  top: 0;
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 12%, transparent 100%);
          mask-image: linear-gradient(to bottom, #000 0%, #000 12%, transparent 100%);
}
.fringe-bot {
  bottom: 0;
  -webkit-mask-image: linear-gradient(to top, #000 0%, #000 12%, transparent 100%);
          mask-image: linear-gradient(to top, #000 0%, #000 12%, transparent 100%);
}

/* ----------------------------------------------------------------- load motion */
/* On load: the card slides in from the left, then its blocks settle in
   line-by-line (rise + de-blur). The wheel's spin-to-random is handled in JS. */
@keyframes cardIn { from { transform: translateX(-105%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
@keyframes lineIn {
  from { opacity: 0; transform: translateY(-8px); filter: blur(6px); }
  to   { opacity: 1; transform: translateY(0);    filter: blur(0); }
}
/* Entrance is GATED behind body.home-enter — JS adds it ~150ms after the loader starts
   lifting (and immediately on the QA/webdriver + reduced-motion paths). Without the class
   every element rests fully visible/static, so QA screenshots are deterministic.
   FILL MODE IS `backwards`, NOT `both`, on every entrance: `both` keeps the final keyframe
   applied FOREVER (it outranks inline style), which would pin ring opacity (fadeUI sets
   ring.style.opacity on project-open), glassbar transform (glassLoop drives it live) and the
   .intro fold (body.project-open translateX). `backwards` holds only the pre-delay `from`
   state, then releases to base once the run ends — same entrance, no stuck transforms.
   The .intro card-slide + .card-line cascade are DESKTOP-ONLY (see the ≥1536 block); in the
   drawer/mobile tiers the card rests off-canvas and the .card-tab handle fades in instead. */
@keyframes homeFadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes homeRiseIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
/* mobile variant: the glassbar centres via translateX(-50%) at ≤767px, so the entrance must
   carry that offset through both keyframes or the pill sits shifted right for the run then snaps */
@keyframes homeRiseInMobile { from { opacity: 0; transform: translateX(-50%) translateY(10px); } to { opacity: 1; transform: translateX(-50%) translateY(0); } }
/* Hold the entrance elements INVISIBLE (opacity only — never transform: the drawer-tier .intro
   rests at translateX(-100%), and a transform override here would flash the bio card) while the
   loader is up and home-enter hasn't fired. So the overlay fades to the bare colour field, a
   beat, THEN the staged pop-in — instead of the fully-painted page being revealed and re-run.
   Scoped to :has(#loader): QA/webdriver (loader removed + home-enter added at once) and no-JS
   (loader never dismisses, so it covers the page regardless) are both unaffected. Once home-enter
   is present :not(.home-enter) stops matching → elements release to opacity 1. */
body:has(#loader):not(.home-enter) :is(.intro, .ring, .glassbar, .card-tab) { opacity: 0; }

/* staged delays: ring first (~0s), card + bio lines a beat later (~0.08s), contact pill last (~0.28s) */
body.home-enter .ring { animation: homeFadeIn 0.9s ease 0s backwards; }            /* opacity only — the dial's spin-in supplies the motion */
body.home-enter .glassbar { animation: homeRiseIn 0.7s cubic-bezier(0.22, 1, 0.36, 1) 0.4s backwards; }
body.home-enter .card-tab { animation: homeFadeIn 0.7s ease 0.4s backwards; }     /* drawer/mobile handle (hidden at desktop) — opacity fade only */

/* drawer-handle idle knock — a soft double-tap "there's something here" nudge.
   Quiet 88% of the cycle; killed for good once the card is first opened (.seen). */
@keyframes tabKnockX {
  0%, 88%, 100% { transform: none; }
  91% { transform: translateX(5px); }
  94% { transform: translateX(0); }
  97% { transform: translateX(3px); }
}
@keyframes tabKnockY {
  0%, 88%, 100% { transform: none; }
  91% { transform: translateY(5px); }
  94% { transform: translateY(0); }
  97% { transform: translateY(3px); }
}

/* --------------------------------------------------- contact paper-plane (hover) */
.gbtn--primary svg { transition: transform 0.25s ease; }
.gbtn--primary:hover svg { animation: planeFly 0.9s cubic-bezier(0.42, 0, 0.2, 1); }
@keyframes planeFly {
  0%   { transform: translate(0, 0) rotate(0deg);        opacity: 1; }
  28%  { transform: translate(7px, -9px) rotate(14deg);  opacity: 1; }
  50%  { transform: translate(24px, -20px) rotate(20deg); opacity: 0; }
  51%  { transform: translate(-16px, 13px) rotate(-8deg); opacity: 0; }
  100% { transform: translate(0, 0) rotate(0deg);        opacity: 1; }
}

/* ----------------------------------------------------------------- grain */
/* riso / printed-paper grain — all-over noise.
   BAKED feTurbulence tiles (grain-*.png, 512px @2x shown at 256px — same
   frequencies/stitch as the old live #grainFilter/#grainFilter2): the live SVG
   filters on two 200%-viewport layers re-rasterised on every backdrop change;
   the tiles rasterise once and the boil is pure compositor transform jitter.
   Multiply blending stays — it's what makes the grain darken saturated brand
   colours (the riso look); the blend was never the expensive part. */
.grain {
  position: fixed; inset: -32px;   /* overscan covers the boil jitter (max ±30px) */
  z-index: 20; pointer-events: none;
  background: url('./grain-coarse.png') repeat 0 0 / 256px 256px;
  opacity: 0.17;            /* present, not loud (third trim by request) */
  mix-blend-mode: multiply;
  will-change: transform;
  animation: grainBoil 1.7s steps(1, end) infinite;   /* subtle film-grain shimmer — calm cadence */
}
/* second, finer layer adds tooth (offset timing so the two don't sync) */
.grain2 { background-image: url('./grain-fine.png'); opacity: 0.085; animation: grainBoil 1.4s steps(1, end) infinite reverse; }
/* stepped jitter of the tiled field — reads as a living grain. Offsets only need to beat the
   ~1px grain size to fully decorrelate; kept small + slow so the boil reads as texture
   breathing, not the page juddering (Robin, 2026-07-12). */
@keyframes grainBoil {
  0%   { transform: translate(0, 0); }
  10%  { transform: translate(-11px, -6px); }
  20%  { transform: translate(7px, 10px); }
  30%  { transform: translate(-7px, 8px); }
  40%  { transform: translate(12px, -9px); }
  50%  { transform: translate(-10px, 4px); }
  60%  { transform: translate(6px, -8px); }
  70%  { transform: translate(-4px, 11px); }
  80%  { transform: translate(9px, 5px); }
  90%  { transform: translate(-8px, -10px); }
  100% { transform: translate(0, 0); }
}

/* riso misregistration — soft RGB channel split on the big display type
   (sharp core + soft red/cyan fringes; no full blur) */
.project__label,
.pv__title-clone,
.hi-name {
  text-shadow: 0.85px 0 0.5px rgba(255, 30, 60, 0.38),
              -0.85px 0 0.5px rgba(0, 190, 255, 0.33);
}
/* gentler split on the small name heading */
.hi-name {
  text-shadow: 0.5px 0 0.5px rgba(255, 30, 60, 0.2),
              -0.5px 0 0.5px rgba(0, 190, 255, 0.17);
}

@media (prefers-reduced-motion: reduce) {
  .hint__bar { animation: none; }
  .avatar, .avatar__img, .gbtn { transition: none; }
  .intro, .card-line { animation: none !important; }
  /* home-enter entrances off under reduced-motion (JS adds home-enter immediately on this path) */
  body.home-enter .ring, body.home-enter .glassbar, body.home-enter .card-tab,
  body.home-enter .intro, body.home-enter .card-line { animation: none !important; }
  .gbtn--primary:hover svg { animation: none; }
  .exp__co, .polaroid, .skill-card { transition: none; }
  .grain, .grain2 { animation: none; }
  .pv__tick, .pv__tick-prev, .pv__shot { transition: none; }
}

/* ---- Laptop / small windows (768–1535px): the identity card becomes a hide-and-reveal
   DRAWER so the dial + project views get the full width (three overlaid layers can't
   coexist below ~1536px). A slim avatar spine tab on the left edge reveals it: hover
   peeks, click springs it open as an overlay, click-out (scrim) closes. ≥1536px keeps
   the persistent card. ---- */
@media (min-width: 768px) and (max-width: 1535px) {
  /* free the space the card used to reserve */
  :root { --content-x: var(--pad); }
  .fringe { left: 0; }

  /* card slides fully off-screen left; glides in as an overlay when opened (expo-out, no
     bounce). No resting sliver — Robin: it read as an error. The knock + hover peek carry
     the discoverability instead. */
  .intro {
    animation: none;          /* cancel the cardIn load slide (fill:both would pin the transform) */
    transform: translateX(-100%);
    transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
    /* two tighter shadows instead of one huge 70px blur: a wide soft gradient over the flat
       brand colour stepped visibly (banding that "moved like a bad TV" on the hover-peek);
       smaller radii + stacked falloffs keep the depth without the visible bands */
    box-shadow: 16px 0 32px rgba(0, 0, 0, 0.16), 4px 0 10px rgba(0, 0, 0, 0.10);
  }
  .intro.is-open { transform: translateX(0); }
  /* hover the handle → peek the card edge out as an invitation */
  body:has(.card-tab:hover) .intro:not(.is-open) { transform: translateX(calc(-100% + 18px)); }

  /* scrim dims the dial/project behind the open card; click to close */
  .card-scrim {
    display: block; position: fixed; inset: 0; z-index: 17;
    background: rgba(20, 18, 26, 0.26);
    opacity: 0; pointer-events: none;
    transition: opacity 0.45s ease;
  }
  body.card-open .card-scrim { opacity: 1; pointer-events: auto; }

  /* the reveal handle: just the (cycling) avatar + a contrast chevron, floating on the edge */
  .card-tab {
    display: flex; flex-direction: row; align-items: center; gap: 6px;
    position: fixed; z-index: 20; left: 14px; top: 50%;
    transform: translateY(-50%);
    padding: 0; border: 0; background: none; cursor: pointer;
    transition: transform 0.42s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;   /* opacity too → the profile handle FADES out on project open, never pops */
  }
  .card-tab__avatar {
    width: 54px; height: 54px; border-radius: 50%;
    background: url('./me/5.webp') 30% 44% / 175% no-repeat;   /* JS repaints + cycles this */
    box-shadow: 0 6px 18px rgba(0, 0, 0, 0.22), inset 0 0 0 2px rgba(255, 255, 255, 0.85);
    transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), box-shadow 0.3s ease, opacity 0.3s ease;
  }
  /* idle "knock" — a gentle double-nudge toward the content every ~6s until first opened */
  @media (prefers-reduced-motion: no-preference) {
    .card-tab:not(.seen) .card-tab__avatar { animation: tabKnockX 6s ease-in-out infinite; }
  }
  .card-tab__chevron { color: #1b1b1b; transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1); }
  .card-tab:hover .card-tab__avatar { box-shadow: 0 8px 22px rgba(0, 0, 0, 0.28), inset 0 0 0 2px #fff; }
  body:not(.card-open) .card-tab:hover .card-tab__chevron { transform: translateX(3px); }
  /* opening: the handle rides RIGHT with the card; the circle fades away and just the
     chevron stays near the card's far-right edge, flipped to point left = click to close */
  body.card-open .card-tab { transform: translateY(-50%) translateX(calc(var(--card-w) - 88px)); pointer-events: auto; }
  body.card-open .card-tab__avatar { opacity: 0; transform: scale(0.3); pointer-events: none; }
  body.card-open .card-tab__chevron { transform: rotate(180deg); color: #1b1b1b; }
  /* hide the handle while a project view is open (the back button owns that corner) */
  body:has(#projectView:not([hidden])) .card-tab { opacity: 0; pointer-events: none; }

  /* back button: always top-right, every page + tier (R10) — gallery no longer pushes it
     to the 50% split; its colour is set per-tier in app.js for contrast on any page. */
  .card-back { top: 20px; right: 20px; left: auto; }

  /* gallery goes 50/50 — project card (text + panel) left, carousel right. The carousel
     underlaps the panel a touch so the panel's rounded edge reveals it (not flat brand). */
  .pv__media.pv--gallery { right: 54%; }              /* text ends before the split (right padding) */
  .pv__panel { right: 50%; }
  .pv__bgspark { right: 50%; }   /* keep the spark's crop box matched to the 50% panel (same fix as ≥1536px project-open) */
  .pv__carousel { left: 46%; }
  .pv__carousel .pv__shot { width: min(100%, 460px); }   /* books etc. were oversized on laptop */
  /* compact the whole gallery foot so books + wrapped tool chips fit the shorter column
     and never spill over the fixed contact pill */
  .pv__book { max-width: 148px; }
  .pv__book-cap { font-size: 11px; }
  .pv__reports { gap: 12px; margin-bottom: 10px; }
  .pv__g-foot { padding-top: 12px; }
  .pv__tool { padding: 6px 10px; font-size: 12px; gap: 6px; }
  .pv__media.pv--gallery { overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }   /* safety: scroll, never spill */
  .pv__jump { transform: translateY(-50%) scale(0.82); transform-origin: right center; }   /* smaller switcher lines */

  /* the morphed title is smaller without the card beside it — pull the content up.
     bottom clearance keeps the bottom-anchored tools clear of the contact pill. */
  .pv__media { top: calc(var(--pad) + 168px); bottom: calc(var(--pad) + 84px); }   /* clearance: tools row must clear the contact pill even when chips wrap */
  .pv__fold  { top: calc(var(--pad) + 168px); }
}

/* big desktop (≥1536px): while a project is open, the persistent card folds off-screen left
   (same slide language as the drawer tier) so the project reclaims the full width. The card
   slides back in as soon as the project starts leaving (driven by body.project-open, toggled
   in openProject/doExit — not :has(), which would fire too late for an animated reopen). */
@media (min-width: 1536px) {
  /* the transition lives on .intro unconditionally (not just .project-open) so the slide
     BACK IN on close also eases, after project-open is removed. Scoped to this breakpoint,
     so it never leaks into drawer/mobile, which define their own .intro transitions. */
  /* animation:none is UNCONDITIONAL here (matching the drawer/mobile tiers) — cancelling the
     cardIn load slide together with the transform change in the same class-add gave the browser
     no "before" frame to interpolate from, so the fold jumped instead of animating. Decoupled,
     the transition on the always-present .intro rule now drives both the fold-out and slide-back. */
  .intro { animation: none; transition: transform 0.7s cubic-bezier(0.22, 1, 0.36, 1); }
  /* entrance (home-enter, desktop only): the persistent card slides in, then the bio lines
     cascade. Outranks the `animation: none` above (0,2,0 > 0,1,0). backwards fill releases to
     the base .intro after the run so cardIn never pins the fold transform (see load-motion). */
  body.home-enter .intro { animation: cardIn 0.72s cubic-bezier(0.22, 1, 0.36, 1) 0.12s backwards; }
  body.home-enter .card-line {
    animation: lineIn 0.55s cubic-bezier(0.22, 1, 0.36, 1) backwards;
    animation-delay: calc(120ms + var(--i, 0) * 52ms);
  }
  body.project-open { --content-x: var(--pad); }        /* project reclaims the card's width */
  body.project-open .intro { transform: translateX(-100%); }

  /* the folded card lets a project span the full width, so the bottom-anchored tools row can
     collide with the floating contact pill (glassbar). Reserve clearance for the pill's height
     + shadow reach — only while a project is open (home-view/persistent-card spacing untouched). */
  body.project-open .pv__media { bottom: calc(var(--pad) + 76px); }

  /* adopt the drawer tier's near-50/50 project split while the card is folded (copied from
     the 768–1535px block — proven values). */
  body.project-open .pv__media.pv--gallery { right: 54%; }
  body.project-open .pv__panel { right: 50%; }
  body.project-open .pv__carousel { left: 46%; }
  /* the spark's host box was still sized to the old 36/64 (persistent-card) panel width —
     narrower than the folded panel's actual 50% edge, so the graphic drew as if the panel
     still ended where it used to. Widen its box to match. */
  body.project-open .pv__bgspark { right: 50%; }

  /* back button off the card, onto the project's top-right (same as drawer tier) — always
     top-right on every page including galleries (R10, no 50%-split exception) */
  body.project-open .card-back { top: 20px; right: 20px; left: auto; }
}

/* Height-gated tightening for the PERSISTENT desktop card (≥1536 wide). At ~1080px tall the full
   stack (avatar / name / bio / Work Experience / Skills / Bits of My Life) overflows .intro__body
   and scrolls. Reclaim the space by tightening VERTICAL spacing ONLY — no font-size / line-height
   changes — mirroring the mobile-drawer tightening (≤767px block). Scoped to the persistent card
   tier (≥1536) so the drawer + 768–1535 tier are untouched. */
@media (min-width: 1536px) and (max-height: 1100px) {
  .hi-row { margin-bottom: 12px; }
  .hi-name { margin: 0 0 10px; }
  .bio { margin-bottom: 16px; }
  .rule { margin-bottom: 16px; }
  .subhead { margin: 0 0 10px; }
  .exp__title { margin-bottom: 12px; }
  .exp { margin-bottom: 18px; gap: 13px; }
  .skills { gap: 10px; margin-bottom: 16px; }
  .skill-card { min-height: 46px; }
  .polaroid { width: 118px; }
  .bits { padding: 4px 2px 8px; }
}
/* Shorter still (≤1000px tall): squeeze the same knobs a touch further so the card still fits. */
@media (min-width: 1536px) and (max-height: 1000px) {
  .hi-row { margin-bottom: 8px; }
  .hi-name { margin: 0 0 8px; }
  .bio { margin-bottom: 12px; }
  .rule { margin-bottom: 10px; }
  .subhead { margin: 0 0 8px; }
  .exp__title { margin-bottom: 10px; }
  .exp { margin-bottom: 14px; gap: 11px; }
  .skills { gap: 9px; margin-bottom: 12px; }
  .skill-card { min-height: 42px; }
  .polaroid { width: 106px; }
  .bits { padding: 3px 2px 5px; }
}

/* ---- Ultra-wide / 4K (≥2048px). The base design caps most type + chrome via clamp()
   ceilings tuned around 1440–1920; past ~2048 those ceilings leave a small design floating
   in a big canvas (Commbank text, the Plum player, the jump ticks all read tiny). Lift the
   ceilings — kept vw-fluid so they keep scaling out to 3840 — so the page reads as the SAME
   design, just larger. Mobile (<768) is untouched; these only apply at genuine 4K-class CSS
   widths (retina 4K reports as ~1920 CSS px and stays on the base tier). ---- */
@media (min-width: 2048px) {
  /* project / gallery type */
  .pv__g-heading { font-size: clamp(30px, 1.5vw, 54px); }
  .pv__g-desc { font-size: clamp(18px, 0.82vw, 30px); max-width: 68ch; }
  .pv__lead { font-size: clamp(22px, 1.7vw, 40px); max-width: min(1400px, 100%); }
  .pv__body { font-size: clamp(16px, 1vw, 26px); }
  .pv__meta { font-size: clamp(13px, 0.72vw, 20px); }
  .pv__outcome { font-size: clamp(18px, 1.3vw, 30px); }

  /* the inline video player was capped at 1040px — a small box in a 3840 canvas. Fill more. */
  .pv__video { width: min(100%, 68vw); }

  /* carousel breathes: wider side gutters + taller gaps between shots */
  .pv__carousel-track { padding: 0 clamp(112px, 8vw, 220px); gap: clamp(52px, 4vh, 96px); }

  /* jump switcher scales up so it isn't a hairline column lost in the corner */
  .pv__jump { transform: translateY(-50%) scale(1.35); transform-origin: right center; }

  /* focus (daydream) copy */
  .pv__dd-desc-title { font-size: clamp(24px, 1.5vw, 40px); }
  .pv__dd-desc-body { font-size: clamp(15px, 0.9vw, 24px); max-width: 40ch; }

  /* NOTE: the contact pill (.glassbar/.gbtn) is intentionally NOT scaled here — Robin wants it
     identical to the 1728 rendering at 2560+, so it inherits the base sizing. */

  /* back button */
  .card-back { width: 58px; height: 58px; }
  .card-back svg { width: 24px; height: 24px; }

  /* gallery foot chrome (CTA / tool chips / books / reports) */
  .pv__g-cta { font-size: 18px; padding: 14px 22px; }
  .pv__tool { font-size: 16px; padding: 9px 15px; gap: 12px; }
  .pv__book { max-width: 210px; }
  .pv__reports { gap: 26px; }
  .pv__g-foot { padding-top: 40px; }
}

/* contact pill is a touch smaller on laptop + mobile so it fits neatly under the card */
@media (max-width: 1535px) {
  .glassbar { padding: 5px; border-radius: var(--r-card); }
  .gbtn { height: 38px; padding: 0 15px; font-size: 13px; border-radius: var(--r-md); }
  .gbtn--icon { width: 38px; padding: 0; }
  .gbtn--primary { padding: 0 15px; }
  .gbtn svg { width: 18px; height: 18px; }

  /* fullscreen project pages (video / sites) scroll so the player + tools below the
     fold are reachable on a shorter screen (the desktop viewport fits them without it) */
  .pv__media.pv--video, .pv__media.pv--sites {
    overflow-y: auto; overflow-x: hidden; scrollbar-width: none;
    /* a scroll container CLIPS the player's 40px drop shadow into a hard line unless the
       shadow has room to fade INSIDE the scrollport. The negative margins cancel the
       padding exactly, so the CONTENT never moves — only the scrollport grows. */
    margin: -64px -64px 0;
    padding: 64px 64px 100px;   /* enough headroom for the 40px blur (offset 18 + blur 40) so it fades INSIDE, never clips to a line; bottom clears the tools + contact pill */
  }
  .pv__media.pv--video::-webkit-scrollbar,
  .pv__media.pv--sites::-webkit-scrollbar { display: none; }
  /* theater beats the scroll-tier margins/padding (higher specificity than the .pv--video
     rule above): the fixed fullscreen stage must not inherit the -64px scroll offsets */
  .pv__media.pv--video.pv--max { margin: 0; padding: var(--pad); overflow: visible; }
}

/* ============================================================ Mobile (portrait) */
/* The desktop layout is landscape: a full-height identity card on the left, the
   wheel off to the right. On a phone that doesn't fit, so the card becomes a compact
   top band and the wheel lives in the lower part of the screen (geometry retuned in
   computeGeom()). Project views go full-width + single column. */
@media (max-width: 767px) {

  /* ---- home: the identity card is a DRAWER hidden off the top; a grabber handle pulls
     it down to reveal the full profile. The wheel fills the screen behind it. ---- */
  .intro {
    inset: 0 0 auto 0;              /* pin to the top, full width */
    width: auto;
    max-height: 80vh;               /* leave room at the bottom so the card clears the contact pill */
    padding: clamp(18px, 5vw, 26px);
    border-right: none;
    border-bottom: 1px solid rgba(0, 0, 0, 0.1);
    border-radius: 0 0 var(--r-card) var(--r-card);
    box-shadow: 0 14px 28px rgba(0, 0, 0, 0.16), 0 4px 10px rgba(0, 0, 0, 0.10);   /* stacked tight shadows — no gradient banding */
    touch-action: pan-y;            /* the card body scrolls; the dial drag is elsewhere */
    animation: none;                /* no left slide-in; it lives off the TOP instead */
    transform: translateY(-100%);
    transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;
  }
  .intro.is-open { transform: translateY(0); }
  .intro::after { display: none; }  /* right-edge colour wash is for a left column */
  .bio { margin-bottom: 18px; }
  .rule { margin-bottom: 18px; }
  .exp { margin-bottom: 20px; }
  .skills { margin-bottom: 18px; }
  .polaroid { width: 116px; }
  /* work rows stack (period label above, role + pill below, wrapping) so nothing runs
     off a narrow phone */
  .exp__row { grid-template-columns: 1fr; row-gap: 5px; }
  .exp__when { font-size: 12px; }
  .exp__main { flex-wrap: wrap; row-gap: 6px; }

  /* top-centre grabber handle — same clean floating avatar + chevron as the laptop
     (no pill); transparent padding keeps a comfortable touch target. Chevron points
     DOWN (pull down to open). */
  .card-tab {
    display: flex; flex-direction: column; align-items: center; gap: 5px;
    position: fixed; z-index: 20; top: calc(env(safe-area-inset-top, 0px) + 8px); left: 50%;
    transform: translateX(-50%);
    padding: 6px 12px 8px;
    border: 0; background: none; cursor: grab; touch-action: none;
    transition: transform 0.42s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;
  }
  .card-tab__avatar {
    width: 46px; height: 46px; border-radius: 50%;
    background: url('./me/5.webp') 30% 44% / 175% no-repeat;   /* JS repaints + cycles this */
    box-shadow: 0 5px 15px rgba(0, 0, 0, 0.22), inset 0 0 0 2px rgba(255, 255, 255, 0.85);
    transition: opacity 0.3s ease, transform 0.3s ease;
  }
  /* idle knock, vertical here (the sheet pulls DOWN from the top edge) */
  @media (prefers-reduced-motion: no-preference) {
    .card-tab:not(.seen) .card-tab__avatar { animation: tabKnockY 6s ease-in-out infinite; }
  }
  .card-tab__chevron { transform: rotate(90deg); transition: transform 0.35s ease; }
  /* open: the circle fades away and just the chevron stays (points up = close); the handle
     rides up toward the very top edge so the close affordance isn't floating mid-ish */
  body.card-open .card-tab { transform: translateX(-50%) translateY(-8px); }
  body.card-open .card-tab__avatar { opacity: 0; transform: scale(0.3); }
  body.card-open .card-tab__chevron { transform: rotate(-90deg); }

  /* scrim behind the open sheet — tap to close */
  .card-scrim {
    display: block; position: fixed; inset: 0; z-index: 17;
    background: rgba(20, 18, 26, 0.32);
    opacity: 0; pointer-events: none; transition: opacity 0.4s ease;
  }
  body.card-open .card-scrim { opacity: 1; pointer-events: auto; }
  /* grab strip on the card's bottom edge — pull it up / tap to close; carries a grabber pill */
  .intro__grab {
    display: block; position: absolute; left: 0; right: 0; bottom: 0; height: 30px; z-index: 5;
    touch-action: none; cursor: grab;
  }
  .intro__grab-pill {
    position: absolute; left: 50%; bottom: 7px; transform: translateX(-50%);
    width: 44px; height: 5px; border-radius: 3px;
    background: color-mix(in srgb, currentColor 22%, transparent);
  }

  /* the dial fills the screen now (card hidden by default); shorter fringe so the extreme
     edge words aren't blurred into looking unrendered */
  .fringe { left: 0; height: clamp(50px, 9vh, 90px); }
  .tiles { display: none; }
  .ring { touch-action: none; }     /* drag turns the wheel; stop the page panning/bouncing */

  /* glassbar centred along the bottom, clear of the screen edge */
  .glassbar { left: 50%; right: auto; bottom: max(16px, env(safe-area-inset-bottom)); transform: translateX(-50%); }   /* centred on mobile (Robin) — springs are hover-gated, so nothing clobbers this transform */
  /* entrance must keep the -50% centring offset (see homeRiseInMobile) so the pill doesn't flash right-shifted */
  body.home-enter .glassbar { animation: homeRiseInMobile 0.7s cubic-bezier(0.22, 1, 0.36, 1) 0.4s backwards; }
  /* toast: left/bottom come from JS live off the Contact button (screen-centre on mobile); the base
     .toast rule already carries the center-anchored transforms, so no mobile override is needed. */

  /* back control top-right; the title reserves room for it (see colRightX) so a long
     title like "The First Principle" no longer runs into it */
  .card-back { top: 16px; left: auto; right: 16px; }

  /* ---- a project is open: fade the card + handle out (never display:none — that pops) ---- */
  body:has(#projectView:not([hidden])) .intro { opacity: 0; pointer-events: none; }
  body:has(#projectView:not([hidden])) .card-tab { opacity: 0; pointer-events: none; }

  /* ---- project views: full width, stacked, no horizontal overflow ---- */
  :root { --content-x: var(--pad); }   /* content runs to the left padding — no card beside it */
  .pv__media { top: calc(var(--pad) + 116px); }    /* the morphed title is small on mobile */
  .pv__fold  { top: calc(var(--pad) + 116px); left: var(--pad); }

  /* gallery: stacked vertically — text on the TOP half (over the coloured panel),
     carousel on the BOTTOM half. The panel is a top sheet parked covering the carousel
     (translateY 100%, see panelCover()) that slides UP to reveal it on open. */
  .pv__panel {
    display: block;
    /* runs FAR off the top (mirrors desktop's left:-80vw) so that when it's parked /
       sliding it fully covers the carousel — no strip of it peeks above the panel.
       Only the rounded BOTTOM edge (at 50%) is ever on screen. */
    /* left/right run further off-screen (-4px) so the 2.5px lit bevel doesn't peek as a
       1px stroke on the sides — only the rounded BOTTOM edge shows */
    top: -80vh; left: -4px; right: -4px; bottom: 50%; height: auto;
    border-radius: 0 0 var(--r-card) var(--r-card);
    /* plasticky lip on that bottom edge: inset ring + lit bevel + a downward light-fall */
    box-shadow:
      inset 0 0 0 1px rgba(0, 0, 0, 0.12),
      inset 0 0 0 2.5px rgba(255, 255, 255, 0.22),
      0 22px 46px rgba(0, 0, 0, 0.2);
  }
  /* spark texture lives in the top-half brand panel on mobile (was hidden) */
  .pv__bgspark { display: block; left: 0; right: 0; top: 0; bottom: 50%; }
  .pv__bgspark.is-pending { opacity: 0; }   /* held until the title parks, then fades in */
  .pv__media.pv--gallery { right: var(--pad); bottom: 50%; overflow-y: auto; scrollbar-width: none; padding-bottom: 24px; }
  .pv__media.pv--gallery::-webkit-scrollbar { display: none; }
  .pv__g-desc { max-width: none; }
  /* the tools foot flows in-column below the text. On mobile we DON'T reserve the CTA's slot:
     an empty reserved block read as a phantom "link-out" skeleton between the body and tools.
     A hidden CTA fully collapses (display:none); when scrubbing to the one shot that carries a
     real CTA the tools nudge down slightly — acceptable here since they sit in the scrollable
     top-half, below the fold. (Desktop keeps its in-place fade.) */
  .pv--has-cta .pv__g-cta.is-hidden { display: none; }
  /* carousel extends UP under the panel's bottom edge so the rounded corners reveal the
     grid layer (not a flat strip of the same colour) */
  .pv__carousel { left: 0; top: 46%; }
  /* JS owns the carousel drag on touch — kill native scroll so the two don't fight (that
     fight is why it never settled / jumped back to top). Matches the desktop mouse-drag path. */
  .pv__carousel-track { padding: 0 16px; touch-action: none; }
  /* the desktop 56vh cap on square/tall shots assumes a full-height carousel; on mobile the
     carousel only occupies the bottom ~54% of the viewport (top:46% above), so 56vh massively
     overshoots — the shot ate almost the whole carousel, leaving no clearance above/below and
     crowding the contact pill. Scale the cap down to match the mobile carousel's real height. */
  .pv__carousel.is-square .pv__shot, .pv__shot--tall { width: min(100%, 24vh); }
  /* hide the carousel's blur folds on mobile — stacked under the panel, the top fold read
     as a light blurry "gap" band at the seam (esp. when the carousel surface is off-white) */
  .pv__carousel-fold { display: none; }
  .pv__tick-prev { display: none; }     /* no hover preview on touch... */
  /* ...but DO show the active tick's preview above the finger while scrubbing */
  body.scrubbing .pv__tick.is-current .pv__tick-prev {
    display: block; opacity: 1;
    top: auto; bottom: calc(100% + 24px); right: 4px; left: auto;
    transform: none;
  }
  /* switcher lives in the carousel (lower) half so it doesn't split across the seam */
  .pv__jump { top: 74%; transform: translateY(-50%) scale(0.7); transform-origin: right center; }

  /* video / sites: full width, cards stack one per row; bottom room so the tool chips
     clear the centred contact pill */
  .pv__sites { gap: 16px; }
  .pv__site { flex: 1 1 100%; max-width: none; }
  .pv__media.pv--sites, .pv__media.pv--video { padding-bottom: 76px; }
  /* side padding so card/frame drop shadows fall INSIDE the scroll box, which clips on the
     x-axis (.is-scroll sets overflow-x:hidden for vertical scrolling) — 8px was not nearly
     enough clearance for the .pv__site card's 34px blur radius and still hard-clipped it into
     a visible vertical line; match the padding to the largest shadow blur in play here. */
  .pv__media.pv--video, .pv__media.pv--sites { margin: -44px -40px 0; padding: 44px 40px 100px; }   /* neg-margin + padding so the 40px blur fades inside the scrollport (no hard clip line); content stays put */
  .pv__media.pv--video.pv--max { margin: 0; padding: var(--pad); overflow: visible; }   /* theater beats the mobile scroll-tier margins */
  .pv__video { width: 100%; }
  /* touch has no hover: the enlarge (→ native fullscreen) button is always shown */
  .pv__expand-btn { opacity: 1; pointer-events: auto; transform: none; right: 12px; bottom: 12px; }

  /* split (rolodex) page: stack text over the wheel overlay */
  .pv__media.pv--split { right: var(--pad); bottom: 52%; }
  .pv__wheel { left: 0; top: 50%; }
}

/* resume: single column on a phone */
/* mobile: less padding around the (zoom-fitted) A4 page so more of it fits. The A4 sizing +
   fit-to-viewport zoom now lives on the base .cv__page rule (applies on every viewport). */
@media screen and (max-width: 767px) {
  .cv__scroll { padding: 10px 6px; }
}

/* =========================================================== Resume (native A4 page) */
.cv[hidden] { display: none; }   /* the hidden attribute must win over the display:flex below */
.cv {
  position: fixed; inset: 0; z-index: 50;
  display: flex; flex-direction: column;
  background: rgba(20, 18, 26, 0.55);
  -webkit-backdrop-filter: blur(10px); backdrop-filter: blur(10px);
  opacity: 0; transition: opacity 0.3s ease;
}
.cv.is-open { opacity: 1; }
.cv__bar {
  position: absolute; top: clamp(16px, 2.4vw, 28px); right: clamp(16px, 2.4vw, 28px); z-index: 2;
  display: flex; gap: 10px;
}
.cv__act {
  display: inline-flex; align-items: center; gap: 8px; cursor: pointer;
  font: inherit; font-size: 14px; font-weight: 600; letter-spacing: -0.01em; color: #1b1b1b;
  padding: 10px 16px; border-radius: var(--r-xs); border: 1px solid rgba(0, 0, 0, 0.1);
  background: linear-gradient(168deg, #ffffff, #f3f3ee);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.85), 0 10px 28px rgba(0, 0, 0, 0.22);
  transition: transform 0.2s ease;
}
.cv__act:hover { transform: translateY(-1px); }
.cv__act--close { padding: 10px; }
.cv__scroll { flex: 1 1 auto; overflow: auto; display: flex; justify-content: center; align-items: center; padding: clamp(20px, 5vh, 64px) 20px; }
/* .cv__fit reserves the SCALED footprint (transform:scale doesn't affect layout size, unlike
   the old `zoom`, which mobile browsers handled unreliably) so flex centering keeps working.
   It also owns the open animation — the page itself only ever carries the fit scale. */
.cv__fit {
  flex: 0 0 auto;
  width: calc(794px * var(--cv-zoom, 1));
  height: calc(1123px * var(--cv-zoom, 1));
  opacity: 0; transform: translateY(14px);
  transition: opacity 0.4s ease, transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
.cv.is-open .cv__fit { opacity: 1; transform: none; }
.cv__page {
  /* ALWAYS true A4 (794x1123 @96dpi). Never reflow/stretch it — it's only ever scaled
     uniformly to fit the viewport via --cv-zoom (set in JS on #cv; print resets it).
     height (not min-height) + overflow:hidden: content can NEVER stretch it past A4. */
  width: 794px; height: 1123px; overflow: hidden;
  transform: scale(var(--cv-zoom, 1)); transform-origin: top left;
  background: #fff; color: #2c2e35; border-radius: var(--r-md);
  box-shadow: 0 40px 90px rgba(0, 0, 0, 0.4);
  padding: clamp(34px, 5.4%, 52px); box-sizing: border-box;
}
/* Switzer (self-hosted, preloaded) so the resume renders identically on every OS — a
   Helvetica/Arial stack rendered differently Mac vs Windows. */
.cv__page { font-family: "Switzer", -apple-system, Arial, sans-serif; line-height: 1.42; letter-spacing: -0.004em; }
/* margin reset at SAME specificity as the spacing classes below, so those (declared later) win.
   The old `.cv__page p {margin:0}` was more specific (0,1,1) and silently killed every <p> margin. */
.cv__page * { margin: 0; }
.cv__head { display: grid; grid-template-columns: 1fr 1fr; column-gap: 44px; align-items: start; margin-bottom: 42px; }   /* room for the note; >= bio -> sections */
.cv__name { font-size: 23px; font-weight: 400; letter-spacing: -0.02em; line-height: 1.1; margin-bottom: 8px; }
.cv__linkwrap { position: relative; }
.cv__site { font-size: 11px; font-weight: 600; color: #2c2e35; text-decoration: none; }
.cv__note { position: absolute; top: 15px; left: 4px; display: flex; flex-direction: column; align-items: flex-start; font-family: 'Indie Flower', cursive; font-size: 16px; line-height: 1; color: #2c2e35; white-space: nowrap; pointer-events: none; }
.cv__arrow { width: 54px; height: 36px; margin-left: 24px; margin-bottom: 2px; color: #2c2e35; opacity: 0.75; }
.cv__contact { justify-self: start; width: max-content; }   /* rule only as wide as the contact text */
.cv__rule { height: 1px; background: rgba(44, 46, 53, 0.55); margin-bottom: 8px; }
.cv__h { font-size: 12.5px; font-weight: 600; margin-bottom: 16px; }   /* heading -> first content (a real boundary) */
.cv__meta { font-size: 11px; line-height: 1.72; }
.cv__body { display: grid; grid-template-columns: 1fr 1fr; column-gap: 44px; align-items: start; }
.cv__intro { grid-column: 1; grid-row: 1; margin-bottom: 38px; }   /* fills column 1 to match the body text below (was capped at 33ch, read as too narrow) */
.cv__intro p { font-size: 14px; line-height: 1.5; text-wrap: balance; }   /* two separate <p> so balance evens each paragraph on its own (one shared <p> orphaned the last word) */
.cv__intro p + p { margin-top: 21px; }   /* the gap the old <br><br> gave */
.cv__exp { grid-column: 1; grid-row: 2; }
.cv__right { grid-column: 2; grid-row: 2; display: flex; flex-direction: column; gap: 32px; }
.cv__sec { display: flex; flex-direction: column; }
.cv__job { display: flex; flex-direction: column; margin-bottom: 22px; }
.cv__job:last-child { margin-bottom: 0; }
.cv__job::after { content: ''; display: block; width: 16px; height: 1px; background: rgba(44, 46, 53, 0.5); margin-top: 13px; }   /* little dash under each role */
.cv__job-top { display: flex; justify-content: space-between; gap: 12px; }
.cv__org { font-size: 11px; font-weight: 600; }
.cv__role { font-size: 11px; font-weight: 600; text-align: right; }
.cv__inst { font-size: 11px; font-weight: 600; }
.cv__dates { font-size: 11px; font-weight: 600; color: #000; margin-top: 2px; }   /* full black, tight under the company (grouped) */
.cv__desc { font-size: 11px; line-height: 1.55; margin-top: 14px; }   /* boundary: title/dates group -> body */
.cv__sub { font-size: 12px; font-weight: 600; margin-top: 20px; margin-bottom: 6px; }   /* boundary: prev body -> this sub-heading */
.cv__sub + .cv__desc { margin-top: 0; }   /* a body directly under a sub-heading is GROUPED with it (tight) */
.cv__h + .cv__sub { margin-top: 0; }       /* first sub sits tight under its section heading */

@media print {
  body { background: #fff !important; }
  body > *:not(.cv) { display: none !important; }
  .cv { position: static; display: block; inset: auto; background: #fff; backdrop-filter: none; -webkit-backdrop-filter: none; opacity: 1; }
  .cv__bar { display: none; }
  .cv__scroll { display: block; overflow: visible; padding: 0; }
  .cv__fit { width: auto; height: auto; opacity: 1; transform: none; }
  .cv__page { width: 100%; height: auto; overflow: visible; margin: 0; padding: 53px 14mm; border-radius: 0; box-shadow: none; transform: none; }   /* 53px (integer) top/bottom; 14mm sides preserve the text measure */
  /* Section rules sit at fractional Y (accumulated from the name's 25.3px line-height etc.), so a
     1px fill AA-spreads and looks thicker than the crisp job dashes. Draw them as a hairline BORDER
     of a zero-height box instead of a filled 1px div — a border snaps to one device row, matching
     the dashes' delicacy regardless of sub-pixel position. */
  .cv__rule { height: 0; border-top: 1px solid rgba(44, 46, 53, 0.5); background: none; }
  /* force the background-drawn divider lines + dashes to actually print */
  .cv, .cv * { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
  .cv__site { text-decoration: underline; text-underline-offset: 2px; }   /* show robinhenstock.com as a link in the PDF */
  @page { size: A4; margin: 0; }
}
