/* ==== Design System V3 ==== */

/* ============================================================
   WEBFONTS — self-hosted, on purpose
   ============================================================
   These were loaded from fonts.googleapis.com. That could never render correct
   text on first paint: the browser cannot discover a font FILE until it has
   fetched AND PARSED Google's stylesheet, so the fonts always arrived after the
   first frame and the page visibly re-flowed from system-ui into Archivo. No
   amount of preconnect fixes that — it is an ordering problem, not a latency
   one. Served from our own origin and <link rel=preload>ed in the page head,
   the fetch starts at 0ms alongside the HTML instead.

   Each file is ONE VARIABLE font covering the whole weight range, which is why
   four files (144 KB total) replace what looked like 19 separate requests.
   Do not "optimise" this into per-weight files — that is strictly more bytes
   and more requests.

   font-display:block means text is invisible until its font is ready, rather
   than painted in the wrong face and then swapped. That is the deliberate
   trade: with a same-origin preload the blank window is normally under ~50ms
   and exactly zero on a repeat visit, because Caddy serves .woff2 as
   `immutable, max-age=31536000`.

   unicode-range is Google's own `latin` subset verbatim — these files ARE that
   subset, so claiming any wider coverage would render tofu instead of falling
   back. Anything outside it drops to system-ui, which is correct.

   The four preload tags in every page head are load-bearing. Adding a family
   here without adding its preload gives you invisible text instead of a flash.
   ============================================================ */
@font-face{
  font-family:"Archivo";
  font-style:normal;
  font-weight:100 900;
  font-display:block;
  src:url("fonts/archivo.woff2") format("woff2");
  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:"Geist";
  font-style:normal;
  font-weight:100 900;
  font-display:block;
  src:url("fonts/geist.woff2") format("woff2");
  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:"Inter";
  font-style:normal;
  font-weight:100 900;
  font-display:block;
  src:url("fonts/inter.woff2") format("woff2");
  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:"JetBrains Mono";
  font-style:normal;
  font-weight:100 800;
  font-display:block;
  src:url("fonts/jetbrains-mono.woff2") format("woff2");
  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 & Token Vars ---- */
:root{
  --font-wordmark:"Archivo", system-ui, sans-serif;
  --font-geist:"Geist", system-ui, sans-serif;
  --font-display:"Geist", system-ui, sans-serif;
  --font-body:"Inter", system-ui, sans-serif;
  --font-mono:"JetBrains Mono", ui-monospace, monospace;
  /* backward-compat alias used by admin/block CSS */
  --font-head:var(--font-wordmark);
  --font-logo:var(--font-wordmark);

  --radius:2px;

  /* ---- Page geometry ----
     The shell width, and everything measured against it. It was a bare 1200px
     literal in .head-inner and .content, with its consequences hand-computed
     into the page index's left: — 576 was (1200/2 - 24), 810 was that plus the
     rail and its gap. Change the literal alone and the rail keeps positioning
     itself against a shell that no longer exists: at 1120 it sat 30px off the
     viewport edge and 70px off the copy, hugging the wrong side.

     --shell-edge is the load-bearing one: the distance from the page's centre
     line out to where text starts. Half the shell, less the padding inside it.
     Every rail number below is derived from it, so the shell is now one number.

     NOT everything can follow. Media queries cannot read custom properties —
     `@media (max-width: var(--shell))` is invalid CSS and fails silently — so
     the two breakpoints in the page index are still literals and still have to
     be moved by hand. Each one names the figure it comes from. */
  --shell:1120px;
  --shell-pad:24px;
  --shell-edge:calc(var(--shell) / 2 - var(--shell-pad));
  --rail-w:240px;

  /* ---- Cursor glow on hairlines (see the CURSOR GLOW section) ----
     --mx/--my are the pointer in VIEWPORT coordinates, written by
     rule-glow.js. They start off-screen so nothing is lit before the first
     pointer event and on any device that never sends one. --glow-a is the
     master fade, 0 or 1; it is a registered property so it can transition. */
  --mx:-9999px;
  --my:-9999px;
  --glow-radius:240px;
  --glow-alpha:.84375;
  /* --glow-ink is a THEME token, set with the palettes below: the effect is a
     grey lift of the line itself, not the accent, so it has to move with the
     line. One shared gradient so every lit edge is the same light.

     --glow-c is that light at full strength, with the master fade folded in;
     the stops below scale it. Splitting it out is what lets every stop reuse
     one definition of the colour instead of restating the calc five times. */
  --glow-c:color-mix(in srgb, var(--glow-ink)
             calc(var(--glow-alpha) * var(--glow-a) * 100%), transparent);

  /* The falloff, shaped the same way as the tab bar's (see CURSOR GLOW at the
     end of the file). Two stops gave a straight ramp — measured, it was still
     at 0.34 of full strength 100px out and 0.21 at 120px, so the light spread
     flatly across its whole radius and read as a fog rather than as something
     with a centre under the pointer.

     These decay faster than linear, roughly (1 - d/reach) squared, giving a
     bright core at the cursor that thins quickly and keeps a faint tail. The
     two inner stops carry the hot spot and are set 25% above that curve, which
     widens and brightens the core without touching the tail; the 0% stop is
     --glow-alpha itself and is already the ceiling, so the hot spot is raised
     from the inside out rather than by lifting the peak. The
     REACH IS UNCHANGED: the stops are placed at fifths of the same 70% the
     single stop used, so this redistributes the light rather than shrinking
     it. Percentages are of --glow-radius, so all of it scales together. */
  --glow:radial-gradient(circle var(--glow-radius) at var(--mx) var(--my),
      var(--glow-c) 0%,
      color-mix(in srgb, var(--glow-c) 76%, transparent) 14%,
      color-mix(in srgb, var(--glow-c) 41%, transparent) 28%,
      color-mix(in srgb, var(--glow-c) 14%, transparent) 42%,
      color-mix(in srgb, var(--glow-c)  3%, transparent) 56%,
      transparent 70%);

  /* ---- The same light, for the frames around media ----
     Gallery blocks, video blocks and the project cover. Painted by rule 5 in
     the CURSOR GLOW section; only the colour lives here.

     WHY THIS CANNOT REUSE --glow ABOVE, mechanically: a custom property is
     substituted where it is DECLARED, not where it is used. --glow resolved
     --glow-ink on THIS element, :root, and its computed value is already the
     finished gradient — so setting a different ink further down the tree
     cannot reach back inside it. A second ink needs a second gradient, and the
     falloff is restated rather than shared because CSS gives no way to
     parameterise a token. Keep the two stop lists identical; only the ink and
     the alpha are meant to differ.

     WHY IT NEEDS A DIFFERENT INK: every other lit edge in this file sits on
     ground this stylesheet chose, which is what lets --glow-ink be a measured
     few steps up from --rule. This one sits one pixel inside a frame full of
     ARTWORK, and the colour underneath is whatever the work happens to be.
     White is the only ink that behaves on an unknown ground: where it reads,
     it reads as light, and where it does not — a blown-out photo — it
     disappears rather than smudging a grey wash across the edge of the work.

     And why it is NOT per-theme, unlike --glow-ink: that token has to move
     with the line it lifts, and the line's ground flips between themes. This
     one's ground never was the theme's, so there is nothing to mirror. Light
     switches the effect off through the alpha in OFF IN LIGHT, the same way it
     switches off every other glow here.

     --media-glow-alpha is the strength dial: the peak white directly under the
     cursor. It sits above the 50% the frame itself lights to, so the hot spot
     stays brighter than the stroke it is riding on — a glow that only matched
     the line would read as a thicker line. */
  --media-glow-ink:hsl(0 0% 100%);
  --media-glow-alpha:.7;
  --media-glow-c:color-mix(in srgb, var(--media-glow-ink)
             calc(var(--media-glow-alpha) * var(--glow-a) * 100%), transparent);
  --media-glow:radial-gradient(circle var(--glow-radius) at var(--mx) var(--my),
      var(--media-glow-c) 0%,
      color-mix(in srgb, var(--media-glow-c) 76%, transparent) 14%,
      color-mix(in srgb, var(--media-glow-c) 41%, transparent) 28%,
      color-mix(in srgb, var(--media-glow-c) 14%, transparent) 42%,
      color-mix(in srgb, var(--media-glow-c)  3%, transparent) 56%,
      transparent 70%);


  /* runtime accent (overridden by strip.js) */
  --accent-h:152; --accent-s:62%; --accent-l:54%;
  --accent:hsl(var(--accent-h) var(--accent-s) var(--accent-l));
  --accent-dim:hsl(var(--accent-h) var(--accent-s) calc(var(--accent-l) - 6%) / .35);

  /* toast hover vars — overridden by strip.js at runtime */
  --toast-fill-h:var(--accent-h);
  --toast-fill-s:var(--accent-s);
  --toast-fill-l:var(--accent-l);
  --toast-border:var(--accent);
  --toast-fill:var(--accent);

  /* Light theme defaults (used when no data-theme attr) */
  --bg:#f4f4f2;
  --paper:#ececea;
  --panel:#ffffff;
  --ink:#101012;
  --ink-soft:#34343a;
  --muted:#6a6a6e;
  --rule:#dcdcd8;
  --rule-strong:#c8c8c4;
  /* Cursor glow. LIFTS the line in both themes — the glow makes a line
     brighter, never darker, and that has to hold on light ground too. An
     earlier version went the other way here, deepening the line on the theory
     that "more present" was the light-mode equivalent of brighter; it read as
     a blue-grey bruise smeared along the rule, which is not a glow.

     WHITE, and all the way to it. On this ground the rule (220) is darker than
     the paper (244), so a line lifted toward white passes through the paper
     value on its way up and is briefly invisible there. That is not a defect
     to design around — it is what light glinting off a wire actually does, and
     the hot spot on the far side of it is the point. An earlier version
     stopped short of the paper to avoid the crossing, and bought a line that
     only ever got softer and never glinted at all.

     --glow-alpha goes to 1 here, unlike dark: white IS the ceiling, so the
     glint is only as bright as alpha lets it reach. At 1 the core lands on 255
     against a 244 page — the full 11 levels this ground has to give. Anything
     less spends part of the only headroom there is.

     Both grounds under these overlays — the rule at 220 and the page at 244 —
     lift the same way toward white, so ONE ink serves them both. A previous
     version needed a second one, because an ink that stopped short of the
     paper could lift a 220 line but not a 244 one; going to white removed the
     split and the duplicate gradient that came with it. */
  --glow-ink:hsl(214 10% 100%);
  --glow-alpha:1;
  /* How the active tab's light combines with the cursor's where they
     overlap, and how strong its own pool is.

     screen on dark, multiply on light. They are mirror images — screen never
     darkens, multiply never lightens — so each is the REINFORCING direction
     for its own ground. On light that matters more than it sounds: screen
     bleaches a saturated accent against paper, measured taking the marker
     green from saturation 65 down to 4, essentially white. Colour accumulates
     on a light substrate the way ink does, by multiplying.

     It also composes with the cursor glint, which goes to white here:
     multiplying the accent into a white-lifted line returns the accent at full
     strength, so the two lights reinforce rather than cancel.

     Light carries a higher --tab-glow-alpha for the same reason: a 40% wash
     that reads as a clear glow against near-black is thin against paper. */
  --glow-blend:multiply;
  --tab-glow-alpha:70%;
  --media-bg:#e2e2de;
  /* Resting media stroke = the divider colour. It used to be its own literal,
     a couple of steps brighter than --rule, and that made a framed gallery the
     heaviest hairline on the page — louder than the rules it sat between. It
     is the same kind of line, so it is the same value. Rollover is where the
     frame gets to speak, and that stays its own literal below. */
  --media-stroke:var(--rule);
  --media-stroke-hover:hsl(0 0% 50%);  /* rollover. Mirror of dark's 50% — see below */

  /* legacy aliases used in admin / block styles */
  --border:var(--rule-strong);
  --chip:var(--paper);
  --chip-ink:var(--ink);
  --shadow-ambient: 0 1px 2px rgba(0,0,0,.06), 0 8px 24px rgba(0,0,0,.06);
}

html[data-theme="light"]{
  --bg:#f4f4f2;
  --paper:#ececea;
  --panel:#ffffff;
  --ink:#101012;
  --ink-soft:#34343a;
  --muted:#6a6a6e;
  --rule:#dcdcd8;
  --rule-strong:#c8c8c4;
  /* Cursor glow. LIFTS the line in both themes — the glow makes a line
     brighter, never darker, and that has to hold on light ground too. An
     earlier version went the other way here, deepening the line on the theory
     that "more present" was the light-mode equivalent of brighter; it read as
     a blue-grey bruise smeared along the rule, which is not a glow.

     WHITE, and all the way to it. On this ground the rule (220) is darker than
     the paper (244), so a line lifted toward white passes through the paper
     value on its way up and is briefly invisible there. That is not a defect
     to design around — it is what light glinting off a wire actually does, and
     the hot spot on the far side of it is the point. An earlier version
     stopped short of the paper to avoid the crossing, and bought a line that
     only ever got softer and never glinted at all.

     --glow-alpha goes to 1 here, unlike dark: white IS the ceiling, so the
     glint is only as bright as alpha lets it reach. At 1 the core lands on 255
     against a 244 page — the full 11 levels this ground has to give. Anything
     less spends part of the only headroom there is.

     Both grounds under these overlays — the rule at 220 and the page at 244 —
     lift the same way toward white, so ONE ink serves them both. A previous
     version needed a second one, because an ink that stopped short of the
     paper could lift a 220 line but not a 244 one; going to white removed the
     split and the duplicate gradient that came with it. */
  --glow-ink:hsl(214 10% 100%);
  --glow-alpha:1;
  /* How the active tab's light combines with the cursor's where they
     overlap, and how strong its own pool is.

     screen on dark, multiply on light. They are mirror images — screen never
     darkens, multiply never lightens — so each is the REINFORCING direction
     for its own ground. On light that matters more than it sounds: screen
     bleaches a saturated accent against paper, measured taking the marker
     green from saturation 65 down to 4, essentially white. Colour accumulates
     on a light substrate the way ink does, by multiplying.

     It also composes with the cursor glint, which goes to white here:
     multiplying the accent into a white-lifted line returns the accent at full
     strength, so the two lights reinforce rather than cancel.

     Light carries a higher --tab-glow-alpha for the same reason: a 40% wash
     that reads as a clear glow against near-black is thin against paper. */
  --glow-blend:multiply;
  --tab-glow-alpha:70%;
  --media-bg:#e2e2de;
  --media-stroke:var(--rule);
  --media-stroke-hover:hsl(0 0% 50%);
  --border:var(--rule-strong);
  --shadow-ambient: 0 1px 2px rgba(0,0,0,.06), 0 8px 24px rgba(0,0,0,.06);
}

html[data-theme="dark"]{
  --bg:#0a0a0c;
  --paper:#101013;
  --panel:#16161a;
  --ink:#ececee;
  --ink-soft:#bdbdc0;
  --muted:#86868a;
  --rule:#1e1e22;
  --rule-strong:#2a2a2e;
  /* A grey well up from --rule. The line is already lighter than the ground
     here, so lifting it further is a real glow.

     Raised from 21.9% by request, to make the cursor's effect on the page's
     hairlines plainer. Worth knowing what that crossed: at 21.9% the ink sat
     between --rule (#1e1e22) and --rule-strong (#2a2a2e), so a lit --rule line
     climbed to about the weight of a resting --rule-strong one and no further.
     At 30% it resolves to rgb(69,76,84) and clears --rule-strong outright, so
     a lit hairline is now brighter than ANY resting line on the page — which
     is what makes it read as light rather than as a line thickening, and is
     also why it cannot go much further without the pool reading as a haze laid
     over the page instead of as something the lines are catching.

     This is the general-lines ink only. The frames around media carry their
     own, --media-glow-ink, and are unaffected — see the token up top. */
  --glow-ink:hsl(214 10% 30%);
  --glow-alpha:.84375;   /* light overrides this; see its note */
  --glow-blend:screen;
  --tab-glow-alpha:40.5%;
  --media-bg:#06060a;
  --media-stroke:var(--rule);
  --media-stroke-hover:hsl(0 0% 50%);
  --border:var(--rule-strong);
  --shadow-ambient: 0 1px 2px rgba(0,0,0,.25), 0 8px 24px rgba(0,0,0,.25);
}

/* ---- Dark: framed components drop to the divider colour ----
   The framed pattern is a 1px --rule-strong ring OUTSIDE the box with --rule
   for the divisions inside it. Three components carry it: the stats strip, the
   page index and the list block.

   In LIGHT that two-step reads as intended — the ring closes the object, the
   dividers structure it. In DARK it does not. #2a2a2e against a #0a0a0c ground
   is a bigger relative jump than #c8c8c4 is against #f4f4f2, so the ring came
   forward as its own mark while every other line on the page — title rules,
   dividers, the rail's spine — sat back at #1e1e22. One box outlined louder
   than the page it was on.

   So in dark the ring joins them: one line colour, and the object is read from
   its corners and its fill rather than from a brighter edge. The dividers are
   already --rule, which is why these rules only touch the ring.

   Scoped to dark on purpose. Light keeps the two-step — it works there, and
   this is the only place in the sheet where a component's own value differs by
   theme rather than coming from a token. Adding a fourth framed component
   means adding it here too.

   The :not() is load-bearing. A list set to draw no lines kills its ring with
   `.block-list[data-lines="none"]{ box-shadow:none }` — a class and an
   attribute, which a bare `html[data-theme="dark"] .block-list` would outrank,
   handing an unframed list a ring back in dark only. Skipping the mode
   entirely is steadier than winning a specificity race against it. */
html[data-theme="dark"] .proj-stats,
html[data-theme="dark"] .pindex,
html[data-theme="dark"] .block-list:not([data-lines="none"]){
  box-shadow:0 0 0 1px var(--rule);
}

/* ---- Base ---- */
*{ box-sizing:border-box }
html,body{
  margin:0; background:var(--bg); color:var(--ink);
  font-family:var(--font-body); font-weight:400;
  -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;

  /* ---- no sideways scroll, no zoom ----
   * pan-y, not manipulation. `manipulation` only took double-tap-to-zoom off
   * the table and left pinch alone; pan-y allows vertical panning and nothing
   * else, so pinch and double-tap both go. Anything on the page that needs a
   * horizontal drag of its own sets its own touch-action and is unaffected —
   * the scrub bar and the volume slider already declare touch-action:none, and
   * the tab bar scrolls through overflow-x:auto rather than a gesture.
   *
   * Neither of these is the whole story on iOS: Safari has ignored
   * user-scalable=no in the viewport meta since iOS 10, and does not reliably
   * honour touch-action against the pinch gesture either. The other half is
   * the gesture guard in touch-lock.js. */
  touch-action:pan-y;
}
/* Sideways is CLIPPED, not hidden, and that distinction is load-bearing:
 * overflow:hidden makes an element a scroll container, and the tab bar is
 * position:sticky — a sticky element positions against its nearest scrolling
 * ancestor, so turning one on here is exactly how sticky bars quietly stop
 * sticking. `clip` does not scroll and creates no such ancestor.
 *
 * On the ROOT it propagates to the viewport, which is what makes it apply to
 * the whole page rather than to the html box.
 *
 * This is a backstop, NOT the fix for anything currently overflowing. The one
 * real offender — the project cover's full bleed missing the shell padding by
 * 8px — is corrected where it happens, further down. Clipping is here so a
 * future overflow shows up as a clipped edge instead of a page that slides
 * sideways under the thumb, which is the failure the ask is about.
 * overflow-x on html is stated alone; the other axis is left to compute to
 * auto so the page still scrolls vertically. */
html{ overflow-x:clip; }
a{ color:inherit }
::selection{ background:var(--palette-ink, var(--ink)); color:var(--bg) }
.visually-hidden{ position:absolute; width:1px; height:1px; margin:-1px; padding:0; border:0; clip:rect(0 0 0 0); overflow:hidden }
.empty-state{ color:var(--muted); font-family:var(--font-body); text-align:center; padding:48px 16px; }


/* ============================================================
   HEADER · V11 Toolbar
   ============================================================ */
.site-head{
  background:var(--bg);
}
.head-inner{
  max-width:var(--shell); margin:0 auto; padding:0 var(--shell-pad);
}

.toolbar{
  display:flex; align-items:center; gap:22px;
  padding:18px 0 16px;
}

.brand{
  display:flex; align-items:baseline; gap:14px;
  text-decoration:none; color:inherit; flex:none;
}
.brand .name{
  font-family:var(--font-wordmark);
  font-weight:800;
  font-stretch:125%;
  text-transform:uppercase;
  font-size:22px; letter-spacing:-0.03em; line-height:1;
  color:var(--ink);
}
.brand .role{
  font-family:var(--font-mono);
  font-size:10.5px; letter-spacing:.18em; text-transform:uppercase;
  color:var(--muted);
  padding-left:14px; border-left:1px solid var(--rule-strong);
  align-self:center;
}

/* Primary navigation (inside toolbar) */
.nav{
  margin-left:auto;
  display:flex; align-items:center; gap:24px;
  font-family:var(--font-mono);
  font-size:10.5px; letter-spacing:.18em; text-transform:uppercase;
}
.nav a{
  color:var(--muted); text-decoration:none;
  opacity:.7; transition:opacity .15s, color .15s;
  -webkit-tap-highlight-color:transparent;
}
.nav a:hover{ opacity:1; color:var(--ink); }
.nav a.is-active, .nav a.on{
  opacity:1; color:var(--ink);
}
.nav a.is-active::before, .nav a.on::before{
  content:""; display:inline-block;
  width:6px; height:6px; border-radius:50%;
  background:var(--palette-ink, var(--ink));
  margin-right:8px; vertical-align:1px;
}

/* Theme toggle — circular */
.theme-toggle{
  appearance:none; border:1px solid var(--rule-strong); background:transparent;
  width:22px; height:22px; border-radius:50%;
  cursor:pointer; position:relative; padding:0;
  margin-left:6px; flex:none;
  transition:border-color .15s;
  -webkit-tap-highlight-color:transparent;
}
.theme-toggle::after{
  content:""; position:absolute; inset:5px;
  background:var(--muted); border-radius:50%;
  transition:background .15s;
}
.theme-toggle::before{ display:none }
.theme-toggle .toggle-icon{ display:none }
@media (hover:hover) and (pointer:fine){
  .theme-toggle:hover{ border-color:var(--palette-ink, var(--ink)); }
  .theme-toggle:hover::after{ background:var(--palette-ink, var(--ink)); }
}

/* Strip canvas — full inner width. The height MUST be reserved here.
 *
 * It used to be `height:auto`, which on a <canvas> resolves against the
 * element's *attribute* size, not its content — and an unsized canvas defaults
 * to 300x150. At `width:100%` in the 1200px shell that computed to roughly
 * HALF THE CONTAINER WIDTH (~576px) for every first frame, until strip.js ran
 * and collapsed it. That was the layout jump on every page load.
 *
 * These numbers must equal strip.js's `cssH = rows * cell`. Those come from the
 * `_header` entry in palettes.json — {rows:2, cellD:6, cellM:3} — and NOT from
 * the colour theme: no theme defines rows/cellD/cellM, so `Object.assign({},
 * header, picked)` at strip.js:324 leaves _header's values standing. Reading
 * the per-theme defaults in strip.js (3/18/2) instead gives 54px and puts the
 * jump straight back. Both values below are MEASURED off the live element, not
 * derived. strip.js then writes the same number as an inline style, so nothing
 * moves. Change _header in palettes.json and you must change these too.
 *
 * The breakpoint is strip.js's DESKTOP_Q verbatim. Do not "tidy" it to match
 * the other breakpoints in this file.
 */
.strip-wrap{ padding:0 0 14px; }
.strip-wrap canvas{
  display:block; width:100%; border:0; border-radius:0;
  height:6px;                      /* _header rows 2 x cellM 3 */
}
@media (min-width:768px){
  .strip-wrap canvas{ height:12px; }   /* _header rows 2 x cellD 6 */
}


/* ============================================================
   CONTENT SHELL
   ============================================================ */
.content{
  max-width:var(--shell); margin:0 auto; padding:24px var(--shell-pad) 80px;
}


/* ============================================================
   MAIN PAGE — INDEX HERO
   ============================================================ */
.index-hero{
  padding:36px 0 22px;
  border-bottom:1px solid var(--rule);
}
.index-kicker{
  font-family:var(--font-mono);
  font-size:10.5px; letter-spacing:.18em; text-transform:uppercase;
  color:var(--muted); margin:0 0 14px;
  display:flex; align-items:center; gap:10px;
}
.index-kicker .accent-dot{
  width:6px; height:6px; border-radius:50%; background:var(--palette-ink, var(--ink));
  display:inline-block;
}
.index-kicker .sep{ color:var(--rule-strong); }
.index-title{
  font-family:var(--font-wordmark);
  font-weight:800; font-stretch:125%;
  font-size:clamp(44px, 7vw, 84px);
  letter-spacing:-0.035em; line-height:.95;
  margin:0; text-wrap:balance;
}
.index-title .slash{ color:var(--palette-ink, var(--ink)); font-weight:800; }
/* Subtitle under a page's hero title. Projects and About have none; authored pages may. */
.index-sub{
  margin:20px 0 0; max-width:62ch;
  font-size:clamp(17px, 1.7vw, 21px); line-height:1.45;
  color:var(--ink-soft); white-space:pre-line; text-wrap:pretty;
}


/* ============================================================
   MAIN PAGE — FILTER BAR
   ============================================================ */
.filter-bar{
  display:flex; align-items:center; gap:0;
  padding:14px 0 18px;
  font-family:var(--font-mono);
  font-size:10.5px; letter-spacing:.18em; text-transform:uppercase;
  flex-wrap:wrap;
}
.filter-bar .label{ color:var(--muted); margin-right:18px; }
.filter-chips{
  display:flex; align-items:center; gap:4px; flex-wrap:wrap;
}
.chip{
  appearance:none; border:0; background:transparent;
  color:var(--muted); font:inherit;
  padding:6px 10px; cursor:pointer;
  letter-spacing:.18em; text-transform:uppercase;
  transition:color .15s, background .15s;
  border-radius:var(--radius);
  -webkit-tap-highlight-color:transparent;
}
.chip:hover{ color:var(--ink); }
.chip .dot{
  display:none;
  width:5px; height:5px; border-radius:50%;
  background:var(--palette-ink, var(--ink));
  margin-right:6px; vertical-align:1px;
}
.chip.on{
  color:var(--ink);
}
.chip.on .dot{
  display:inline-block;
}
.filter-meta{
  margin-left:auto;
  color:var(--muted);
  display:flex; gap:14px; align-items:center;
}
.filter-meta .count .num{ color:var(--ink); font-weight:500; }
.viewmode{
  display:flex; gap:0; border:1px solid var(--rule-strong);
  border-radius:var(--radius); overflow:hidden;
}
.viewmode button{
  appearance:none; border:0; background:transparent;
  color:var(--muted); font:inherit;
  padding:5px 10px; cursor:pointer;
  letter-spacing:.18em; text-transform:uppercase;
  font-family:var(--font-mono); font-size:10.5px;
  transition:color .15s, background .15s;
  -webkit-tap-highlight-color:transparent;
}
.viewmode button + button{ border-left:1px solid var(--rule-strong); }
.viewmode button:hover{ color:var(--ink); }
.viewmode button.on{ background:var(--ink); color:var(--bg); }


/* ============================================================
   MAIN PAGE — PROJECTS GRID
   ============================================================ */
.projects-wrap{}
.projects{
  display:grid;
  grid-template-columns:repeat(3, 1fr);
  gap:20px;
}
@media (max-width:980px){ .projects{ grid-template-columns:repeat(2, 1fr); gap:14px; } }
@media (max-width:600px){ .projects{ grid-template-columns:1fr; gap:12px; } }

/* List mode header row */
.list-header{
  display:none;
  grid-template-columns:120px 1fr 180px 1fr 80px;
  gap:20px; align-items:center;
  padding:10px 14px 8px;
  font-family:var(--font-mono);
  font-size:9.5px; letter-spacing:.2em; text-transform:uppercase;
  color:var(--muted);
  border-bottom:1px solid var(--rule);
}
.projects-wrap.list-active .list-header{ display:grid; }
@media (max-width:760px){
  .projects-wrap.list-active .list-header{
    grid-template-columns:80px 1fr 70px; gap:14px;
  }
  .list-header span:nth-child(3),
  .list-header span:nth-child(4){ display:none; }
}


/* ============================================================
   PROJECT CARD (TOAST)
   ============================================================ */
.card{
  background:var(--panel);
  text-decoration:none; color:inherit;
  display:flex; flex-direction:column;
  position:relative;
  border-radius:var(--radius);
  overflow:hidden;
  box-shadow: 0 0 0 1px var(--rule-strong),
              0 1px 2px rgba(0,0,0,.06),
              0 8px 24px rgba(0,0,0,.06);
  transition:transform .015s ease-out;
  -webkit-tap-highlight-color:transparent;
  -webkit-touch-callout:none;
  -webkit-user-select:none; user-select:none;
}

.card-media{
  position:relative; width:100%; aspect-ratio:16/9;
  background:var(--media-bg);
  overflow:hidden;
}
.card-media img,
.card-media video{
  width:100%; height:100%; object-fit:cover; display:block;
}
.card-media .placeholder{
  position:absolute; inset:0;
  background:
    repeating-linear-gradient(45deg, transparent 0 8px, rgba(255,255,255,.04) 8px 9px),
    var(--media-bg);
  display:flex; align-items:center; justify-content:center;
  font-family:var(--font-mono); font-size:10px;
  letter-spacing:.18em; text-transform:uppercase;
  color:var(--muted);
}
html[data-theme="light"] .card-media .placeholder{
  background:
    repeating-linear-gradient(45deg, transparent 0 8px, rgba(0,0,0,.05) 8px 9px),
    var(--media-bg);
}

.card-body{
  padding:14px 16px 16px;
  display:flex; flex-direction:column;
  flex:1 1 auto;
  background:var(--panel);
}

.card-title{
  font-family:var(--font-wordmark);
  font-weight:700; font-stretch:110%;
  text-transform:uppercase;
  font-size:17px; letter-spacing:-0.015em; line-height:1.1;
  margin:0 0 10px; color:var(--ink);
  text-wrap:balance;
}

.card-meta{
  margin:0;
  display:grid;
  grid-template-columns:auto 1fr;
  column-gap:14px; row-gap:5px;
  font-family:var(--font-mono);
  font-size:10px; letter-spacing:.18em; text-transform:uppercase;
  color:var(--muted);
}
.card-meta dt{ color:var(--muted); }
.card-meta dd{
  margin:0; color:var(--ink-soft);
  font-weight:400; letter-spacing:.04em; text-transform:none;
  font-size:11px;
  white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}

/* Hover: body lights up, all text white */
@media (hover:hover) and (pointer:fine){
  .card:hover{
    transform:translateY(-2px);
    box-shadow: 0 0 0 1px var(--toast-border),
                0 4px 16px rgba(0,0,0,.18),
                0 12px 32px rgba(0,0,0,.14);
  }
  .card:hover .card-body{ background:var(--toast-fill); }
  .card:hover .card-title,
  .card:hover .card-meta dt,
  .card:hover .card-meta dd{ color:#fff; }
}
.card:active{ transform:translateY(0); }
.card:active .card-body{ background:var(--toast-fill); }
.card:active .card-title,
.card:active .card-meta dt,
.card:active .card-meta dd{ color:#fff; }

/* ---- List mode ---- */
.projects.list-mode{
  grid-template-columns:1fr;
  gap:0;
  border-top:1px solid var(--rule);
  border-bottom:1px solid var(--rule);
}
.projects.list-mode .card{
  display:grid;
  grid-template-columns:120px 1fr 180px 1fr 80px;
  align-items:center; gap:20px;
  padding:10px 14px;
  border-radius:0; box-shadow:none;
  background:transparent;
  border-bottom:1px solid var(--rule);
}
.projects.list-mode .card:last-child{ border-bottom:0; }
.projects.list-mode .card-media{
  margin:0; aspect-ratio:16/9;
  width:120px; height:auto;
  border:1px solid var(--rule);
}
.projects.list-mode .card-body{
  display:contents; background:transparent !important; padding:0;
}
.projects.list-mode .card-title{
  margin:0; font-size:15px; color:var(--ink);
}
.projects.list-mode .card-meta{ display:contents; }
.projects.list-mode .card-meta dt{ display:none; }
.projects.list-mode .card-meta dd{ font-size:11px; color:var(--ink-soft); }
.projects.list-mode .card-meta dd.year{ color:var(--ink); text-align:right; }
.projects.list-mode .card-meta dd.role{ color:var(--ink-soft); }
@media (hover:hover) and (pointer:fine){
  .projects.list-mode .card:hover{
    background:var(--toast-fill); transform:none;
    box-shadow:inset 0 0 0 1px var(--toast-border);
  }
  .projects.list-mode .card:hover .card-title,
  .projects.list-mode .card:hover .card-meta dd{ color:#fff; }
}
@media (max-width:760px){
  .projects.list-mode .card{
    grid-template-columns:80px 1fr 70px; gap:14px;
  }
  .projects.list-mode .card-media{ width:80px; }
  .projects.list-mode .card-meta dd.role,
  .projects.list-mode .card-meta dd.company{ display:none; }
}


/* ============================================================
   GALLERY CONSTRUCT
   ============================================================ */
.gal{ position:relative; width:100% }
.gal-viewport{
  position:relative; width:100%; overflow:hidden;
  border-radius:var(--radius);
  background:#0a0a0a;
}
/* Container stroke on media surfaces: gallery blocks, video blocks and the
 * project cover (the cover carries its own border declaration further down, so
 * it is coloured there rather than here — a later rule of equal specificity
 * would otherwise win and put --rule back).
 *
 * --media-stroke is --rule, the same value the dividers and the section-title
 * rules carry: at full ink weight the stroke shouted louder than the image it
 * framed, and even at its old dedicated literal it outweighed every other
 * hairline on the page. A frame around media is the same kind of line as a
 * divider, so it is the same colour, and it now follows --rule through any
 * future theme rather than drifting from it. box-sizing is border-box
 * globally, so the 1px sits inside the box and nothing reflows.
 * Opt out per gallery with the Composer's "Disable container stroke". */
.block-gallery .gal-viewport,
.block-video .vid-viewport{
  border:1px solid var(--media-stroke);
  transition:border-color .15s;
}
/* Rollover lights the stroke to a neutral, NOT the palette colour: the frame
 * should read as chrome around the work, and a per-palette hue competed with
 * whatever was inside it.
 *
 * --media-stroke-hover is an explicit per-theme literal rather than a shared
 * one: it CANNOT be one value in both themes. Light mode's page is #f4f4f2, so
 * a white rollover would sit at about 1.05:1 against it — the border would
 * vanish on hover instead of lighting up. Each theme takes the pole furthest
 * from its own background.
 *
 * HALFWAY TO THAT POLE, not all of it. The frame used to go to the pole itself
 * (#fff dark, #000 light) and at full strength it stopped reading as a frame
 * lighting up and started reading as a second, brighter object drawn around
 * the work. Backing off leaves the rollover unmistakable while keeping the
 * stroke subordinate to what it frames.
 *
 * The two literals are the SAME number seen from each theme's ground: dark
 * travels from black toward white, light from white toward black, and the
 * relationship between them is l_light = 100 - l_dark. Move one and move the
 * other, or the themes stop being mirrors.
 *
 * THAT THEY ARE CURRENTLY IDENTICAL IS A COINCIDENCE OF THE MIDPOINT, and not
 * a licence to collapse them into one shared declaration. 50 is the one value
 * that is its own mirror; the moment this is retuned to anything else the two
 * themes need different numbers again, and a single declaration would silently
 * light one theme the wrong way. They stay separate for the next edit's sake.
 *
 * The cursor glow rides on top of this; it lives in the CURSOR GLOW section at
 * the end of the file with the rest of the light, and is the one glow there
 * that is gated on :hover, so these frames are untouched at rest.
 *
 * Gated on (hover:hover) exactly like .theme-toggle above: without it a touch
 * tap leaves the border stuck lit until you tap elsewhere. */
@media (hover:hover) and (pointer:fine){
  .block-gallery .gal-viewport:hover,
  .block-video .vid-viewport:hover,
  .proj-cover:hover{ border-color:var(--media-stroke-hover); }
}
/* After the hover rule on purpose: equal specificity, so this must win and keep
 * an opted-out gallery at zero width whether or not the pointer is over it. */
.block-gallery.no-stroke .gal-viewport{ border:0; }
/* Fullscreen video already drops its corner radius; the stroke has to go with
 * it, or a hairline frames the entire screen. */
.vid-viewport.is-fullscreen,
.vid-viewport:-webkit-full-screen,
.vid-viewport:fullscreen{ border:0; }

.gal-track{ display:flex; height:100%; will-change:transform; }
.gal-slide{ flex:0 0 100%; height:100%; position:relative; }
.gal-img{
  display:block; width:100%; height:100%;
  object-fit:cover;
  -webkit-user-select:none; user-select:none;
}
/* Aspect 'original': the frame takes the ratio of the slide on show
 * (gallery.js, _fitOriginal), so cover and contain agree once it settles.
 * Contain is for the in-between — mid-swipe, the neighbour is drawn into the
 * current slide's box and cover would crop it. The transition eases the box
 * between two shapes where the browser interpolates aspect-ratio and is a
 * harmless snap where it does not. */
.gal.is-original .gal-img{ object-fit:contain; }
.gal.is-original .gal-viewport{ transition:aspect-ratio .35s cubic-bezier(.4,0,.2,1); }
.gal-caption{
  padding:10px 2px 4px;
  font-family:var(--font-body); font-size:14px; color:var(--muted);
  line-height:1.45; min-height:1.45em;
}
/* Gallery arrows, lightbox arrows and the lightbox close are all styled as the
 * buttons in the native <video> control bar: a bare white glyph, no fill, no
 * border, in EVERY state — measured off Chrome's own bar rather than guessed.
 * HOVER is Chrome's own: a translucent grey circle filling the button box, the
 * glyph staying white. Do NOT trust a screenshot probe to confirm this — two
 * separate attempts reported "no hover state" because that highlight does not
 * paint under headless, and it is in no UA rule CDP will hand back either. It
 * is plainly there in a real browser. A solid white puck was tried first and
 * was wrong: it read as a new component appearing, not the button responding.
 *
 * The drop-shadow does the job Chrome's control bar does with its dark gradient
 * scrim. These sit directly on the image, and several gallery images are white
 * or cream, where a bare white chevron would disappear. It is invisible over
 * dark imagery.
 *
 * The 36px box is kept for the hit area even though nothing paints it: at glyph
 * size alone this would be a 20px touch target. border-radius has no visible
 * effect without a fill; it is here so the inset focus ring stays a circle. */
.gal-arrow{
  position:absolute; top:50%; z-index:2;
  width:36px; height:36px; border-radius:50%;
  background:none; color:#fff;
  border:none;
  display:flex; align-items:center; justify-content:center;
  filter:drop-shadow(0 1px 3px rgba(0,0,0,.75));
  cursor:pointer;
  transition:opacity .2s ease, background-color .15s;
  -webkit-tap-highlight-color:transparent;
}

.gal-arrow:active{ transform:translateY(-50%) scale(.95) }
.gal-prev{ left:10px; transform:translateY(-50%) }
.gal-next{ right:10px; transform:translateY(-50%) }

/* The rollover, shared by all three: gallery arrows, lightbox arrows, lightbox
 * close. Same treatment Chrome gives the buttons in its own control bar. */
@media (hover:hover) and (pointer:fine){
  .gal-arrow:hover,
  .gal-lb-arrow:hover,
  .gal-lb-close:hover{ background:rgba(255,255,255,.2); }
}
.gal-dots{ display:flex; justify-content:center; gap:8px; padding:8px 0 2px; }
.gal-dot{
  width:8px; height:8px; border-radius:50%; padding:0;
  border:1px solid var(--border); background:var(--chip);
  cursor:pointer; transition:background .15s ease;
  -webkit-tap-highlight-color:transparent;
}
.gal-dot.is-active{ background:var(--ink) }

/* Gallery Lightbox */
.gal-lightbox{
  position:fixed; top:0; left:0;
  width:100%; width:100dvw;
  height:100%; height:100dvh;
  z-index:9999;
  background:#000;
  display:flex; flex-direction:column;
  opacity:0; pointer-events:none;
  transition:opacity .25s ease;
  -webkit-tap-highlight-color:transparent;
  touch-action:none;
  overscroll-behavior:none;
}
.gal-lightbox.is-open{ opacity:1; pointer-events:auto; }
.gal-lb-topbar{
  position:absolute; top:0; left:0; right:0; z-index:3;
  display:flex; align-items:center; justify-content:space-between;
  padding:12px 12px 8px;
  padding-top:max(env(safe-area-inset-top, 12px), 12px);
  background:linear-gradient(to bottom, rgba(0,0,0,.5) 0%, transparent 100%);
  transition:opacity .2s ease;
}
/* Lightbox close — same bare white glyph as .gal-arrow and the native <video>
 * buttons. No resting puck and no hover fill: the reference is the video control
 * bar: a glyph at rest, a translucent circle on hover. :active deepens that
 * same circle rather than introducing a second idea. */
.gal-lb-close{
  width:44px; height:44px; border-radius:50%;
  border:none; background:none; color:#fff;
  display:flex; align-items:center; justify-content:center;
  cursor:pointer; -webkit-tap-highlight-color:transparent;
  filter:drop-shadow(0 1px 3px rgba(0,0,0,.75));
  transition:background-color .15s;
}
.gal-lb-close:active{ background:rgba(255,255,255,.3) }
.gal-lb-counter{
  font:14px/1 system-ui, sans-serif; color:rgba(255,255,255,.6); padding-right:4px;
}
.gal-lb-stage{
  position:absolute; top:0; left:0; right:0; bottom:0;
  overflow:hidden; touch-action:none;
}
.gal-lb-track{ display:flex; width:100%; height:100%; will-change:transform; }
.gal-lb-slide{
  flex:0 0 100%; width:100%; height:100%;
  display:flex; align-items:center; justify-content:center; overflow:hidden;
}
.gal-lb-img{
  max-width:calc(100% - 16px); max-height:calc(100% - 16px);
  object-fit:contain; display:block;
  -webkit-user-select:none; user-select:none;
  transform-origin:center center; will-change:transform;
}
.gal-lb-vid{ max-width:calc(100% - 16px); max-height:calc(100% - 16px); display:block; outline:none; }
/* Bare white glyph, matching .gal-arrow and the native <video> buttons. The
 * :active deepens the same circle. gallery.js drives this element's inline
 * opacity for the end-of-run (0.3) and zoomed (0) states; those inline values
 * outrank anything here, so the hidden and dimmed states still win. */
.gal-lb-arrow{
  position:absolute; top:50%; transform:translateY(-50%); z-index:3;
  width:44px; height:44px; border-radius:50%;
  border:none; background:none; color:#fff;
  display:flex; align-items:center; justify-content:center;
  cursor:pointer;
  filter:drop-shadow(0 1px 3px rgba(0,0,0,.75));
  transition:opacity .2s ease, background-color .15s;
  -webkit-tap-highlight-color:transparent;
}
.gal-lb-arrow:active{ background:rgba(255,255,255,.3) }
.gal-lb-prev{ left:8px }
.gal-lb-next{ right:8px }
.gal-lb-caption{
  position:absolute; bottom:0; left:0; right:0; z-index:3;
  text-align:center;
  padding:8px 16px 12px;
  padding-bottom:max(env(safe-area-inset-bottom, 12px), 12px);
  background:linear-gradient(to top, rgba(0,0,0,.5) 0%, transparent 100%);
  font:14px/1.45 system-ui, sans-serif; color:rgba(255,255,255,.8);
  transition:opacity .2s ease;
}


/* ============================================================
   TEXT BLOCK
   ============================================================ */
.block-text{
  width:100%; box-sizing:border-box; padding:4px 0;
}
/* Heading sizes for content blocks.
 *
 * The site's display type is a golden-ratio ladder, hand-rounded:
 *
 *     15.5  body        (.block-text-body)
 *     26    heading     (this rule)            15.5 x 1.618 = 25.1
 *     44    section     (.section-title, 966)  26   x 1.618 = 42.1
 *     66    project     (.proj-title, 1169)    15.5 x 1.618^3 = 65.7
 *
 * Which means there is NO rung available below the default heading: one phi
 * step down from 26 is 16.07, and body copy is 15.5. A "small heading" on the
 * existing ladder is a paragraph.
 *
 * So the extra sizes are HALF-rungs -- sqrt(phi) = 1.272 -- which subdivides
 * the ladder instead of contradicting it. Each clamp below is the default
 * clamp multiplied or divided by 1.272, with one deliberate exception noted.
 *
 *     h-sm  20    26 / 1.272 = 20.4
 *     --    26    default, unclassed
 *     h-lg  33    26 x 1.272 = 33.1   (halfway to the 44px section title)
 *
 * Corroboration that 1.272 is the right subdivision: a quarter-rung above 20
 * is 22.2, and .block-text-body h3 (line 809) has been 22px all along.
 *
 * THE EXCEPTION: h-sm's floor is 17px, not the 14.15px that scaling the
 * default's 18px floor would give. 14.15 is below the 15.5px body, so a small
 * heading would render SMALLER than the paragraph it introduces. 17px keeps it
 * above body at every width; at phone sizes it separates mostly on face and
 * weight (display 600 vs body 400) rather than on size, which is expected but
 * is why the floor is a judgement call and not arithmetic.
 *
 * Set from the Heading dropdown on text, gallery, grid and 3D-view blocks. */
.block-text-heading{
  margin:0 0 .5em;
  font-family:var(--font-display); font-weight:600;
  font-size:clamp(18px,3vw,26px); line-height:1.25; letter-spacing:-0.02em; color:var(--ink);
}
.block-text-heading.h-sm{
  font-size:clamp(17px,2.4vw,20px); line-height:1.3; letter-spacing:-0.01em;
}
.block-text-heading.h-lg{
  font-size:clamp(23px,3.8vw,33px); line-height:1.2; letter-spacing:-0.025em;
}
.block-text p{
  margin:0 0 1em;
  font-family:var(--font-body); font-weight:400;
  font-size:15.5px; line-height:1.65; color:var(--ink);
}
.block-text p:last-child{ margin-bottom:0 }

/* ------------------------------------------------------------
   LIST BLOCK
   A framed, celled list: the title is one cell and each item is
   another, carrying the same closed frame as the page index and
   the stats strip — 1px outside the box, --radius, and --rule for
   the divisions inside it. The ring is --rule-strong in light and
   --rule in dark; see the dark override by the theme tokens.
   The bullets take --palette-ink, the one accent this page uses
   for state everywhere else: the nav dot, the section number,
   the active node in the rail.
   ------------------------------------------------------------ */
.block-list{
  width:100%; box-sizing:border-box;
  border-radius:var(--radius);
  box-shadow:0 0 0 1px var(--rule-strong); --ring:1;
  overflow:hidden;
  /* Containing block for the glow ring in the CURSOR GLOW section. --ring and
     --sep below are that section's switches: every rule that draws a line here
     sets them to 1 and every rule that kills one sets them to 0, in the same
     declaration block, so the lit edge and the real edge cannot drift apart. */
  position:relative;
}
.block-list-head{ padding:18px 22px; }
/* The heading is already inside a cell, so it does not carry the bottom
   margin it uses when it sits above loose copy. */
.block-list-head .block-text-heading{ margin:0; }

.block-list-items{ list-style:none; margin:0; padding:0; }
.block-list-item{
  display:flex; gap:14px;
  padding:16px 22px;
  border-top:1px solid var(--rule);
  position:relative; --sep:1;
  font-family:var(--font-body); font-weight:400;
  font-size:15.5px; line-height:1.65; color:var(--ink);
}
/* The rule between cells belongs to the item above it. When there is no title
   the first cell is the block's own first child and has nothing to divide from
   — which is either a plain cell sitting directly in the block, or the first
   <li> of the first run of bullets. Both are spelled out; there is no single
   selector for "first cell" once the cells live at two different depths. */
.block-list > .block-list-item:first-child,
.block-list > .block-list-items:first-child > .block-list-item:first-child{ border-top:0; --sep:0; }

/* ---- Plain cells (bullet off) ----
   Framing copy — the sentence that introduces the bullets under it. It carries
   .block-list-item and is styled entirely by it: same type, same colour, same
   cell padding and divider as a bullet. The ONLY difference is the missing dot,
   and .is-plain adds no styling of its own — it is a renderer hook, kept for
   the same reason the admin's hook classes are.

   Two things fall out of the markup rather than out of a rule here. No dot is
   rendered at all, rather than one hidden with visibility: an empty 6px box
   plus the 14px gap would hold the copy in the bullet column, and the point of
   a plain cell is that it starts where the TITLE starts and the bullets hang in
   from it. With one child the flex gap has nothing to sit between, so the body
   lands on the padding edge by itself. And the cell is a <div> beside the <ul>,
   not an <li> in it — see the renderer for why that is not cosmetic. */

.block-list-dot{
  flex:none; width:6px; height:6px; border-radius:50%;
  background:var(--palette-ink, var(--ink));
  /* Centred on the first line, derived from the line box rather than nudged by
     hand: (line-height - dot) / 2. It stays centred if the type scale moves. */
  margin-top:calc((1.65em - 6px) / 2);
}
.block-list-body{ min-width:0; }
.block-list-body p{ margin:0 0 .75em; }
.block-list-body p:last-child{ margin-bottom:0; }
.block-list-body strong, .block-list-body b{ font-weight:700; }
.block-list-body em, .block-list-body i{ font-style:italic; }
.block-list-body u{ text-decoration:underline; }
.block-list-body a{ color:var(--palette-ink, var(--ink)); }
/* ---- How much of the ruling a list draws ----
   Four settings, most structure to least. Everything above is `all`, the
   default and what a list drew before there was a choice:

     all     frame · rule under the title · rule between every item
     title   frame · rule under the title · items run together
     frame   frame · nothing inside it is divided
     none    no frame, no rules

   data-lines rather than a class per mode, the way .pindex carries data-align
   and data-state: it is one setting with four values, and an element that can
   only be in one of them should not be able to hold two of the classes.

   In `all` the rule under the title is not the title's own — it is the first
   item's border-top. That is why `title` is expressible at all: kill every
   divider, then give exactly that one back. */
.block-list[data-lines="frame"] .block-list-item,
.block-list[data-lines="title"] .block-list-item{ border-top:0; --sep:0; }
.block-list[data-lines="title"] > .block-list-head + .block-list-items > .block-list-item:first-child,
.block-list[data-lines="title"] > .block-list-head + .block-list-item{
  border-top:1px solid var(--rule); --sep:1;
}

/* Undivided items are no longer cells, so they take the halved rhythm the
   `none` mode below already uses — 16px between items rather than 32px of two
   facing cell paddings, which is the same reasoning, one mode along.

   Longhand top/bottom, not the `padding` shorthand: the 600px block further
   down sets all four, and the horizontal half of it must still win here.

   The three declarations after it are bookkeeping, not taste. Halving the item
   padding pulls the run away from the frame at its ends, so the block gives
   back what the cells stopped contributing — 8 + 8 = the 16px `all` has at the
   bottom edge, and at the top of an untitled frame. A title in `frame` mode
   then needs 10 rather than its usual 18, because it is sitting on the block's
   new 8. Every frame edge measures the same in all three framed modes; only
   the gap BETWEEN items changes. */
.block-list[data-lines="frame"] .block-list-item,
.block-list[data-lines="title"] .block-list-item{ padding-top:8px; padding-bottom:8px; }
.block-list[data-lines="frame"],
.block-list[data-lines="title"]{ padding-bottom:8px; }
.block-list[data-lines="frame"]{ padding-top:8px; }
.block-list[data-lines="frame"] .block-list-head{ padding-top:10px; }

/* Lines off: the frame and the cell divisions go, and with them the reason for
   the horizontal inset — without a frame to sit inside, the copy should line up
   with the body text around it rather than hang 22px in from it. The vertical
   padding halves too, so the items read as a list rather than as cells that
   have lost their edges. The title goes back to carrying its own margin, which
   is what it does everywhere else it is not in a cell. */
.block-list[data-lines="none"]{ box-shadow:none; border-radius:0; overflow:visible; --ring:0; }
.block-list[data-lines="none"] .block-list-head{ padding:0; }
.block-list[data-lines="none"] .block-list-head .block-text-heading{ margin:0 0 .5em; }
.block-list[data-lines="none"] .block-list-item{ border-top:0; padding:8px 0; --sep:0; }

@media (max-width:600px){
  .block-list-head{ padding:14px 16px; }
  .block-list-item{ padding:12px 16px; gap:10px; }
  .block-list[data-lines="none"] .block-list-head{ padding:0; }
  .block-list[data-lines="none"] .block-list-item{ padding:8px 0; }
  /* The give-back above is sized against the desktop cell. Here the cell is
     12px rather than 16, so the block returns 4 rather than 8 and the frame
     edges keep measuring the same in all three framed modes at this width too.
     The head's 10px needs no counterpart: 8 + 10 is the desktop head's 18 and
     4 + 10 is this one's 14, which is the same number by coincidence of the
     two scales — leave it alone rather than restate it. */
  .block-list[data-lines="frame"],
  .block-list[data-lines="title"]{ padding-bottom:4px; }
  .block-list[data-lines="frame"]{ padding-top:4px; }
}

/* Rich-text body */
.block-text-body{
  font-family:var(--font-body); font-weight:400;
  font-size:15.5px; line-height:1.65; color:var(--ink);
}
.block-text-body p{ margin:0 0 1em; }
.block-text-body p:last-child{ margin-bottom:0; }
.block-text-body strong, .block-text-body b{ font-weight:700; }
.block-text-body em, .block-text-body i{ font-style:italic; }
.block-text-body u{ text-decoration:underline; }
.block-text-body s, .block-text-body del{ text-decoration:line-through; }
.block-text-body a{ color:var(--accent); text-decoration:underline; }
.block-text-body a:hover{ opacity:.8; }
.block-text-body h2{
  font-family:var(--font-display); font-size:clamp(20px,3.5vw,28px);
  font-weight:600; margin:1.2em 0 .5em; line-height:1.2;
}
.block-text-body h3{
  font-family:var(--font-display); font-size:clamp(17px,2.5vw,22px);
  font-weight:600; margin:1em 0 .4em; line-height:1.2;
}
.block-text-body ul, .block-text-body ol{ margin:0 0 1em; padding-left:1.5em; }
.block-text-body li{ margin:0 0 .3em; }
.block-text-body blockquote{
  border-left:3px solid var(--ink); margin:0 0 1em;
  padding:4px 0 4px 16px; color:var(--muted); font-style:italic;
}
/* Alpha in the colour rather than `opacity:.2`, which was equivalent for a
   flat line but would also have scaled the cursor glow painted on top of
   it down to a fifth. Same rendered colour, glow at full strength. */
.block-text-body hr{ border:0; height:1px;
  background:color-mix(in srgb, var(--ink) 20%, transparent); margin:1.5em 0; }
.block-text-body img{ max-width:100%; height:auto; border-radius:var(--radius); margin:.5em 0; }


/* ============================================================
   BLOCK GALLERY WRAPPER
   ============================================================ */
.block-gallery{ margin:8px 0 16px; }


/* ============================================================
   VIDEO PLAYER
   ============================================================ */
.vid{ position:relative; width:100% }
.vid-viewport{
  position:relative; width:100%; overflow:hidden;
  border-radius:var(--radius); background:#0a0a0a;
}
.vid-el{
  display:block; width:100%; height:100%;
  object-fit:contain; background:#000;
}
.vid-el:focus{ outline:none }
.vid-el::-webkit-media-controls-enclosure{ border-radius:0; }
.vid-viewport.is-fullscreen{ border-radius:0; }
.vid-viewport:-webkit-full-screen{ border-radius:0; width:100%; height:100%; }
.vid-viewport:-webkit-full-screen .vid-el{ width:100%; height:100%; object-fit:contain; }
.vid-viewport:fullscreen{ border-radius:0; width:100%; height:100%; }
.vid-viewport:fullscreen .vid-el{ width:100%; height:100%; object-fit:contain; }
.vid-caption{
  padding:10px 2px 4px;
  font-family:var(--font-body); font-size:14px; color:var(--muted);
  line-height:1.45; min-height:1.45em;
}
.block-video{ margin:8px 0 16px; }

/* ============================================================
   CUSTOM VIDEO CONTROLS  (.vc — see video-controls.js)
   ============================================================
   Replaces the native <video> bar on every player on the site. The markup is
   built by video-controls.js; this owns the geometry, and the two files meet at
   exactly three places, all marked below:
     --vc-f        the 0..1 fill fraction, written by JS on each .vc-track
     .is-visible   the reveal state
     --vc-handle   and --vc-fill-inset, both measured by JS at drag start

   SCALE. The source is videoplayer_playmode_embedded_mk3.css, a Figma export on
   a 1022x575 frame, and every number is taken at HALF of what it says — a 36px
   control bar, an 8px timebar, 9px type. Halve any new value off that file and
   stop there; there is no second factor, and the +25% the old bar carried
   before mk2 is long gone. The halving is a decision rather than something the
   export states, and .vc-in sets out both sides of it — read that before
   changing the factor.

       10 inset  |  timebar 8  |  gap 8  |  bar 36  |  10 inset  |  10 bottom

   That is the shipped vertical chain: 8 + 8 + 36 = 52 of layer standing 10 off
   the bottom edge. mk3 draws 16 + 16 + 72 = 104 at 20.

   TWO THINGS IN THE MOCK ARE DELIBERATELY NOT REPRODUCED, and both have been
   tried:
     - the 4px #313131 frame. Video keeps the shared 1px --media-stroke it wears
       alongside galleries and the project cover, with the rollover that goes
       with it.
     - backdrop-filter on the three buttons. It draws a rectangle rather than a
       frosted key, because a nested backdrop-filter double-composites the bar's
       own fill. Measured; see the note above .vc-play. */
.vc-host{ position:relative; }

.vc{
  position:absolute; left:0; right:0; bottom:0; z-index:2;

  /* THE CONTAINER, and nothing else — every size lives on .vc-in below,
   * because an element cannot query the container it is itself establishing.
   * The layer is stretched by its host, so its inline size never depends on
   * its contents and the containment costs nothing.
   *
   * A container query rather than a viewport one because the binding width is
   * the PLAYER's, not the window's: a half-width video block is 512px wide on
   * a 1440px desktop, and no viewport query can see that. */
  container-type:inline-size;

  /* Hidden until the pointer is over the player — the requested behaviour.
   * pointer-events stays off the whole layer while hidden (the children below
   * only take it back when visible), so an invisible button cannot be tapped
   * on a touch screen where there is no hover to reveal it first.
   *
   * Deliberately NOT visibility:hidden or display:none: either would drop the
   * buttons out of the tab order, and then a keyboard user could never reach
   * the controls at all — there is no hover to open them with. Invisible but
   * focusable means Tab lands on the play button, focusin fires, and the bar
   * reveals itself. That is the whole keyboard entry path. */
  pointer-events:none;

  /* ---- the scrim's tokens ----
   * ON .vc, not with every other size on .vc-in, and that is not a stylistic
   * choice: the scrim is .vc-veil, which is .vc-in's SIBLING. A custom
   * property declared on .vc-in inherits to .vc-in's descendants and reaches
   * the scrim never.
   *
   * ============ THE TWO KNOBS. TUNE THE SCRIM HERE. ============
   * NOT from the mock: mk2 draws the controls, not what is under them, so
   * neither of these came off the 2x export and neither should be halved.
   *
   *   --vc-scrim-h      how far up from the bottom edge the band reaches. The
   *                     band must DIE just above the timebar: 54 of plateau
   *                     plus 18 of ramp is 72, and the top edge lands 10 above
   *                     the timebar's own top. It is 72 and not 64 for a
   *                     measured reason — see the note below. Raising this
   *                     lengthens the ramp and pushes the top edge up the
   *                     picture, which is the thing that has been wrong twice.
   *   --vc-scrim-blur   the blur at the very BOTTOM of that band — the peak of
   *                     the ladder, not one radius. MEASURED at 32.5 when this
   *                     says 32, so it now means what it says. It did not
   *                     before: the old ladder halved the radius four times and
   *                     the five added in quadrature to about 1.15x, so 32
   *                     bought 36.8. This is the number to turn to make the
   *                     whole thing stronger.
   *
   * The SHAPE of the falloff — how the blur is distributed up the band — is
   * two numbers per layer down at .vc-veil, not here.
   * =============================================================
   *
   * THE VEIL STILL HAS NO TINT, and that has not changed. The scrim once
   * carried a black wash UNDER its own blur, the wash was darkening the
   * picture on the blur's schedule, and it was reverted. Do not put a
   * background back on .vc-veil or its layers. The dimming asked for since
   * then is .vc-dim below — a SEPARATE element with its own height and its own
   * falloff — precisely so that the two are tuned apart rather than one knob
   * dragging the other, which is what made the old wash unusable. */
  --vc-scrim-h:100px;
  --vc-scrim-blur:32px;

  /* ---- the dimmer curtain's tokens ----
   * Here rather than on .vc-in for the same reason as the scrim's: .vc-dim is
   * .vc-in's SIBLING, and a property declared on .vc-in reaches it never.
   *
   * ============ THE FOUR KNOBS. TUNE THE CURTAIN HERE. ============
   *   --vc-dim-color    what it dims TOWARDS. Black is the usual answer; a
   *                     very dark blue or brown grades the footage as well as
   *                     darkening it. Kept separate from the alpha so the hue
   *                     can be chosen once and the strength swept freely.
   *   --vc-dim-alpha    the strength at the very BOTTOM edge, 0..1. Every stop
   *                     in the ramp is a fraction of this one number, so it
   *                     scales the whole curtain without reshaping it. 0 is
   *                     off, and off still costs one transparent gradient.
   *   --vc-dim-h        how far up from the bottom the curtain reaches.
   *                     INDEPENDENT OF --vc-scrim-h by design — the blur wants
   *                     to die just above the timebar (see the long note
   *                     above), and a dimmer usually wants to run further up
   *                     the picture than that. Tying them together is the
   *                     thing this element exists to avoid.
   *   --vc-dim-falloff  how far up it holds FULL strength before it starts to
   *                     fade, in % of --vc-dim-h. 0% is a pure ramp from the
   *                     bottom edge. Raising it adds a solid plateau at the
   *                     bottom and squeezes the fade into what is left, so the
   *                     curtain reads harder and its top edge steeper. At 100%
   *                     there is no fade at all and you get a flat band with a
   *                     visible line across the picture, which is exactly what
   *                     the ramp is here to prevent.
   * ===============================================================
   *
   * These are the defaults it ships at, not a measured optimum like the
   * scrim's — nobody has looked at this over the real footage yet. 160/.55 is
   * a conservative starting point: clearly present under the controls, gone
   * well before the middle of a 16:9 player. Sweep it in the Composer. */
  --vc-dim-color:#000;
  --vc-dim-alpha:.55;
  --vc-dim-h:160px;
  --vc-dim-falloff:0%;
}

/* ---- the scrim ----
 * A band standing --vc-scrim-h up from the bottom of the picture that blurs
 * what is behind it and does nothing else — it does not darken. The blur is
 * strongest at the bottom and thins to nothing by the top, so there is no edge
 * where it stops.
 *
 * FIVE LAYERS, AND WHY A MASK ALONE CANNOT DO THIS
 * This was one element with a fading mask over a single blur, and it looked
 * barely blurred however high the radius went. A mask does not weaken a blur —
 * it controls how much of the blurred copy is composited over the ORIGINAL,
 * which is still perfectly sharp underneath. At mask alpha .55 you are looking
 * at 55% blurred plus 45% razor sharp, and the sharp half is what the eye
 * locks onto. Only the very bottom, where the mask reached alpha 1, was ever
 * really blurred. Raising --vc-scrim-blur could not fix that; it made the
 * blurred half blurrier and left the sharp half exactly as sharp.
 *
 * Stacked backdrop-filters COMPOUND rather than blend, because each layer's
 * backdrop includes the layers already painted beneath it. Measured, to be
 * sure of it rather than assuming: three stacked blur(8px) siblings gave the
 * same standard deviation as one blur(13.86px), which is 8*sqrt(3) — blurs
 * adding in quadrature, exactly as Gaussians should. So the ladder below is
 * real blur at every height, not a cross-fade to a sharp image.
 *
 * ======== TUNING THE SHAPE — the three numbers per layer below ========
 *     --b   this layer's radius, as a fraction of --vc-scrim-blur
 *     --p   how far up it stays at FULL strength, in % of --vc-scrim-h
 *     --t   how far up it reaches at all; it fades from --p to here
 *
 * THE SHAPE IS A PLATEAU AND THEN A RAMP, and the RAMP HAS TO FINISH JUST
 * ABOVE THE TIMEBAR. Both halves are requirements and they pull against each
 * other; this has now been got wrong in both directions, so:
 *
 *   Full blur behind the control bar (10 of bottom margin plus the 36 bar, so
 *   everything below 46). That is what the controls are read against, and a
 *   slope that was still climbing at 46 left sharp picture showing through it.
 *   The plateau runs to 54 — the gap above the bar — and the ramp then takes
 *   54 to 72, so it is spent by 10px above the timebar's top edge. The band
 *   does NOT carry on up the picture.
 *
 * THE TIMEBAR SITS IN THE RAMP, at roughly 56% of peak, and that is deliberate
 * rather than a compromise: it paints its own rgba(0,0,0,.4) plate and a
 * border, so it does not need what the control bar needs, and holding full
 * blur to 62 would leave nowhere for the ramp to go except further up the
 * picture.
 *
 * WHY 72 AND NOT 64. The reference is a 128 band on the 1022x575 mock, which
 * halves to 64 like everything else here. Measured at exactly 64, the blur is
 * only 80% of peak at the top of the control bar and 15% at the top of the
 * timebar — which is the "sharp behind the bar" complaint coming straight
 * back, because at 64 the ramp has to start below the bar to fit. 72 is the
 * shortest band that keeps the bar at 90% and still dies within 10px of the
 * timebar. Going to 64 tightens it and costs the bar; going to 80 or 88 softens
 * the ramp and walks the top edge back up the picture.
 *
 * 54 is why the --p values bottom out at 75%: 75% of 72 is 54. IF
 * --vc-scrim-h CHANGES, THAT NUMBER MUST CHANGE WITH IT, or the plateau stops
 * matching the controls; likewise if the control sizes change. The plateau is
 * pinned in pixels but has to be written in percent, and there is no way round
 * that — the tokens that would let it be computed live on .vc-in, and this
 * element is .vc-in's sibling, so they never reach here. See the note on .vc.
 *
 * WHY EACH LAYER NEEDS ITS OWN --p. When all five started fading at the same
 * height they thinned at once and the blur fell off a cliff immediately above
 * the plateau — measured at 19% lost per 5px against a long ramp. Staggering
 * the starts, strongest letting go first and weakest last, means only one or
 * two are fading at any height and the total comes down evenly. Over this
 * ramp, which is short by design, that is the difference between a visible
 * horizontal edge and a falloff.
 *
 * THE RADII ARE CLOSE TOGETHER ON PURPOSE. They used to be /16, /8, /4, /2, 1 —
 * each half the one below. Because blurs add in quadrature the total is then
 * dominated by whichever single layer is largest, so losing one steps off a
 * ledge; that ladder held 99% of peak to a quarter of the way up and then fell
 * to nothing, which is a blurred plate with an edge. Spaced 0.29 to 0.55
 * instead, every layer contributes at every height and losing one barely shows.
 * They are also NORMALISED — the five sum in quadrature to exactly 1.0 — which
 * is what makes --vc-scrim-blur mean the blur at the bottom rather than 1.09x
 * of it. Rescale the whole set if you change one, or the knob starts lying.
 *
 * MEASURED, NOT EYEBALLED. Candidates rendered over stripe patterns at four
 * periods (16/30/60/120 — one period cannot resolve both a 2px and a 35px blur)
 * with the effective radius recovered at each height from the amplitude loss.
 * This shape measures 90% of peak at the top of the control bar, 79% at the
 * bottom of the timebar and 57% at its top, and is gone by 72. The slope that
 * came before it held 30% at the top of the bar, which is what left sharp
 * picture showing through.
 * ====================================================================
 *
 * The mask is declared ONCE, in the shared rule, derived from --t. It used to
 * be written out twice (prefixed and not), which meant editing the -webkit-
 * copy alone changed nothing at all — the unprefixed one came second and won.
 * Do not inline per-layer masks and bring that trap back. */
.vc-veil{
  position:absolute; left:0; right:0; bottom:0;

  /* NO PERCENTAGE HERE, AND DO NOT ADD ONE. A cap of min(var(--vc-scrim-h),60%)
   * was tried, to stop a 150 band swallowing a grid-cell player only ~113 tall.
   * It resolved to 37px on a full-size player and was caught by measurement.
   * The reason: percentages resolve against the CONTAINING BLOCK, and this
   * element's containing block is .vc — which sets no `top`, so it is only as
   * tall as the controls inside it, about 62. 60% of 62 is 37. The band is
   * taller than .vc by design and simply overflows it upward, which is fine
   * because nothing on this layer clips.
   * Anything that wants to be a fraction of the PICTURE has to measure the
   * picture, and .vc is not it. */
  height:var(--vc-scrim-h);
  pointer-events:none;

  /* NO opacity on this element, ever. The fade is on the layers individually
   * below, because an ancestor with opacity < 1 forms a BACKDROP ROOT, and a
   * backdrop-filter inside one has nothing outside the group left to sample —
   * the blur would vanish and then pop in at the end of the fade. That bug has
   * already been fixed once on this layer. An element's OWN opacity is
   * harmless; it is an ancestor's that breaks it. */
}
.vc-veil > i{
  position:absolute; left:0; right:0; bottom:0; top:0;
  display:block;
  -webkit-backdrop-filter:blur(var(--b));
          backdrop-filter:blur(var(--b));
  -webkit-mask-image:linear-gradient(to top,
      #000 0%, #000 var(--p), transparent var(--t));
          mask-image:linear-gradient(to top,
      #000 0%, #000 var(--p), transparent var(--t));
}
/* Weakest and widest first, strongest and shortest last. Read the --p column
 * downwards: 75% is 54px, the top of the plateau, and every layer is solid to
 * at least there. Above it they let go one at a time, and the last of them is
 * gone at 100% — 72px, ten above the timebar. */
.vc-veil > i:nth-child(1){ --b:calc(var(--vc-scrim-blur) * .5); --p:25%; --t:40%; }
.vc-veil > i:nth-child(2){ --b:calc(var(--vc-scrim-blur) * .25); --p:40%; --t:60%;  }
.vc-veil > i:nth-child(3){ --b:calc(var(--vc-scrim-blur) * 0.1); --p:60%; --t:75%;  }
.vc-veil > i:nth-child(4){ --b:calc(var(--vc-scrim-blur) * 0.01); --p:75%; --t:85%;  }
/*.vc-veil > i:nth-child(5){ --b:calc(var(--vc-scrim-blur) * 0.550); --p:75%; --t:82%;  }*/

/* ---- the dimmer curtain ----
 * A tint standing --vc-dim-h up from the bottom of the picture, fading out at
 * the top. It darkens and does not blur; the veil above blurs and does not
 * darken. Two elements, four knobs and two knobs, no shared number between
 * them — which is the whole brief, and the reason the wash that used to ride
 * on the veil is not coming back.
 *
 * PAINTED AFTER .vc-veil, ON PURPOSE. backdrop-filter samples everything
 * already painted beneath it, so a curtain placed BEFORE the veil would be
 * inside the veil's backdrop and the blur would smear the curtain's own ramp —
 * the curtain's falloff would then depend on --vc-scrim-blur, which is exactly
 * the coupling being designed out. After the veil, the tint lands flat on the
 * blurred picture and --vc-dim-falloff means what it says at every blur.
 * (It is still BEFORE .vc-in, so the controls sit on top of it and are dimmed
 * by nothing.)
 *
 * WHY THE RAMP HAS FOUR STOPS AND NOT TWO. A two-stop linear-gradient
 * interpolates alpha linearly, which the eye reads as a band with a soft edge
 * rather than as a falloff — the derivative is discontinuous at both ends and
 * Mach banding lands on exactly those two lines. The stops below are a
 * smoothstep, 3t^2-2t^3, sampled at quarters: 1, .844, .5, .156, 0. Both ends
 * leave and arrive flat, so there is no line to see at either.
 *
 * EVERY STOP IS A FRACTION OF --vc-dim-alpha, so the strength knob scales the
 * curve without reshaping it, and the shape knob (--vc-dim-falloff) moves
 * where the curve starts without touching its strength. That separation is
 * what makes the two sliders independently tunable instead of interacting.
 *
 * The first stop sits at --vc-dim-falloff rather than at 0%, and a gradient
 * holds its first stop's colour all the way back to the start — so everything
 * below the plateau is full strength with no stop written for it. */
.vc-dim{
  position:absolute; left:0; right:0; bottom:0;
  height:var(--vc-dim-h);
  pointer-events:none;

  /* How much room the fade has left after the plateau. A helper, declared
   * here because it is the element the stops resolve on — and derived, so it
   * is deliberately not exposed as a token. */
  --vc-dim-ramp:calc(100% - var(--vc-dim-falloff));

  background:linear-gradient(to top,
    color-mix(in srgb, var(--vc-dim-color) calc(var(--vc-dim-alpha) * 100%), transparent)
      var(--vc-dim-falloff),
    color-mix(in srgb, var(--vc-dim-color) calc(var(--vc-dim-alpha) * 84.4%), transparent)
      calc(var(--vc-dim-falloff) + var(--vc-dim-ramp) * .25),
    color-mix(in srgb, var(--vc-dim-color) calc(var(--vc-dim-alpha) * 50%), transparent)
      calc(var(--vc-dim-falloff) + var(--vc-dim-ramp) * .5),
    color-mix(in srgb, var(--vc-dim-color) calc(var(--vc-dim-alpha) * 15.6%), transparent)
      calc(var(--vc-dim-falloff) + var(--vc-dim-ramp) * .75),
    transparent 100%);

  /* NO opacity of its own here — the reveal below owns it, exactly as it owns
   * the veil's layers. This element is not an ancestor of any
   * backdrop-filter, so unlike .vc it is safe to fade directly. */
}

/* ---- the fade, and WHY IT IS NOT ON .vc ----
 * .vc used to carry opacity 0/1 and everything inside came with it. That
 * cannot stay: an element with opacity < 1 forms a BACKDROP ROOT, and a
 * backdrop-filter inside one has nothing outside the group left to sample, so
 * the scrim's blur vanished for the whole transition and snapped in at the
 * end. The opacity therefore sits on the two things being revealed —
 * .vc-in (the controls) and the veil's layers individually — and never on a
 * shared ancestor of a backdrop-filter.
 *
 * BOTH SELECTORS ARE LOAD-BEARING. .vc-in must be in the opacity:0 rule below,
 * not only in the :is-visible rule above it. Without it the controls have no
 * hidden state at all and simply never fade out, while the scrim keeps working
 * perfectly — which looks like a JS timing bug and is not one. This was broken
 * exactly that way once, by an edit to the rule above that swallowed the
 * `.vc-in,` line. Do not let a selector list here lose a member.
 *
 * .vc-dim is a THIRD member of both lists, for the same reason as the other
 * two: it is chrome, it appears with the controls and leaves with them. A
 * curtain that stayed up after the bar faded would read as the video having
 * gone dark. It takes opacity directly, unlike .vc, because nothing inside it
 * carries a backdrop-filter — the backdrop-root trap below applies to the
 * veil's ancestors only. */
.vc-in,
.vc-dim,
.vc-veil > i{ opacity:0; transition:opacity .18s ease; }
.vc.is-visible .vc-in,
.vc.is-visible .vc-dim,
.vc.is-visible .vc-veil > i{ opacity:1; }
/* ---- the auto-hide's own timing ----
 * The bar fades out on its own once the pointer has been still for IDLE_MS
 * (the clock is in video-controls.js; this class is only what it toggles).
 * Slower than the .18s above ON PURPOSE: that one answers a deliberate act —
 * the pointer leaving the picture — and wants to keep up with the hand. This
 * one is the player deciding by itself, with nothing having just happened, and
 * at .18s it reads as a flicker at the edge of vision rather than as the
 * controls withdrawing. Half a second registers as intentional without hanging
 * about.
 *
 * Only the fade OUT is slowed. Coming back is still .18s, because that one IS
 * an answer to the hand: the class comes off in the same frame .is-visible
 * goes on, so the rule above is what governs the return. */
.vc.is-idle .vc-in,
.vc.is-idle .vc-dim,
.vc.is-idle .vc-veil > i{ transition-duration:.5s; }

.vc-in{
  /* ============================================================
     EVERY NUMBER BELOW IS videoplayer_playmode_embedded_mk3.css / 2.
     ============================================================
     mk3 replaces mk2 as the source. Same 1022x575 frame and the same halving,
     so the conversion is unchanged — only the drawing moved.

     THE HALVING IS AN ASSUMPTION, AND IT IS WORTH KNOWING THAT IT IS ONE.
     Nothing in either export states a scale. Read at 1:1 the mock's numbers
     close the frame exactly (20 + 982 + 20 = 1022), the declared frame matches
     the SVG's viewBox (0 0 1022 575), and the timecode is 18px in BOTH mk3
     files — the 1022-wide embedded one and the 1512-wide fullscreen one — which
     is hard to explain if the two were drawn at different scales. Against
     that: halving is what this file has always done, and it is what was asked
     for again when mk3 came in. So it is kept deliberately, not by
     inheritance. If it is ever undone, this block and the fullscreen tier are
     the only things that change.

     WHAT MOVED FROM mk2. All of it upward, and none of it by a single factor:

                     mk2      mk3        /2
       stack          74  ->  104   ->  37 -> 52
       timebar        10  ->   16   ->   5 ->  8   and it gains a border
       gap             8  ->   16   ->   4 ->  8
       control bar    56  ->   72   ->  28 -> 36
       buttons        48  ->   64   ->  24 -> 32   ALL THREE, not just play
       glyphs         40  ->   48   ->  20 -> 24
       timecode       16  ->   18   ->   8 ->  9   and weight 500 -> 400
       inset          28  ->   20   ->  14 -> 10
       bottom         21  ->   20   ->  10.5 -> 10

     THE LAYOUT DID NOT INVERT AGAIN. mk3 keeps timebar_root above the control
     bar, exactly as mk2 did, so the DOM order in video-controls.js still
     matches what is drawn and needs no change. */

  --vc-inset:10px;      /* 20/2 — sides */

  /* 20/2, and the half pixel is gone. mk2 put the layer's bottom edge at 554
   * in a 575 frame, which halved to 10.5 — the one fractional value in the
   * vertical chain, softening the bar's edges by half a pixel on a 1x display.
   * mk3 draws 20 (555 in 575, matching its 20 sides), so the chain is whole
   * again and that wart closed itself. */
  --vc-bottom:10px;
  --vc-gap:8px;         /* 16/2 — timebar to control bar, and nothing else */

  --vc-h:36px;          /* 72/2 — videoControlBar_root height */

  /* ONE BUTTON SIZE, WHICH IS NEW. mk2 drew play as a 48 box with a 40 glyph
   * and the two right-hand controls as the bare 40 glyph box. mk3 draws all
   * three identically: a 64 box holding a 48 glyph, 8 of clearance all round.
   * So --vc-play-size is gone and this replaces it — there is no longer a size
   * that belongs to the play button alone. */
  --vc-btn-size:32px;   /* 64/2 — ALL THREE buttons */
  --vc-icon:24px;       /* 48/2 — the glyph box inside each of them */

  --vc-igap:8px;        /* 16/2 — between the two right-hand buttons */
  --vc-time-w:80px;     /* 160/2 — timecode_text box */
  --vc-font:9px;        /* 18/2 */
  --vc-line:12px;       /* 24/2 */
  --vc-border:1px;      /* 2/2 — the control bar's border and the volume
                         * panel's. NOT the timebar's any more: that split off
                         * to --vc-track-border below, which is also drawn on
                         * the outside. These two are still unified. */

  /* TWO RADII SINCE mk3, where mk2 had one. The bar is drawn at 2 and the
   * buttons at 4, so they no longer share a value and a single token cannot
   * serve both. --vc-radius is the bar; --vc-btn-radius is the buttons, and
   * the volume panel used to borrow it as a floating key; since the slider
   * went inline there is no second surface, and this is the row's alone. */
  --vc-radius:2px;      /* 2/2 — videoControlBar_root */
  --vc-btn-radius:2px;  /* 4/2 — the three buttons */

  /* ---- the timebar, and WHERE ITS INSET WENT ----
   * mk2 got the fill's inset by drawing a 10 plate and an 8 fill offset 1 all
   * round, so a hairline of plate showed around it. mk3 does the same job with
   * a real stroke instead: timebar_base is 16 with a 2 border, and
   * timebar_fill sits at top 2 / bottom 2 / left 2, which is exactly the box
   * inside that stroke. The ring is now the border and the fill FILLS what is
   * left — no plate shows around it any more.
   *
   * So --vc-fill-inset is 0 and the border does the insetting. It is kept
   * rather than deleted because video-controls.js reads it, and because
   * trackMetrics() sums BORDER + INSET — which is why the scrub mapping needed
   * no change at all when the border arrived. Its comment predicted this
   * ("the track carries no border today, and that is exactly the kind of thing
   * that comes back"); it came back, and the sum absorbed it.
   *
   * --vc-fill-h is DERIVED, not written. It is whatever the border leaves, and
   * stating it as a literal is how the two drift apart — it also means the
   * fullscreen tier below sets one number here instead of three. */
  --vc-track-h:8px;     /* 16/2 — timebar_base. THE PLATE, with no stroke in
                         * it: the timebar's stroke is drawn outside this box
                         * now, so the bar occupies --vc-track-h + 2 *
                         * --vc-track-border on screen and this number is the
                         * part you actually see filled. */

  /* ---- the timebar's stroke: its own width, and OUTSIDE the box ----
   * Split from --vc-border so the timebar can carry a different weight from
   * the control bar without dragging the volume panel along with it.
   *
   * OUTSIDE, via box-shadow rather than border — see .vc-track for the how.
   * The consequence to hold on to is here, though: because the stroke is
   * outside, it takes nothing from --vc-track-h. An inside stroke had to eat
   * the plate, and on an 8px bar that was a real cost — at 3px there was 2px
   * of plate left. Now --vc-track-h is the plate, whole, at any stroke width,
   * and the bar simply grows. */
  --vc-track-border:1px;

  /* --vc-fill-h WAS calc(--vc-track-h - 2 * --vc-border), because the fill got
   * whatever an inside border left it. There is nothing left to subtract: with
   * the stroke outside, the fill and the plate are both the whole box. Kept as
   * a token rather than folded into .vc-fill so the fullscreen tier still sets
   * one number, and so the name still says what the height is FOR. */
  --vc-fill-h:var(--vc-track-h);
  /* 0px, AND THE UNIT IS LOAD-BEARING. Written as a bare 0 this token is a
   * <number>, and --vc-travel below is calc(100% - 2 * var(...) - var(...)):
   * subtracting a unitless number from a percentage is a type error, the whole
   * calc is invalid at computed-value time, and the playhead silently pins to
   * the left end at every value of --vc-f. The fill's `right` fails the same
   * way. Measured exactly that: handle centre stuck at f=0 for f=0..1. */
  --vc-fill-inset:0px;  /* 2/2 is the border now. See above. */
  --vc-handle:0px;      /* 12/2 — timebar_playHead WIDTH. Square: 12 wide and
                         * 16-2-2 = 12 tall, so 6 and 6 here. mk2's was 4x4. */
  --vc-bar-radius:2px;  /* 2/2  — timebar_base */
  --vc-fill-radius:1px;/* 1/2 — timebar_fill and timebar_playHead */

  /* Half of the hit-target growth, because it is applied to both sides: 0.15
   * makes each control 30% larger in each dimension than it is painted. See
   * HIT TARGETS at the end of this section. */
  --vc-hit:0.15;

  /* ---- colour ----
   * The two blacks are literal in the export and literal here: they are the
   * scrim a control bar needs over arbitrary footage, not a themed surface.
   *   controlBase_fill  rgba(0,0,0,.4) + 2px rgba(0,0,0,.05)   -> .05, was .1
   *   timebar_base      rgba(0,0,0,.4)
   *
   * The gradient's two ends are NOT literal. mk2 draws #EEB741 -> #FDFF6F,
   * which is one palette's slider pair, and hardcoding it would freeze the
   * scrubber to that palette while the rest of the page re-tints. So the hues
   * stay on --slider-start / --slider-end (strip.js resolves them per palette,
   * per theme) and it is the ALPHAS that were matched to the export:
   *   fill      tail .5 at 0%, tail .5 at 50%, head .5 at 100%   (head was .8)
   *   playHead  head .8                                          (was solid)
   * On the palette the mock was drawn with, those two produce the export's
   * colours exactly. */
  --vc-fill-head:var(--slider-end, var(--palette-ink, var(--ink)));
  --vc-fill-tail:var(--slider-start, var(--accent));
  --vc-skin:rgba(15,17,20,.6);

  /* ---- the SLIDER's backer, split off from --vc-skin ----
   * IDENTICAL TO --vc-skin TODAY, and that is not an oversight — it is the
   * whole of the split. The two were one token, so the unfilled part of the
   * scrub bar could never be tuned without dragging the control row's fill
   * with it, and they are answering different questions: --vc-skin is a
   * legibility scrim that has to hold a timecode and five icons over
   * arbitrary footage, while this is the GROUND THE GRADIENT IS READ AGAINST
   * and wants to be judged next to the fill, not next to the text.
   *
   * WHY IT MATTERS MORE THAN A NORMAL SURFACE WOULD. .vc-fill is 60% opaque
   * (see the alpha note above), so whatever sits behind it is 40% of the
   * colour you actually see. This token IS that backdrop for the unfilled
   * half and, through the plate showing beneath it, part of the filled half
   * too. Darkening it deepens the gradient; lightening it washes the tail
   * out. Neither of those is something the control row wants.
   *
   * Reaches BOTH sliders — the scrub bar and the volume slider inside the
   * panel — because both are .vc-track. The panel's own surface is still
   * --vc-skin, so the volume slider is the one place the two tokens are
   * visible side by side, which makes it the honest place to check a split
   * value before committing to it.
   *
   * THE UNFILLED BAR IS THIS BACKGROUND. .vc-rest is built and never drawn,
   * so the plate beside .vc-fill IS the unfilled state — there is no second
   * surface to keep in step, and this one token is the entire empty track.
   *
   * Starting them equal means nothing moves until it is tuned. Set them apart
   * in the Composer's Visuals pane; there is no reason they have to meet
   * again. */
  --vc-track-skin:rgba(15,17,20,.6);

  --vc-edge:rgba(0,0,0,.4);

  /* ---- the control bar's own stroke, split off from --vc-edge ----
   * WHITE, where --vc-edge above is black, and that is the whole reason this
   * token exists: the bar was asked to carry a brighter edge than the timebar
   * and the volume panel, which keep the black one.
   *
   * WHY IT LIFTS RATHER THAN DARKENS. A border paints over the element's own
   * background — background-clip is border-box by default — so this stroke
   * composites on top of --vc-skin, not beside it. Black at .2 therefore made
   * the bar's rim DARKER than its interior, which is the opposite of an edge
   * catching light. Measured over mid-grey footage: rim 41, interior 53.
   *
   * The arithmetic is the argument for white. Over footage F the interior is
   * 0.4F and this rim is 0.32F + 51 — the 51 being white's own contribution,
   * which is what a black stroke has none of. So the rim is brighter than the
   * fill at every F, and it is brightest in relative terms exactly where an
   * edge is hardest to hold: on black footage the fill is 0 and the rim is
   * still 51. A black rim did the reverse — it went to 0 with the fill and
   * disappeared.
   *
   * Its lead over the fill NARROWS as the picture brightens, and does not
   * hold constant: the difference is 51 - 0.08F, because a 20% white stroke
   * attenuates the footage under it slightly more than the fill does. All
   * four measured, rim vs fill:
   *     footage #000     51 vs   0   (+51)
   *     footage #202020  61 vs  13   (+48)
   *     footage #808080  92 vs  51   (+41)
   *     footage #dcdcdc 122 vs  88   (+34)
   * +34 is still a clear hairline, so nothing needs correcting for it — but
   * do not read the lead as fixed when tuning the alpha.
   *
   * mk2 already used rgba(255,255,255,.1) for the fill and playhead rims, so
   * a white hairline is the established move here.
   *
   * NOT WHAT THE EXPORT SAYS, said plainly: mk3 draws controlBase_fill with
   * 2px rgba(0,0,0,0.05). The shared token had it at .2 — the TIMEBAR's value,
   * which is what sharing one token cost — so this stroke was already off
   * spec before it went white. This is a deliberate departure, not a match.
   *
   * Raise the alpha for a brighter rim, lower it toward 0 to fade the bar's
   * outline out. It is composited UNDER the cursor glint, not replaced by it:
   * over mid grey the rim is 92 at rest and the glint takes it to 166. */
  --vc-row-edge:rgba(125,130,140,.25);

  /* ---- the cursor glint on those edges ----
   * How brightly the pointer lights the strokes above. The LIGHT itself is
   * --media-glow, shared with the frame around the player; this is only its
   * strength on the controls, and it has to live here rather than as a second
   * alpha because --media-glow resolved its own alpha back on :root, where it
   * is declared. See rule 7 in the CURSOR GLOW section for why it is applied
   * as an opacity and not as a colour.
   *   1     the frame's full strength — the bar as bright as the frame
   *   0.50  where it sits, tuned down twice after seeing it on the bar
   *   0     off, with the paint still costed; to remove it, delete rule 7
   * Linear in the lift, because the band is composited over the stroke rather
   * than mixed into it. Measured on the bar's white rim over mid-grey footage,
   * which rests at 92: at 0.75 the peak was 166, at 0.50 it is 139.
   * Dark theme only, like every other glow here. */
  --vc-glint:0.50;

  /* ---- the same pointer, lifting the bar's SURFACE ----
   * --vc-glint above lights the 1px stroke; this lifts the fill inside it.
   * Rule 8 in CURSOR GLOW. Same light, same falloff, same dial mechanism —
   * an opacity on the finished --media-glow gradient — so the two cannot
   * drift apart in hue or in reach.
   *
   * A MUCH SMALLER NUMBER THAN --vc-glint, and that is not timidity. The
   * glint's job is a hairline that has to read as lit metal, so it wants to
   * reach near-white within its one pixel row. This covers the whole 36px
   * backer, and anything near the glint's strength is not a lift but a white
   * smear across the bar with the timecode sitting in it.
   *
   * Peak lift on the fill, swept and measured over mid-grey footage where the
   * fill rests at 51 — pick from these rather than from the number, since the
   * number means nothing on its own:
   *     0.06   59   (+8)   barely there
   *     0.10   65  (+14)   where it sits
   *     0.12   67  (+16)
   *     0.20   78  (+27)   reads as a highlight, not a lift
   *     0.35   97  (+46)   too far; the bar looks backlit
   *
   * The lift is close to flat across the picture, unlike the rim above it,
   * because it adds white to the fill rather than replacing a stroke over it.
   * At 0.12: +20 over black footage, +19 over #202020, +16 over mid grey,
   * +13 over #dcdcdc. So it survives on dark footage, which is where a bar
   * needs its own presence most.
   *
   * It does NOT compound with the edge glint. Measured across the whole sweep
   * above, the rim's peak held at 139 while the fill went 51 -> 97: the two
   * cover disjoint pixels by construction, and rule 8 says how.
   *
   * The fill's own value is --vc-skin, so raising that raises the floor this
   * starts from and the same dial will read as less. */
  --vc-lift:0.1;

  /* ---- THE PLAYER'S OWN LIGHT, and why it is a third gradient ----
   * Rules 7 and 8 used to read --media-glow. They cannot any more, because
   * the video controls are wanted lit in BOTH themes and --media-glow is
   * switched off in light — OFF IN LIGHT zeroes --media-glow-alpha, and that
   * alpha was folded in back on :root where the gradient is declared. A
   * custom property is substituted where it is DECLARED, not where it is
   * used, so no override down here can reach inside it. The same rule that
   * forced --media-glow to exist as a copy of --glow forces this copy of
   * --media-glow. CSS gives no way to parameterise a token.
   *
   * WHY THE CONTROLS ARE EXEMPT from a file-wide dark-only rule: every other
   * glow lifts a line whose GROUND flips with the theme, which is what makes
   * one ink impossible in light and why they switch off there. This bar does
   * not flip. --vc-skin is the same rgba in both themes — it is a scrim over
   * footage, not a themed surface — so one white light is correct on it in
   * both, and gating it bought nothing but a dead effect in light mode.
   *
   * KEEP THE STOP LIST IDENTICAL to --glow and --media-glow. Only the ink,
   * the alpha and the radius are meant to differ. Three copies of one falloff
   * is the cost of CSS having no way to share it; if you retune the curve,
   * retune all three.
   *
   * --vc-glow-radius defaults to the page's reach but can now be set on its
   * own, which --media-glow could not offer: the var() resolves on THIS
   * element, so the player's pool is finally tunable from inside the player.
   *
   * STILL OFF under prefers-reduced-motion, which is the one gate that does
   * apply — this is motion and it tracks a pointer. Twice over: the selectors
   * are dropped in the reduced-motion list, and rule-glow.js does not bind
   * the pointer there at all, so --glow-a never leaves 0. */
  --vc-glow-ink:hsl(0 0% 100%);
  --vc-glow-alpha:.7;
  --vc-glow-radius:var(--glow-radius);
  --vc-glow-c:color-mix(in srgb, var(--vc-glow-ink)
              calc(var(--vc-glow-alpha) * var(--glow-a) * 100%), transparent);
  --vc-glow:radial-gradient(circle var(--vc-glow-radius) at var(--mx) var(--my),
      var(--vc-glow-c) 0%,
      color-mix(in srgb, var(--vc-glow-c) 76%, transparent) 14%,
      color-mix(in srgb, var(--vc-glow-c) 41%, transparent) 28%,
      color-mix(in srgb, var(--vc-glow-c) 14%, transparent) 42%,
      color-mix(in srgb, var(--vc-glow-c)  3%, transparent) 56%,
      transparent 70%);


  padding:0 var(--vc-inset) var(--vc-bottom);
  box-sizing:border-box;
}

@media (prefers-reduced-motion:reduce){
  .vc-in, .vc-dim, .vc-veil > i{ transition:none; }
}

.vc.is-visible .vc-pill,
.vc.is-visible .vc-btn,
.vc.is-visible .vc-track{ pointer-events:auto; }

/* ---- .vc-pill ----
 * Was the surface every control wore. mk2 has ONE surface, controlBase, and
 * the controls sit on it — so this no longer paints, and survives as the hook
 * that hands the row's children their pointer-events back (above) and as the
 * volume panel's surface (below), that panel being the only pill-shaped thing
 * left on the layer. video-controls.js still applies the class; it is cheaper
 * to let it mean less than to renumber the selectors that depend on it. */
.vc-pill{ box-sizing:border-box; }

/* ---- videoControlBar_root ----
 * 982x72, one box, so the fill and the frame are the same element rather than
 * a wrapper and a child. mk2 called this controlBase and split it in two.
 *
 * THE PADDING IS SYMMETRIC NOW, and simpler than it was. mk3 puts
 * timecode_text at left 8 and fullscreen_button at right 8 — the same figure
 * on both sides, where mk2's 4 and 4 were measured from different boxes.
 * Halved that is 4 from the border box, and inside a 1px border the content
 * padding is 3 either side. mk2 needed 1 here plus 2 more on .vc-right,
 * because rightControls was a padded box of its own; mk3 positions the buttons
 * directly and has no such box, so .vc-right carries nothing and the whole 3
 * lives here. Counting it in two places is what once put the icons 2px too far
 * in, and there is now only one place to count it. */
.vc-row{
  position:relative;
  display:flex; align-items:center; justify-content:space-between;
  box-sizing:border-box;
  height:var(--vc-h);
  padding:0 3px;
  background:var(--vc-skin);
  /* --vc-row-edge, not --vc-edge: this one stroke is white. The other two
   * surfaces still take --vc-edge. See the token for why. */
  border:var(--vc-border) solid var(--vc-row-edge);
  border-radius:var(--vc-radius);
  /* No backdrop-filter here any more. The scrim behind the whole layer does
   * the blurring now, once, so this is the flat rgba(0,0,0,.4) the export
   * specifies and nothing else. */
}
/* The fallback that used to live here darkened the scrim where backdrop-filter
 * could not run. It is gone with the tint: substituting a wash for a blur is
 * the darkening that was asked to stop, and doing it only in old browsers
 * would make it harder to notice, not better. Where the filter is unsupported
 * the scrim now paints nothing and the controls stand on their own fills. */

/* Centred on the ROW, not between its neighbours — 459 + 48/2 = 483 = 966/2 in
 * the mock, which flex alone cannot produce once the timecode's width is in
 * play. The narrow tier drops this. */
.vc-play{
  position:absolute; left:50%; transform:translateX(-50%);
}

/* ---- timecode ----
 * A 154-wide box at left 4 with the text centred in it — no pill behind it in
 * mk2, so this is type directly on the base. The box is FIXED rather than
 * hugging: at 77px the string is centred in constant space, and the elapsed
 * span is still floored at the duration's width (JS sets --vc-tw in ch) so
 * 9:59 -> 10:00 cannot shove the whole string sideways inside it. */
.vc-time{
  display:flex; align-items:center; justify-content:center;
  /* min-width, not width. 77 is the box mk2 draws around "0:30 / 1:00" and at
   * that content this IS 77 to the pixel. A fixed width would clip an
   * hour-long clip's "1:02:30 / 2:02:30", which is half again as long and not
   * a string the mock had to hold; growing past the drawn box beats spilling
   * out of it. */
  min-width:var(--vc-time-w);
  height:var(--vc-h);
  font-family:var(--font-mono);
  font-weight:400;      /* mk3 draws 400 where mk2 drew 500 */
  font-size:var(--vc-font);
  line-height:var(--vc-line);
  color:#fff;
  white-space:nowrap;
  -webkit-user-select:none; user-select:none;
}
.vc-time-cur{ display:inline-block; min-width:var(--vc-tw, 0); text-align:right; }
.vc-time-sep{ white-space:pre; }

/* ---- buttons ---- */
.vc-play,
.vc-btn{
  display:flex; align-items:center; justify-content:center;
  padding:0; margin:0;
  color:#fff;
  cursor:pointer;
  -webkit-tap-highlight-color:transparent;
}
/* ---- the three buttons, which mk3 made identical ----
 * mk2 drew play as a 48 box around a 40 glyph and the two right-hand controls
 * as the bare 40 glyph box, with no frame of their own. mk3 draws all three
 * the same way: a 64 box holding a 48 glyph, 8 of clearance all round, which
 * centring a 24px icon in a 32px box reproduces without a padding declaration.
 * That is why .vc-play and .vc-btn now differ in nothing but their position.
 *
 * THE BLUR IS STILL NOT IMPLEMENTED, AND THAT IS DELIBERATE. mk3 gives each of
 * the three backdrop-filter:blur(16px) over no background at all, where mk2
 * gave it to play alone. Implemented literally on play it drew a visible
 * rectangle around the glyph rather than the frosted key the drawing shows:
 * measured at 32,32,32 against the base's 52,52,52, because a nested
 * backdrop-filter samples a backdrop that already contains the bar's own
 * rgba(0,0,0,.4) and composites it a second time. CSS and Figma do not agree
 * here, and mk3 spreading the same treatment to three buttons would spread the
 * artifact to three. It also darkens, which is the thing this layer has twice
 * been told not to do.
 *
 * So the boxes and the radius are real and the filter is not. If the buttons
 * should read as surfaces, the way to do it WITHOUT the artifact is a light
 * own-fill — background:rgba(255,255,255,.06) on the rule below — which lifts
 * them off the bar instead of pushing them into it. One line, not a filter. */
.vc-play,
.vc-btn{
  width:var(--vc-btn-size); height:var(--vc-btn-size);
  border:0; background:none;
  border-radius:var(--vc-btn-radius);
}
.vc-play svg,
.vc-btn svg{
  width:var(--vc-icon); height:var(--vc-icon);
  display:block; fill:currentColor;
}

/* mk2's rightControls was a 108x48 box with its own 4px of side padding. mk3
 * has no such wrapper: it positions volume_button at right 88 and
 * fullscreen_button at right 8, which is 64 + 16 + 64 + 8 read from the right
 * edge — two 32 buttons and an 8 gap once halved, with the 4 to the bar's edge
 * already spent as .vc-row's padding. So this element survives as the flex
 * group that produces that gap and nothing else. NO PADDING: it would be the
 * double-count the row's comment warns about. */
.vc-right{
  display:flex; align-items:center;
  gap:var(--vc-igap);
  height:var(--vc-h);
}

/* ---- the timebar ----
   timebar_base is the whole bar and the unfilled state at once: a 16-tall
   plate at rgba(0,0,0,.4) with a 2 stroke at rgba(0,0,0,.2) and a 2 radius.
   THE STROKE IS NEW IN mk3 and it replaces mk2's hairline-of-plate trick: the
   fill and the playhead now sit at top 2 / bottom 2 / left 2, which is exactly
   the box inside the stroke, so they FILL it and no plate shows around them.
   mk2 got the same ring by insetting an 8 fill inside a 10 plate. The ring is
   the border now — see --vc-fill-inset, which is 0 for that reason.

   .vc-rest stays undrawn either way: the plate is still the unfilled state.

   Travel is inset by the border at each end and by the playhead's own width,
   so the head never overhangs: the inner left edge at zero, inner width minus
   12 at one. video-controls.js inverts precisely this mapping, and it reads
   BORDER + --vc-fill-inset rather than assuming which of the two is carrying
   it — which is why the stroke arriving here changed nothing there. The pair
   here and in trackMetrics() must stay in step. */
.vc-track{
  position:relative;
  box-sizing:border-box;
  height:var(--vc-track-h);
  cursor:pointer;
  touch-action:none;          /* a drag scrubs; it must not scroll the page */

  /* --vc-track-skin, NOT --vc-skin. The slider's backer is its own token now;
   * see it on .vc-in for why. It starts at the same rgba the shared one had,
   * so this line changed nothing on the day it was written. */
  background:var(--vc-track-skin);

  /* ---- THE STROKE IS OUTSIDE, and it is a shadow rather than a border ----
   * `box-shadow: 0 0 0 <w>` with no inset paints entirely OUTSIDE the border
   * box, takes no part in layout, and follows border-radius the way a border
   * would. .block-list and .pindex frame themselves the same way; this is the
   * file's existing move for an outside stroke, not a new one.
   *
   * WHY NOT border + box-sizing:content-box, which also puts a stroke outside:
   * that changes the element's layout box, so the bar's height would depend on
   * its stroke width and the gap to the control row would move with it. A
   * shadow leaves --vc-track-h meaning exactly what it says at every width.
   *
   * WHAT THIS FIXES, and it is the reason the split was worth doing. THE
   * UNFILLED BAR IS THIS BACKGROUND — .vc-rest is built and never drawn, so
   * the plate showing beside .vc-fill IS the unfilled state. With an inside
   * border the two lived in different boxes: the plate painted out to the
   * border box, while .vc-fill and .vc-handle are absolutely positioned and
   * reach only the padding box. The difference was exactly one border on every
   * side — a ring of bare plate around the fill that read as a stroke and
   * would not go away when --vc-edge went to alpha 0, because it never was the
   * stroke. With no border at all the two boxes coincide by construction and
   * there is nothing left to keep in step. The background-clip:padding-box
   * that used to reconcile them is gone with the border that needed it.
   *
   * IF A REAL BORDER EVER COMES BACK HERE, --vc-fill-h has to go back to
   * subtracting it (see the token) or the fill sits short at the bottom, and
   * the plate needs background-clip:padding-box again or the ring returns.
   * Both were live bugs; neither is reachable while the stroke is a shadow.
   *
   * trackMetrics() in video-controls.js reads borderLeftWidth and gets 0, and
   * --vc-travel resolves against the same box, so the two still agree — the
   * scrub mapping needed no change. Its comment predicted a border arriving;
   * it arrived, and now it has left again, and the sum absorbed both. */
  box-shadow:0 0 0 var(--vc-track-border) var(--vc-edge);
  border-radius:var(--vc-bar-radius);

  /* The fill's width, as a length. Declared HERE and nowhere else, because
   * substitution happens on the element where a custom property is declared:
   * --vc-f is written by JS onto this element, so this is the only place the
   * var() picks up the live fraction rather than the inherited default of 0.
   * The 100% inside it stays unresolved until it is used, and both consumers
   * below want the same reference — the track's width. */
  --vc-travel:calc(100% - 2 * var(--vc-fill-inset) - var(--vc-handle));
  --vc-fw:calc(var(--vc-f, 0) * var(--vc-travel) + var(--vc-handle));

  /* How far along the tail->head ramp the playhead is standing, as a mix
   * percentage. The gradient holds the tail flat to 50% and then runs to the
   * head, so the ramp only starts at the midpoint: below it the answer is 0%
   * (pure tail), above it (f - 0.5) doubled. clamp() is what makes the flat
   * half flat, and it also pins the ends against a --vc-f that arrives
   * slightly outside 0..1.
   * DECLARED HERE for the same reason --vc-fw is: --vc-f is written by JS onto
   * this element, so this is the only place a var() picks up the live fraction
   * rather than the inherited default of 0. .vc-handle reads it already
   * resolved. */
  --vc-head-at:clamp(0%, calc((var(--vc-f, 0) - 0.5) * 200%), 100%);

  /* No backdrop-filter. It carried blur(32px) — asked for back when each
   * control blurred for itself — and the scrim replaced that: one filter for
   * the whole band, sampling the frame once. mk2's timebar_base has none
   * either, so this now agrees with the export as well. */
}
.vc-track[aria-disabled="true"]{ cursor:default; }

/* The 8 between timebar_root's bottom edge and controlBase's top, halved. On
 * the SCRUB track only — the volume slider sits inside its own panel and has
 * no control base under it. This is the whole of --vc-gap's job now; it used
 * to double as the layer's bottom margin, which is --vc-bottom since mk2. */
.vc-scrub{ margin-bottom:var(--vc-gap); }

/* mk2 has no unfilled BAR — the plate above is the unfilled state. The element
 * is still built by video-controls.js, where it also serves the volume slider;
 * taking it out of the markup would mean two shapes of track to maintain, so
 * it is simply not drawn. */
.vc-rest{ display:none; }

/* timebar_fill: 12 tall, filling the box inside timebar_base's stroke, radius 1.
 *
 * THE LIT RIM IS GONE. mk2 drew a 1px rgba(255,255,255,.1) highlight on
 * top/left/right with no bottom edge, and it was what kept the fill from
 * reading as a flat swatch dropped in the groove. mk3 draws no border on
 * timebar_fill or timebar_playHead at all — the base's own 2px stroke does that
 * job now, from outside instead of inside. So --vc-fill-line and
 * --vc-fill-edge are gone with it, and the box-shadow they fed is gone too.
 *
 * If it ever comes back, it comes back as a box-shadow and NOT as a border, for
 * a reason worth keeping: Chrome rounds a non-zero border-width UP to a whole
 * CSS pixel — measured at 1x, 2x and 3x, a 0.5px border rendered 1.00 css px
 * every time, twice the drawn thickness. box-shadow takes the same length
 * without snapping it and follows the radius the way a border would, and it
 * composites the same, over the element's own background. Three inset shadows
 * (top, left, right; a fourth for a bottom edge) is the spelling. */
.vc-fill,
.vc-handle{
  position:absolute;
  /* THE INSET IS A TRANSFORM, not a top/left offset, and it has to be.
   * Chrome SNAPS a painted box's offset to a whole CSS pixel: measured in
   * isolation, an absolutely positioned child at top:.5px reports a layout box
   * at 20.50..24.50 and paints 21.000..25.000 — half a pixel low, at every
   * device scale, with or without compositing hints. On a 4px bar inside a 5px
   * plate that is the whole inset, and it lands as 1px of plate above the fill
   * and 0 below instead of 0.5 either side.
   * A transform is applied after layout and is NOT snapped: the same box under
   * translateY(.5px) paints 45.500..49.500, exact. So the boxes sit at 0 and
   * the half pixel is moved here.
   * Consequences to keep in mind: this makes each a stacking context (harmless
   * — they are siblings with no z-index and paint in DOM order), and clip-path
   * below still cuts in the element's own untransformed coordinates, which is
   * why the reveal is unaffected.
   *
   * AT mk3 THIS TRANSLATES BY ZERO, because --vc-fill-inset is 0 — the base's
   * stroke carries the inset now and there is no fractional offset left to
   * snap. Kept anyway: it costs nothing at 0, it is still correct at any value,
   * and deleting it is how the snapping bug returns the first time a tier wants
   * a fractional inset again. */
  top:0;
  transform:translate(var(--vc-fill-inset), var(--vc-fill-inset));
  height:var(--vc-fill-h);
  box-sizing:border-box;
  border-radius:var(--vc-fill-radius);
  pointer-events:none;
}
.vc-fill{
  left:0;                  /* the 0.5 lives in the transform above */
  width:var(--vc-fw);      /* FALLBACK — the stretching behaviour; see below */
  /* Declared twice on purpose. The first is the sRGB blend and the fallback;
   * the second re-declares it interpolating in OKLCH and wins wherever that
   * parses. A browser that cannot read `in oklch` drops the whole declaration,
   * which is why the plain one has to come first — without it the fill would
   * have no background at all rather than a slightly duller one.
   *
   * WHY: the two ends are palette colours and can sit anywhere on the wheel.
   * On the neon palette they land near-complementary (cyan tail, magenta head)
   * and straight-line sRGB interpolation walks through the middle of the cube:
   * measured, the midpoint fell to saturation 0.03 — a grey band across the
   * centre of the bar. OKLCH rotates the hue instead of crossing the axis, so
   * chroma holds the whole way. The mock never showed this because gold to
   * yellow is a few degrees of hue; a palette-driven gradient is not. */
  background:linear-gradient(90deg,
    color-mix(in srgb, var(--vc-fill-tail) 60%, transparent) 0%,
    color-mix(in srgb, var(--vc-fill-tail) 60%, transparent) 50%,
    color-mix(in srgb, var(--vc-fill-head) 60%, transparent) 100%);
  background:linear-gradient(90deg in oklch,
    color-mix(in srgb, var(--vc-fill-tail) 60%, transparent) 0%,
    color-mix(in srgb, var(--vc-fill-tail) 60%, transparent) 50%,
    color-mix(in srgb, var(--vc-fill-head) 60%, transparent) 100%);
}
/* timebar_playHead: 8 wide and 8 tall at the fill's leading edge. Its right
 * edge and the fill's are the same pixel.
 *
 * IT TAKES THE GRADIENT'S OWN COLOUR AT ITS OWN POSITION, at full opacity —
 * so it is the bar's colour made solid rather than a marker in a colour of its
 * own, and it changes hue as it travels, exactly in step with the gradient
 * revealed behind it. Since the ramp is fixed to the bar, that means it holds
 * the tail colour across the first half and then walks to the head.
 *
 * A color-mix at --vc-head-at rather than a slice of the real gradient. The
 * alternative was to paint the whole gradient on this box and window it with
 * clip-path, which samples rather than recomputes — but a windowed box can
 * only wear the rim its own edges carry, and the playhead's left and right
 * rims would have gone with it. Two stops and a mix reproduce the same colour
 * and leave the box a box.
 *
 * Three declarations, narrowing. The bare head colour is the floor for a
 * browser that cannot mix at all; the srgb mix matches the fill's own sRGB
 * fallback gradient; the oklch one matches the gradient that actually renders
 * wherever `in oklch` parses. Each must fall back to the one above it, which
 * is why they are in this order — a dropped declaration leaves the previous. */
/* THE 0.8 IS NOW REAL. Both mocks give timebar_playHead an 0.8 alpha and the
 * token block up top has claimed it since mk2 — but nothing applied it, so the
 * head painted opaque against a fill at 0.5. It is applied here the way the
 * fill applies its own: to each endpoint before the mix, rather than to the
 * result, so all three fallbacks below carry it and color-mix interpolates the
 * alpha along with the hue. */
.vc-handle{
  left:calc(var(--vc-f, 0) * var(--vc-travel));   /* + the transform's inset */
  width:var(--vc-handle);
  background:var(--vc-fill-head);
  background:color-mix(in srgb,
    color-mix(in srgb, var(--vc-fill-head) 80%, transparent) var(--vc-head-at),
    color-mix(in srgb, var(--vc-fill-tail) 80%, transparent));
  background:color-mix(in oklch,
    color-mix(in srgb, var(--vc-fill-head) 80%, transparent) var(--vc-head-at),
    color-mix(in srgb, var(--vc-fill-tail) 80%, transparent));
}
/* ---- the gradient is FIXED to the bar, and revealed ----
   Asked for directly, and kept through the redesign: the gradient must not
   stretch. A static export cannot show the difference — mk2 draws one frame at
   roughly half play, where a stretched ramp and a revealed one look alike — so
   the behaviour is carried over rather than re-derived from the picture.

   The two jobs are split. The fill is the FULL width of the track, and the
   gradient is painted once across the whole run at a size that never changes.
   Progress is a clip: the box is cut off at the playhead, so playing the clip
   walks the cut rightwards and uncovers a gradient that was already there.
   A given point on the bar is the same colour at every moment of playback.

   clip-path rather than a narrower box because it does not touch layout at
   all. --vc-fw carries the same length into both branches; in the fallback it
   is a width against the containing block, here an inset against the fill's
   own box, and because the fill spans the track those are the same reference.
   The cut's right end is always under the playhead, so its rounding is never
   seen; the radius is written for the left end, which is. */
@supports (clip-path:inset(0 0 0 0 round 1px)) or (-webkit-clip-path:inset(0 0 0 0 round 1px)){
  .vc-fill{
    width:auto;
    /* TWO insets, not one: the box starts at 0 and the transform carries it
     * right by --vc-fill-inset, so the right edge has to come in by the near
     * inset as well as its own to land 0.5 short of the plate. */
    right:calc(2 * var(--vc-fill-inset));
    -webkit-clip-path:inset(0 calc(100% - var(--vc-fw)) 0 0 round var(--vc-fill-radius));
    clip-path:inset(0 calc(100% - var(--vc-fw)) 0 0 round var(--vc-fill-radius));
  }
}
.vc-vol-wrap{ position:relative; display:flex; align-items:center; }
/* ---- THE VOLUME SLIDER IS INLINE NOW, not a panel floating above the row ----
 *
 * It used to be a pill anchored over the button: position:absolute, its own
 * --vc-skin background, its own --vc-edge border, opened and closed by JS
 * toggling [hidden]. The whole of that is gone. It is a bare line sitting in
 * the row beside the speaker, growing out of it on hover — YouTube's shape.
 *
 * WHAT WENT WITH IT, so nobody hunts for it: the floating box was the last
 * user of the old .vc-pill surface treatment, and the note about it opening
 * above --vc-scrim-h onto unblurred footage no longer applies, because nothing
 * opens above the row any more. The negative-margin centring is gone too —
 * that existed only to centre an absolutely positioned box, and its warning
 * about translateX(-50%) killing the cursor glint went with the box. The
 * warning itself still stands everywhere else in this file; see .vc-volpanel's
 * entry in visual-tokens.js, which used to repeat it.
 *
 * NO TRANSFORM HERE EITHER, and that is still deliberate: the width animates,
 * not a scaleX. A transform would be cheaper to composite and would cost this
 * element the fixed-background glint for exactly the reason the old comment
 * gave. Width is the honest property anyway — the button beside it must
 * actually move over, not appear to. */
.vc-volpanel{
  /* The OPEN width. Collapsed is 0 and is not a token: an expanding control
   * that can be configured to expand to nothing is a control that can be
   * configured broken. */
  --vc-panel-w:78px;

  /* IT OPENS TO THE RIGHT, and the speaker moves with it. That is the intended
   * behaviour, chosen deliberately: it is what YouTube does, and matching it is
   * the point of this control.
   *
   * Know what it costs, because it is not obvious and it will look like a bug
   * to whoever meets it next. .vc-row is justify-content:space-between and
   * .vc-right is its last item, so the right cluster is pinned to the right
   * edge and anything growing inside it pushes its own siblings LEFT. Opening
   * the slider therefore slides the speaker --vc-panel-w + 6px out from under
   * the pointer that just opened it. A click that follows the hover without
   * re-aiming lands on the track and sets the volume: measured, a click at the
   * button's pre-hover coordinates gave volume 0.84.
   *
   * This was built opening leftward for exactly that reason and changed back
   * on request. If it ever needs fixing rather than accepting, the fix is
   * `order:-1` here with margin-right for the gap — not a transform, which
   * would cost the row its fixed-background glint. */
  display:flex; align-items:center;
  width:0;
  /* Clips the track while closed, and is what makes the slider unclickable at
   * width 0 without needing [hidden] or pointer-events. */
  overflow:hidden;
  opacity:0;
  height:var(--vc-btn-size);
  transition:width .18s ease, opacity .14s ease, margin-left .18s ease;
}
/* Three ways in, and all three are wanted. :hover is the pointer affordance.
 * :focus-within is what keeps the slider reachable by keyboard — it is still
 * in the tab order while collapsed, so without this it would take focus while
 * invisible. .is-vol-open is JS's, set by the wrapper's pointerenter on a fine
 * pointer and by Escape closing it. */
.vc-vol-wrap:hover .vc-volpanel,
.vc-vol-wrap:focus-within .vc-volpanel,
.vc.is-vol-open .vc-volpanel{
  width:var(--vc-panel-w);
  margin-left:6px;       /* the gap sits between speaker and line */
  opacity:1;
}
@media (prefers-reduced-motion:reduce){
  .vc-volpanel{ transition:none; }
}

/* ---- the LINE, as distinct from the scrub bar's plate ----
 * Same .vc-track element and the same drag code; only the geometry differs,
 * and it differs by overriding tokens ON THIS ELEMENT rather than by writing
 * new rules for .vc-vol-track's parts.
 *
 * THAT IS LOAD-BEARING. A custom property is substituted where it is
 * DECLARED. --vc-travel and --vc-fw are declared on .vc-track, which is this
 * element, so --vc-handle and --vc-fill-inset below reach them. --vc-fill-h is
 * NOT: it is declared on .vc-in as var(--vc-track-h), so it was already
 * resolved to the scrub bar's 8px before this element existed, and setting
 * --vc-track-h here would leave an 8px fill in a 4px line. It has to be
 * restated, and that is why it is restated rather than left to derive. */
.vc-vol-track{
  width:100%;
  --vc-track-h:4px;
  --vc-fill-h:4px;          /* NOT derived — see above */
  --vc-fill-radius:2px;
  /* No playhead, same as the scrub bar. A knob was tried here and taken off:
   * the fill alone carries the level. Left at 0 explicitly rather than left to
   * inherit, because --vc-travel and --vc-fw are declared on THIS element and
   * read it — at 0 the travel is the full width and the fill maps 1:1 to the
   * fraction, which is what the scrub bar does too. */
  --vc-handle:0px;
  /* No outside stroke. The plate's stroke exists to separate a solid bar from
   * the picture; a 4px line over the control row does not need it, and at this
   * height it would be most of the control. */
  --vc-track-border:0px;
  background:var(--vc-track-skin);
  border-radius:2px;
}

/* ---- focus ----
   .vid-viewport, .grid-cell and .proj-cover all clip, but the controls sit
   inside them, so an outside ring has room and the site's normal
   :focus-visible rule applies untouched. Only the tap targets need saying:
   the sliders are divs with role=slider, so they get no ring by default. */
.vc-track:focus-visible{ outline:1px solid #fff; outline-offset:3px; }

/* ---- narrow players ----
   Derived, not guessed, and RE-DERIVED for mk3 — twice now, because the
   controls keep moving. These numbers are a FUNCTION of the sizes above, so
   they are wrong the moment a size changes and are not optional to revisit.

   The binding case is an hour-long clip: "1:02:30 / 2:02:30" at 9px, which is
   wider than the 80px box the mock draws. MEASURED IN THE BROWSER WITH THE REAL
   FACE, not scaled off the old figure — JetBrains Mono is self-hosted here, and
   measuring against the ui-monospace fallback reads about 0.3px narrow per
   string. It is 92.14px at 9px IN SITU — 91.81 for the bare string, plus a third
   of a pixel because the elapsed span carries a --vc-tw floor in ch that rounds
   up; the in-situ figure is the one that binds. At mk2's 8px the bare string is
   81.61, so the 83.6 this comment used to carry was measured against something
   else, most likely the ui-monospace fallback rather than the loaded face.

   TIER 1 — the centred play button meets the timecode. The left group ends at
   3 (the row's padding) + 92.14 = 95.14 from the row's left edge, the centred
   button starts at (rowW - 32)/2, and they touch at rowW = 222.3. The row is
   the container less 2x10 of inset, so that is a container of 242.3px. 250
   clears it. It was 230 at mk2's smaller sizes.

   VERIFIED, not just derived: swept 200..300px a pixel at a time with the
   binding string in place, and there is no width at which the timecode reaches
   the play button, the play button reaches the right pair, or the row's content
   exceeds its content box. The button is frame-centred at 251 and in the flow at
   250, which is this breakpoint landing exactly where it says it does.

   Below it the play button stops being frame-centred and becomes an ordinary
   flex item, which makes a collision impossible at ANY width rather than
   pushing the same failure down to a smaller number. The sizes are NOT reduced
   further: this is the size the mock asks for. */
@container (max-width:250px){
  /* relative, NOT static: the button goes back into the flow either way, but
   * its hit-target ::before is absolutely positioned and needs this element to
   * be its containing block. Under static it would anchor to .vc-row and the
   * enlarged target would land somewhere else entirely. `left:auto` undoes the
   * 50% from the base rule, which relative positioning would otherwise apply
   * as an offset rather than ignore. */
  .vc-play{ position:relative; left:auto; transform:none; }
  /* 8, matching --vc-igap. mk2 used 10 here against 24px buttons; at mk3's 32
   * the row is tighter and the flow gap is what pays for it. */
  .vc-row{ gap:8px; }
}
/* TIER 2 — last resort, for a thumbnail-sized grid cell: elapsed only. Once the
 * play button is in the flow the row needs
 *   3 + 92.14 + 8 + 32 + 8 + 72 + 3 = 218.14
 * of content (row padding, timecode, gap, play, gap, the right pair at
 * 32 + 8 + 32, row padding), which is a 238.1px container. Below that the
 * timecode is the only thing that can give, and a shorter one beats a truncated
 * one. 240, which also leaves no width between the two tiers where the row
 * overflows — tier 1 fires at 250 and the flow layout fits from 238.1 up, so the
 * 10px between them is covered from both sides. */
@container (max-width:240px){
  /* --vc-bottom, not --vc-gap: since mk2 those are different jobs. --vc-gap is
   * the space between the timebar and the control bar and is drawn, not spare;
   * the margin worth reclaiming in a thumbnail is the one under the whole
   * layer. NOT derived from the mock — the mock is one size, and this tier
   * exists for grid cells it never covered. Still 8 at mk3's 10px base, which
   * is now a 2px reclaim rather than 6. */
  .vc-in{ --vc-inset:8px; --vc-bottom:8px; }
  .vc-time-sep, .vc-time-dur{ display:none; }
  .vc-time-cur{ min-width:0; }
  /* The 80px box has to go with them, or dropping the duration frees no width
   * at all — the box would hold its drawn size around a four-character string.
   * "0:30" alone measures 21.6px at 9px, so this is what the tier buys.
   * --vc-tw is what stops the elapsed twitching, and it is zeroed above for the
   * same reason. */
  .vc-time{ min-width:0; }
}

/* ---- fullscreen ----
 * STILL DERIVED, and now derived from a different base. mk3 draws one embedded
 * size on the 1022-wide frame, and an 8px timebar on a 27-inch screen reads as
 * a toy — so this tier is x1.25 off the base above, rounded to whole pixels.
 * One factor for every token: the tier before mk2 scaled each by a different
 * amount (1.29 on the row, 1.59 on the inset) and the proportions the mock sets
 * drifted apart at full screen.
 *
 * THERE IS A FULLSCREEN MOCK NOW AND IT IS NOT USED HERE.
 * videoplayer_playmode_desktopfullscreen_mk3.css exists, on a 1512x982 frame,
 * and it asks for a 56 control bar with 48 buttons where the embedded mock asks
 * for 72 and 64 — so taken literally it would make the fullscreen chrome
 * SMALLER than the embedded chrome. That may well be intended; it was not the
 * change asked for, which was the embedded mock only. So this tier stays a
 * derivation of the base and the fullscreen mock is the obvious next pass. Its
 * numbers, halved, for whoever takes it: inset 10, bottom 10, timebar 8, gap 6,
 * bar 28, buttons 24, timecode 9.
 *
 * The vertical chain stays whole. --vc-fill-h and --vc-fill-inset are no longer
 * set here at all: the first IS --vc-track-h now that the timebar's stroke is
 * outside the box, and the second is 0, so scaling --vc-track-h carries both.
 * --vc-track-border is not scaled either, for the same reason --vc-border is
 * not: a hairline is a hairline at any screen size.
 *
 * The BEVELS do not scale. --vc-radius, --vc-btn-radius and --vc-fill-radius
 * are a constant detail, the same decision that stopped the bar's corners
 * growing back into a capsule; x1.25 would put them on 1.25, 2.5 and 0.625 for
 * nothing. --vc-border is a hairline and stays 1px for the same reason it
 * always has — which also keeps --vc-fill-h's derivation whole. */
.vc.is-fullscreen .vc-in{
  --vc-inset:12.5px;    /* 10  x1.25 */
  --vc-bottom:12.5px;   /* 10  x1.25 */
  --vc-gap:10px;        /* 8   x1.25 */
  --vc-h:45px;          /* 36  x1.25 */
  --vc-btn-size:40px;   /* 32  x1.25 */
  --vc-icon:30px;       /* 24  x1.25 */
  --vc-igap:10px;       /* 8   x1.25 */
  --vc-time-w:100px;    /* 80  x1.25 */
  --vc-font:11px;       /* 9   x1.25 = 11.25, rounded */
  --vc-line:15px;       /* 12  x1.25 */
  --vc-track-h:10px;    /* 8   x1.25 — and --vc-fill-h follows it to 8 */
  --vc-handle:8px;      /* 6   x1.25 = 7.5, taken to 8 so the playhead stays
                         * square against --vc-fill-h, which is 10 - 2 here.
                         * Square is what both mocks draw it as. */
}

/* ---- HIT TARGETS ----
   Every control answers to a box 30% larger than the one it paints, added as a
   transparent ::before rather than as padding or a bigger element — padding
   would move the layout, and a bigger element would move the pixels. Nothing
   here draws, so the visuals cannot shift.

   A pseudo-element hit-tests as part of the element that owns it, so a press
   anywhere in the grown box is a press on the button, and the pointer handlers
   need no changes at all. It also does not disturb the drag maths: the
   scrubber inverts getBoundingClientRect() of .vc-track itself, which is still
   the painted box, so only the grabbable AREA grows and the value under the
   cursor stays exactly where it looks.

   The scrub and volume tracks grow vertically only: they already run the full
   width of the bar, so a horizontal ring would buy nothing and would push the
   left end past the frame inset.

   GROWN FROM --vc-track-h, not --vc-handle. Those were the same token until
   mk2 — the playhead was a square as tall as the bar — and they are not any
   more: --vc-handle is the playhead's WIDTH (4px) and would grow the bar by
   0.6px. The bar's own height is what the target is a proportion of.

   Worth saying plainly: at a 5px bar this rule yields 6.5px to grab, down from
   7.8px at 8 and 11.7px at 9. The 30% is the site's rule and the 5px is the
   mock's, so this is what the two produce together; a floor on this one
   selector is the fix if it is too thin in the hand. */
/* .vc-play is deliberately NOT here. It is already positioned in both tiers —
 * absolute when frame-centred, relative when it drops into the flow — and a
 * `position:relative` in this block would be an equal-specificity rule LATER
 * in the file, so it would win and knock the centred button out of position.
 * Only the two right-hand buttons are static and need an anchor. */
.vc-btn{ position:relative; }

.vc-play::before,
.vc-btn::before,
.vc-track::before{ content:''; position:absolute; }

.vc-play::before,
.vc-btn::before{ inset:calc(var(--vc-btn-size) * var(--vc-hit) * -1); }
.vc-track::before{ inset:calc(var(--vc-track-h) * var(--vc-hit) * -1) 0; }

/* ---- lightbox host ----
   The slide is a full-stage flex centrer, so anchoring the layer to it would
   float the controls in the black beside a pillarboxed clip. This wrapper is
   the video's own box instead, sized from --ar, which gallery.js updates from
   videoWidth/videoHeight once metadata lands. 16/9 is the placeholder until
   then, not an assumption about the clip.
   The width has to be stated outright. The only child is absolutely
   positioned, so an auto width shrink-wraps to 0 and aspect-ratio turns that
   into a 0×0 box — the lightbox opened on black. Max-* alone cannot fix it:
   aspect-ratio does not carry a clamped height back to the width. So the
   slide is a size container and the width is the smaller of "fill the width"
   and "fill the height at this ratio". */
.gal-lb-slide:has(> .gal-lb-vidwrap){ container-type:size; }
.gal-lb-vidwrap{
  position:relative;
  --ar:calc(16 / 9);
  width:min(100cqw - 16px, (100cqh - 16px) * var(--ar));
  aspect-ratio:var(--ar);
}
.gal-lb-vidwrap .gal-lb-vid{
  position:absolute; top:0; left:0;
  width:100%; height:100%;
  max-width:none; max-height:none;
  object-fit:contain;
}


/* ============================================================
   BLOCK GRID
   ============================================================ */
.block-grid{ margin:8px 0 16px; }
.grid-viewport{ display:grid; gap:6px; }
.grid-cell{
  position:relative; overflow:hidden; cursor:pointer; background:#0a0a0a;
}
.grid-cell-media{
  width:100%; height:100%; object-fit:cover; display:block;
  transition:transform 0.3s ease;
  -webkit-user-select:none; user-select:none;
}
.grid-cell-vid-ph{ background:#111; }
.grid-cell:hover .grid-cell-media{ transform:scale(1.03); }
/* Was a decorative span. Now the control that plays the video in the tile —
 * the rest of the cell still opens the lightbox. */
.grid-cell-play{
  position:absolute; bottom:8px; right:10px;
  display:grid; place-items:center;
  width:34px; height:34px; padding:0 0 0 2px;   /* ▶ sits right of its own box */
  border:0; border-radius:50%;
  background:rgba(0,0,0,.45);
  color:rgba(255,255,255,.92);
  font-size:14px; line-height:1;
  cursor:pointer;
  transition:background .2s ease, transform .2s ease;
}
.grid-cell-play:hover{ background:rgba(0,0,0,.75); transform:scale(1.08); }
.grid-cell-play:focus-visible{ outline:2px solid #fff; outline-offset:2px; }

/* Playing in place: the tile crops to the grid's aspect, which is right for a
 * thumbnail and wrong for something you are watching — several of these
 * captures are portrait — so the frame is shown whole instead. */
.grid-cell.is-playing{ cursor:default; background:#000; }
.grid-cell.is-playing .grid-cell-media{ object-fit:contain; }
.grid-cell.is-playing:hover .grid-cell-media{ transform:none; }
.grid-cell.is-playing .grid-cell-play{ display:none; }


/* ============================================================
   3D VIEW BLOCK
   ============================================================ */
.block-3d-view{ margin:8px 0 16px; }
.block-3d-view .viewer-iframe-wrap{
  position:relative; width:100%; overflow:hidden;
  border-radius:var(--radius); background:#0b0f14;
}
.block-3d-view iframe{
  position:absolute; inset:0;
  display:block; width:100%; height:100%; border:none;
}
.viewer-overlay{
  position:absolute; inset:0; z-index:2;
  background:transparent; pointer-events:none; border-radius:inherit;
}
.viewer-iframe-wrap.is-active{
  border-radius:0;
}
.viewer-iframe-wrap.is-active .viewer-overlay{
  box-shadow:inset 0 0 0 1.5px var(--toast-border);
  border-radius:0;
}
.viewer-iframe-wrap.is-xrv-fullscreen{
  position:fixed !important; inset:0 !important;
  z-index:9998 !important; border-radius:0 !important;
  aspect-ratio:unset !important;
}
.viewer-iframe-wrap.is-xrv-fullscreen .viewer-overlay { box-shadow:none; }
.viewer-iframe-wrap:-webkit-full-screen{ width:100% !important; height:100% !important; }
.viewer-iframe-wrap:fullscreen          { width:100% !important; height:100% !important; }

/* ============================================================
   PROTOTYPE BLOCK — see proto-frame.js
   ============================================================
   Same frame as the 3D view: a rounded dark box, lit with the
   same ring while it holds input. The shield covers it until it
   is clicked, so a prototype that zooms on the wheel cannot eat
   the page's scroll. */
.block-proto{ margin:8px 0 16px; }
.proto-frame{
  position:relative; width:100%; overflow:hidden;
  border-radius:var(--radius); background:#0b0f14;
}
.proto-iframe{
  position:absolute; inset:0;
  display:block; width:100%; height:100%; border:none;
}
/* The poster covers the frame until the prototype has loaded (proto-frame.js). */
.proto-poster{
  position:absolute; inset:0; z-index:1;
  width:100%; height:100%; object-fit:cover;
  pointer-events:none; transition:opacity .4s ease;
}
.proto-frame.is-loaded .proto-poster{ opacity:0; }
.proto-shield{
  position:absolute; inset:0; z-index:2;
  display:flex; align-items:center; justify-content:center;
  margin:0; padding:0; border:0; border-radius:inherit;
  background:transparent; cursor:pointer;
  font:inherit; color:#fff;
}
.proto-frame.is-active .proto-shield{ display:none; }
.proto-shield-hint{
  display:inline-flex; align-items:center; gap:8px;
  padding:8px 14px; border-radius:999px;
  background:rgba(0,0,0,.55); border:1px solid rgba(255,255,255,.18);
  font-size:13px; font-weight:600; letter-spacing:.02em;
  -webkit-backdrop-filter:blur(6px); backdrop-filter:blur(6px);
  transition:opacity .2s ease;
}
/* Running and waiting for a click: the hint only shows under the pointer,
   so a live prototype is not permanently captioned. Not started yet: it is
   the Run button, and always shows. */
.proto-frame.is-started .proto-shield-hint{ opacity:0; }
.proto-frame.is-started .proto-shield:hover .proto-shield-hint,
.proto-frame.is-started .proto-shield:focus-visible .proto-shield-hint{ opacity:1; }
.proto-run-icon{
  width:0; height:0;
  border-left:9px solid currentColor;
  border-top:6px solid transparent; border-bottom:6px solid transparent;
}
.proto-ring{
  position:absolute; inset:0; z-index:3;
  pointer-events:none; border-radius:inherit;
}
.proto-frame.is-active{ border-radius:0; }
.proto-frame.is-active .proto-ring{
  box-shadow:inset 0 0 0 1.5px var(--toast-border);
}
/* Under the frame, never over the prototype's own controls (proto-frame.js, THE TOOLS). */
.proto-tools{
  display:flex; justify-content:flex-end; gap:6px;
  margin-top:8px;
}
.proto-tool{
  display:inline-flex; align-items:center; justify-content:center;
  width:28px; height:28px; padding:0;
  border-radius:8px; border:1px solid rgba(255,255,255,.18);
  background:rgba(0,0,0,.55); color:#fff; cursor:pointer;
  -webkit-backdrop-filter:blur(6px); backdrop-filter:blur(6px);
}
.proto-tool:hover{ background:rgba(0,0,0,.75); }
.proto-frame:fullscreen{ width:100% !important; height:100% !important; aspect-ratio:auto !important; border-radius:0; }
.proto-frame:-webkit-full-screen{ width:100% !important; height:100% !important; aspect-ratio:auto !important; border-radius:0; }
.proto-frame:fullscreen .proto-ring{ box-shadow:none; }
/* Expanded: the frame fills the window (proto-frame.js, EXPAND). The iframe keeps out of the
   notch and home bar; the close button sits top centre, clear of a prototype's corner controls. */
.proto-frame.is-expanded{
  position:fixed !important; inset:0; z-index:2147483000;
  width:auto !important; height:auto !important; aspect-ratio:auto !important;
  border-radius:0; background:#000;
}
.proto-frame.is-expanded .proto-iframe{
  /* an iframe is a replaced element: with insets alone and auto size it falls back to its
     intrinsic 300x150, so the size is spelled out */
  top:env(safe-area-inset-top,0px); left:env(safe-area-inset-left,0px);
  width:calc(100% - env(safe-area-inset-left,0px) - env(safe-area-inset-right,0px));
  height:calc(100% - env(safe-area-inset-top,0px) - env(safe-area-inset-bottom,0px));
}
.proto-frame.is-expanded .proto-ring{ box-shadow:none; }
.proto-frame.is-expanded .proto-poster{ display:none; }
.proto-close{
  display:none;
  position:absolute; z-index:5;
  top:calc(10px + env(safe-area-inset-top,0px)); left:50%; transform:translateX(-50%);
  /* round and small: it has to fit between a prototype's own top-left and top-right controls */
  align-items:center; justify-content:center;
  width:40px; height:40px; padding:0; border-radius:50%;
  border:1px solid rgba(255,255,255,.18); background:rgba(0,0,0,.6); color:#fff;
  font:inherit; font-size:13px; font-weight:600; cursor:pointer;
  -webkit-backdrop-filter:blur(6px); backdrop-filter:blur(6px);
}
.proto-frame.is-expanded .proto-close{ display:inline-flex; }
.proto-close span{ position:absolute; width:1px; height:1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; }
html.proto-expanded-lock, html.proto-expanded-lock body{ overflow:hidden; overscroll-behavior:none; }
.proto-frame:-webkit-full-screen .proto-ring{ box-shadow:none; }

/* Per-scene text under the viewer. Typography comes from .block-text-body, which this element
   also carries; only the spacing is new. Hidden outright when the current scene has no text, so
   a scene with nothing to say costs no space — the page reflows as the visitor switches, which
   is preferable to a permanent gap sized for the longest one. */
.viewer-scene-text{ margin-top:12px; }
.viewer-scene-text[hidden]{ display:none; }


/* ============================================================
   BLOCK DIVIDER & SPACE
   ============================================================ */
.block-divider{ width:100%; padding:8px 0; }
.block-divider hr{ border:0; height:1px; background:var(--rule); margin:0; }
.block-space{ width:100% }
.block-space-1x{  height:16px  }
.block-space-2x{  height:32px  }
.block-space-4x{  height:64px  }
.block-space-8x{  height:128px }
.block-space-16x{ height:256px }


/* ============================================================
   SECTION-TITLE BLOCK (Header construct)
   ============================================================ */
.block-section-title{
  width:100%;
  padding:88px 0 0;
}

.section-kicker{
  font-family:var(--font-mono);
  font-size:10.5px; letter-spacing:.2em; text-transform:uppercase;
  color:var(--muted); margin:0 0 14px;
  display:flex; align-items:center; gap:12px;
}
.section-kicker .num{ color:var(--palette-ink, var(--ink)); }
.section-kicker .kicker-rule{
  flex:1; height:1px; background:var(--rule);
}

.section-accent-rule{
  display:block; height:1px; width:48px;
  background:var(--palette-ink, var(--ink)); margin:0 0 14px;
}

.section-title{
  font-family:var(--font-geist);
  font-weight:800; font-stretch:125%;
  /* Casing is authored, not enforced. Titles render exactly as typed in the
     composer, so "The Dependency Problem" and "PARITY" are both possible. */
  text-transform:none;
  font-size:clamp(28px, 3.4vw, 44px);
  letter-spacing:-0.03em; line-height:1.05;
  margin:0 0 24px;
  text-wrap:balance;
  color:var(--ink);
}

/* Keep .block-section-heading as a fallback alias */
.block-section-heading{
  margin:0 0 24px;
  font-family:var(--font-wordmark); font-weight:800; font-stretch:125%;
  text-transform:none;   /* matches .section-title — authored casing */
  font-size:clamp(28px,3.4vw,44px); line-height:1.05; letter-spacing:-0.03em;
  color:var(--ink);
}


/* ============================================================
   LINK BUTTON BLOCK
   ============================================================
   Deliberately NOT a new visual idea.

   The site already has exactly one "this is a thing you can press" gesture —
   the project card in grid mode (line 398) — and this is that gesture at
   button scale: raised on --panel, hairline ring, lifts 2px on hover and
   fills. Same numbers, not merely a similar look.

   Hover is --toast-fill / --toast-border / #fff, which is the site's single
   interaction colour, set by strip.js from whatever palette is active
   (strip.js:698-709). A link button that invented its own accent would be the
   one control on the page that ignores the palette.

   THERE IS ONE STYLE ON PURPOSE. A quieter, inline-looking variant was built
   and removed: inline text links are already handled by .block-text-body a
   (line 765), and a second flat treatment here would have been a slightly
   different answer to a question that is already answered. This block is for
   the case that wants to look like a button.

   THE RING IS A BOX-SHADOW, NOT A BORDER. Same as .card, and for the same
   reason: the ring changes colour on hover, and a border appearing or
   thickening would shift the text by a pixel. A shadow spread never touches
   layout, and it follows border-radius for free.

   HOVER IS GATED on (hover:hover) and (pointer:fine) with a matching :active
   rule, copied from .card's structure. Without the gate a touch device latches
   the hover state after the tap and the button stays filled until you press
   something else.

   Typography is the site's UI voice — mono, 10.5px, .18em, uppercase — the
   same values as .chip (line 316) and .section-kicker (line 912). Presence
   comes from the padding and the ring, not from a larger type size, so this
   stays a peer of the other micro-type and not a new scale.
   ============================================================ */
.block-link{
  width:100%; padding:4px 0;
  display:flex;
}
.block-link.align-left  { justify-content:flex-start; }
.block-link.align-center{ justify-content:center; }
.block-link.align-right { justify-content:flex-end; }

.link-btn{
  display:inline-flex; align-items:center; gap:14px;
  max-width:100%;
  padding:13px 18px;
  font-family:var(--font-mono); font-weight:500;
  font-size:10.5px; letter-spacing:.18em; text-transform:uppercase;
  text-decoration:none; color:var(--ink);
  border-radius:var(--radius);
  cursor:pointer;
  background:var(--panel);
  box-shadow: 0 0 0 1px var(--rule-strong),
              0 1px 2px rgba(0,0,0,.06),
              0 8px 24px rgba(0,0,0,.06);
  transition:transform .015s ease-out;
  -webkit-tap-highlight-color:transparent;
  -webkit-touch-callout:none;
  -webkit-user-select:none; user-select:none;
}

/* Optional icon: a favicon fetched from the link target, or an image the
   author picked. Both arrive here as ordinary files in the project folder.

   THE WHITE PLATE ON FAVICONS IS NOT DECORATION. A favicon is someone else's
   artwork and is very often a dark mark on transparency — GitHub's is pure
   black. Dropped straight onto this button it would be invisible twice over:
   against --panel in the dark theme, and against the accent fill on hover. The
   plate gives every favicon the light field its designer assumed, so a link
   button cannot end up with a hole where the icon should be.

   Uploaded images get NO plate: those are chosen for this spot, they are often
   photographic, and boxing one in white would be wrong. They get object-fit
   cover so any aspect ratio fills the square cleanly.

   18px is set against the 10.5px label — big enough to read as a mark, small
   enough that the button stays a line of type with something in front of it
   rather than a media object. */
.link-btn-icon{
  flex:0 0 auto;
  width:18px; height:18px;
  border-radius:var(--radius);
  object-fit:cover;
  display:block;
}
.link-btn-icon.is-favicon{
  object-fit:contain;
  background:#fff;
  padding:2px;
}

/* The copy is author-supplied and can be any length. It truncates rather than
   wrapping: a two-line button reads as a paragraph in a box, and in a half-
   width .block-row the column is narrow enough for that to happen easily. */
.link-btn-label{
  min-width:0;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
}

/* Inline SVG, not a text glyph. U+2197 (NORTH EAST ARROW) is outside the
   JetBrains Mono subset's unicode-range declared at the top of this file, so
   an arrow character would silently fall back to a system font and sit at the
   wrong weight and baseline. The SVG inherits currentColor, so it turns white
   with the label on hover with nothing extra to keep in sync. */
.link-btn-arrow{
  flex:0 0 auto;
  display:flex; align-items:center;
  transition:transform .15s ease;
}
.link-btn-arrow svg{ display:block; }

@media (hover:hover) and (pointer:fine){
  .link-btn:hover{
    transform:translateY(-2px);
    background:var(--toast-fill); color:#fff;
    box-shadow: 0 0 0 1px var(--toast-border),
                0 4px 16px rgba(0,0,0,.18),
                0 12px 32px rgba(0,0,0,.14);
  }
  .link-btn:hover .link-btn-arrow{ transform:translate(2px,-2px); }
}
.link-btn:active{
  transform:translateY(0);
  background:var(--toast-fill); color:#fff;
  box-shadow:0 0 0 1px var(--toast-border);
}

/* Reduced motion: the lift and the arrow nudge are decoration, the colour
   change is the affordance. Drop the movement, keep the fill. */
@media (prefers-reduced-motion:reduce){
  .link-btn, .link-btn-arrow{ transition:none; }
  .link-btn:hover{ transform:none; }
  .link-btn:hover .link-btn-arrow{ transform:none; }
}


/* ============================================================
   BLOCK ROW (half-width side-by-side)
   ============================================================ */
/* stretch, not flex-start.
   A media block's height is arithmetic, not taste: the viewport is width:100%
   under an aspect-ratio, so at the desktop shell a 16:9 clip is
   (1200 - 48 - 48) / 2 * 9/16 = 310.5px and must be exactly that. It is never
   stretched — a normalised 16:9 container is the whole point of it, and
   growing it only adds bars. So in a row the MEDIA sets the height and
   everything else takes it.

   A list is what takes it. Its frame is a box with content flowing from the
   top, so stretch grows it to the row height and the slack collects at the
   bottom of the frame — the two frames then line up exactly, at every viewport
   width, with no hand-tuning and nothing clipped.

   That works in one direction only, and the direction matters when authoring:
   a list SHORTER than its neighbour is pulled up to it for free, while a list
   TALLER than its neighbour is what sets the row height instead, and no rule
   here can pull it back down without cutting copy off. A list is 118px of
   fixed chrome plus a whole number of 25.575px lines, so it cannot be tuned
   onto 310.5px by writing — one real block got to 2.5px over and the next edit
   in either direction was 23px and 28px out. Land UNDER the neighbour and
   stretch closes the gap; land over and nothing can. */
.block-row{ display:flex; gap:48px; align-items:stretch; width:100%; }
/* The row owns the spacing, not the blocks in it.
 *
 * The four media blocks — gallery, video, grid, 3d-view — each carry
 * `margin:8px 0 16px`; every other block type carries none. Standing alone in
 * the stream that difference is invisible, because renderStream puts a gap
 * after each block anyway. Side by side it is not: the row lines up the flex
 * slots, and then the media block's own top margin drops it
 * 8px inside its slot while the list or text beside it starts at the line. A
 * video next to a list sat 8px low for exactly that reason.
 *
 * Zeroed here rather than removed from the media blocks, which would move
 * every full-width gallery and video on the site to fix a row. The bottom goes
 * with the top: a media block that is the TALLER of the two was adding its
 * 16px to the row's height, on top of the gap appendGap already appends — so
 * the row's height was reading 16px larger than the taller block in it.
 *
 * `.block-row > *` and `.block-video` are BOTH (0,1,0) — a class plus a
 * universal counts for nothing extra — so this wins on source order alone, the
 * same way .block-gallery.no-stroke wins over the hover rule above it. Every
 * media margin is declared further up the file. Keep it that way: a media
 * block restyled below this point would take its margin back inside rows, and
 * the only symptom would be 8px of drift in one column. */
.block-row > *{ flex:1 1 0%; min-width:0; margin-top:0; margin-bottom:0; }
.block-row-spacer{ flex:1 1 0%; min-width:0; }

@media (max-width:880px){
  .block-row{ flex-direction:column; gap:24px; }
  .block-row > *{ flex:0 0 auto; width:100%; }
}


/* ============================================================
   PROJECT PAGE — HERO
   ============================================================ */
.content.project-content{
  margin-top:0;
  padding:0 24px 120px;
}
.project-page{
  display:flex; flex-direction:column; gap:0;
}

/* A section page renders into the index's #projects element (main.js, setSectionMode), so it
 * sheds the index's chrome and its card grid and lays out like .project-page. Declared after
 * the .projects.list-mode rules on purpose: same specificity, so source order decides, and a
 * visitor who switched the index to List must not get a list-mode section page. */
.is-section-page .content > .index-hero,
.is-section-page .filter-bar,
.is-section-page .list-header{ display:none !important; }
.is-section-page .projects{
  display:flex; flex-direction:column; gap:0;
  border:0;
  padding-top:36px;
}
/* A page with its own hero (projects.js, buildPageHero) starts with it, so the hero's own top
 * padding is the gap under the header — the same one the Projects and About headers have. */
.is-section-page .projects:has(> .page-hero:first-child){ padding-top:0; }
.page-hero{ margin-bottom:24px; }
/* A Section Title's 88px top padding separates it from the section before. Straight after
 * the page hero there is no section before — the hero's rule already marks the break — so
 * that space stacked on the hero's own came to 112px of nothing. Same move as
 * .ptab-panel > .block-section-title:first-child. The slot variant is the Composer preview,
 * which wraps the hero (project-admin.js, page-hero-slot). */
.page-hero + .block-section-title,
.page-hero-slot:has(> .page-hero) + .block-section-title{ padding-top:16px; }

/* ── Project page — V6 Stat Block ── */
.proj-breadcrumb{
  display:flex; align-items:center; gap:14px;
  padding:36px 0 14px;
}
.proj-back{
  display:inline-flex; align-items:center; gap:8px;
  color:var(--muted); text-decoration:none;
  font-family:var(--font-mono); font-size:10.5px; letter-spacing:.18em; text-transform:uppercase;
}
.proj-back:hover{ color:var(--ink); }
.proj-div{ width:1px; height:12px; background:var(--rule-strong); }
.proj-crumb{
  display:inline-flex; align-items:center; gap:10px;
  font-family:var(--font-mono); font-size:10.5px; letter-spacing:.18em;
  text-transform:uppercase; color:var(--muted);
}
.proj-dot{ width:6px; height:6px; border-radius:50%; background:var(--palette-ink, var(--ink)); display:inline-block; }
.proj-titleblock{ padding:36px 0 0; }
.proj-title{
  font-family:var(--font-wordmark);
  font-weight:800; font-stretch:100%;
  font-size:clamp(36px, 5vw, 66px);
  letter-spacing:-0.035em; line-height:.95;
  margin:0; text-wrap:balance; color:var(--ink);
}
.proj-cover{
  margin:28px 0 0;
  width:100%; aspect-ratio:21/9;
  background:var(--media-bg);
  border-radius:var(--radius);
  overflow:hidden; position:relative;
  /* --media-stroke, matching gallery and video blocks. Hover lives with theirs
   * in the (hover:hover) block above; :hover outranks this on specificity, so
   * source order does not matter for the lit state. */
  border:1px solid var(--media-stroke);
  transition:border-color .15s;
}
.proj-cover video,
.proj-cover img{ width:100%; height:100%; object-fit:cover; display:block; }
/* Closed on all four sides, the same frame the page index carries: a 1px ring
   drawn OUTSIDE the box by box-shadow, and --radius. The internal dividers
   stay --rule, so in light the frame reads as the object's edge and the
   dividers as structure within it — the rail makes exactly that distinction
   between its ring and its spine. In dark the ring drops to --rule as well
   (see the override by the theme tokens): the two-step that separates edge
   from structure in light reads as one box outlined louder than the page.
   
   The ring's outer edge lands on the content column, so the strip still lines
   up with the cover above it; the cells are inset from it instead, which is
   why the first column now carries the same 22px the others always had. */
.proj-stats{
  /* One track per stat, not a fixed four. Stack is optional and Client, Role
     and Year are each skipped when empty, so the count is 1-4 — open-ended
     rules could trail off past the last cell without anyone noticing, but a
     frame turns that leftover track into a cell with nothing in it. The count
     comes from the markup as --stat-cols; the media queries below still
     override the whole property, so the responsive layouts are unaffected. */
  display:grid; grid-template-columns:repeat(var(--stat-cols, 4), 1fr);
  margin-top:28px;
  border-radius:var(--radius);
  box-shadow:0 0 0 1px var(--rule-strong); --ring:1;
  overflow:hidden;
  position:relative;   /* containing block for the glow ring */
}
/* --sep-r is the column divider, --sep-h the rule between stacked rows. Both
   are the CURSOR GLOW section's switches: every rule below that draws one of
   these borders sets its marker to 1 and every rule that kills one sets it to
   0, in the same block, so the lit edge cannot drift from the real one.

   --sep-h is expressed on the TOP edge at every width even though the 600px
   layout draws it as a border-bottom. "A bottom rule on all but the last" and
   "a top rule on all but the first" are the same pixels, and picking one of
   them means the overlay never has to move — only switch on. */
.proj-stats .col{
  padding:18px 22px;
  display:flex; flex-direction:column; gap:8px;
  border-right:1px solid var(--rule); --sep-r:1;
  position:relative;
}
.proj-stats .col:last-child{ border-right:0; --sep-r:0; }
.proj-stats .lbl{
  font-family:var(--font-mono);
  font-size:10px; letter-spacing:.18em; text-transform:uppercase;
  color:var(--muted);
}
.proj-stats .val{
  font-family:var(--font-wordmark); font-weight:600;
  font-size:18px; letter-spacing:-0.005em; color:var(--ink);
}
.proj-briefdup{
  display:grid; grid-template-columns:1fr 1fr;
  gap:48px; padding:24px 0 0;
}
.proj-briefdup .lbl{
  display:block; margin-bottom:10px;
  font-family:var(--font-mono);
  font-size:10.5px; letter-spacing:.18em; text-transform:uppercase;
  color:var(--muted);
}
/* Typography sits on the CONTAINER, not on `p` — same as .block-text-body.
 *
 * These bodies hold rich text from the Composer's editor, and contenteditable
 * emits <div> for a new line, not <p>. With the font on `p` alone, div-based
 * content inherited the page default instead: Brief (older, <p>-based) and
 * Approach (newer, <div>-based) rendered in visibly different faces and
 * line-heights on the same page. Anything the sanitiser allows must inherit
 * the right type, so only SPACING belongs on the elements. */
.proj-briefdup .proj-brief-body{
  font-family:var(--font-body);
  font-size:15.5px; line-height:1.65;
  color:var(--ink-soft); max-width:62ch;
}
.proj-briefdup .proj-brief-body p{ margin:0 0 14px; }
.proj-briefdup .proj-brief-body p:last-child{ margin-bottom:0; }
/* The editor separates div-paragraphs with <div><br></div>, which renders as a
 * full blank line — 25.6px against the 14px that <p> margins give, so the two
 * columns kept different rhythms even once the type matched. Collapse the
 * spacer and let it carry the same 14px instead. Presentational only: the
 * markup is untouched, so text-align and the author's intent survive. */
.proj-briefdup .proj-brief-body > div:has(> br:only-child){
  height:0; margin:0 0 14px; overflow:hidden;
}
.proj-briefdup .proj-brief-body > div:last-child{ margin-bottom:0; }

/* Mobile overrides */
@media (max-width:880px){
  .proj-stats{ grid-template-columns:repeat(2,1fr); }
  .proj-stats .col:nth-child(2){ border-right:0; --sep-r:0; }
  /* Wrapping makes a second row, and inside a frame that row needs a rule
     above it or the two run together. */
  .proj-stats .col:nth-child(n+3){ border-top:1px solid var(--rule); --sep-h:1; }
  /* An odd number of stats leaves the last cell alone on its row, and the
     half beside it empty — the same hole the frame exposed at full width,
     one breakpoint down. It takes the whole row instead. */
  .proj-stats .col:last-child:nth-child(odd){ grid-column:1 / -1; border-right:0; --sep-r:0; }
  .proj-briefdup{ grid-template-columns:1fr; gap:28px; }
  .proj-cover{ aspect-ratio:16/9; }
}
@media (max-width:767px){
  /* Full bleed: the cover steps back out through the shell's side padding to
     run from edge to edge. Measured off --shell-pad rather than repeating its
     value, because a literal here is only correct while it happens to equal
     whatever the shell is using — which it did not, and that was the sideways
     scroll on every project page. Change the padding at any breakpoint and
     this follows it. */
  .proj-cover{
    border-radius:0;
    margin-left:calc(var(--shell-pad) * -1);
    margin-right:calc(var(--shell-pad) * -1);
    width:calc(100% + var(--shell-pad) * 2);
    border-left:0; border-right:0;
  }
  .block-text{ padding-left:0 !important; padding-right:0 !important; }
  .block-gallery, .block-3d-view, .block-proto{ width:100% !important; align-self:stretch !important; }
}
.is-mobile .block-text{ padding-left:0 !important; padding-right:0 !important; }
.is-mobile .block-gallery,
.is-mobile .block-3d-view,
.is-mobile .block-proto{ width:100% !important; align-self:stretch !important; }


/* ============================================================
   RESPONSIVE — TOOLBAR + FILTER
   ============================================================ */
@media (max-width:880px){
  .toolbar{ flex-wrap:wrap; gap:14px; padding:16px 0 14px; }
  .nav{ width:100%; margin-left:0; gap:18px; flex-wrap:wrap; order:1; }
  #themeToggle{ margin-left:auto; }
  .filter-bar{ flex-wrap:wrap; }
  .filter-meta{ width:100%; margin-left:0; justify-content:space-between; }

  /* THE TOKEN, not three copies of 16. The shell's side padding used to be
     written out here as a literal while --shell-pad stayed at its desktop 24,
     and the project cover — which breaks OUT of that padding to run edge to
     edge — kept using the 24. The two disagreed by 8px, so every project page
     was 8px wider than the viewport and scrolled sideways on a phone. Moving
     the value into the token is what makes the break-out below correct by
     construction instead of by two numbers happening to match.
     --shell-edge is derived from this and only feeds the page index's
     centring, which is superseded by left:16px from 1099px down — so nothing
     above this breakpoint sees the change.

     :root, not html. They select the same element but :root is a pseudo-class
     and outranks a type selector, so `html{...}` here loses to the :root block
     at the top of the file no matter how far down it sits — which is exactly
     what happened: the override silently did nothing and mobile padding went
     from 16 to 24 instead. Specificity, not order. */
  :root{ --shell-pad:16px; }
  .content{ padding:20px var(--shell-pad) 80px; }
  .content.project-content{ padding:0 var(--shell-pad) 80px; }
  /* .head-inner needs no override any more: its base rule is already
     `padding:0 var(--shell-pad)`. */
}
@media (max-width:600px){
  /* Header: keep brand on one line at narrow widths */
  .brand      { gap:8px; }
  .brand .name{ font-size:17px; white-space:nowrap; }
  .brand .role{ font-size:9px; letter-spacing:.06em; padding-left:8px; white-space:nowrap; }
  /* Project detail stats: horizontal label + value on one line */
  .proj-stats{ grid-template-columns:1fr; }
  .proj-stats .col{
    flex-direction:row; align-items:baseline;
    justify-content:space-between; gap:16px;
    padding:10px 16px; border-right:none !important;
    border-top:none !important;
    border-bottom:1px solid var(--rule);
    --sep-r:0;
  }
  .proj-stats .col:last-child{ border-bottom:none; }
  /* Stacked: a rule above every row but the first. Same pixels as the
     border-bottom above, read from the other side — see --sep-h. Written at
     the same specificity as the 880px rule it has to beat, so source order
     decides it rather than an !important. */
  .proj-stats .col:first-child{ --sep-h:0; }
  .proj-stats .col:nth-child(n+2){ --sep-h:1; }
  .proj-stats .lbl{ white-space:nowrap; }
  .proj-stats .val{ font-size:13px; text-align:right; letter-spacing:0; }
}


/* ============================================================
   STATUS PAGES — 404.html, maintenance.html
   Both reuse .site-head / .index-hero / .index-title / .proj-back.
   Only the copy block below is new.
   ============================================================ */
.status-body{
  padding:28px 0 0;
  display:flex; flex-direction:column; align-items:flex-start; gap:22px;
}
.status-copy{
  margin:0; max-width:52ch;
  font-size:15px; line-height:1.6; color:var(--ink-soft);
}

/* maintenance.html carries an EMPTY .nav purely as the auto-margin spacer that
   pins the theme toggle right. Above 880px that costs nothing (zero width). At
   880px and below .nav becomes width:100% on its own wrapped row, so an empty
   one would add a blank 14px gap — collapse it there. Also stops 404.html
   reserving that row before main.js hydrates the nav. */
@media (max-width:880px){
  .nav:empty{ display:none; }
}


/* ============================================================
   FOCUS RING — restyled on the PUBLIC SITE only
   ============================================================
   Replaces the browser's blue OS rectangle with a hairline in the theme's own
   colour. The affordance is KEPT, not removed: keyboard users can still see
   where they are, so this stays on the right side of WCAG 2.4.7.

   :focus-visible does the work. It fires for keyboard navigation and not for
   mouse clicks, which is what made the default ring feel like a bug — it
   lingered after clicking a nav link. The explicit :focus:not(:focus-visible)
   rule below is belt-and-braces for engines that do not apply the UA ring
   through :focus-visible.

   NO HALO. There used to be a `box-shadow:0 0 0 5px <colour at 18%>` here and
   it was wrong: box-shadow spread starts at the element's BORDER BOX, while
   the outline sits at outline-offset:3px — i.e. inside that band. The result
   was dim / bright / dim, read as a spurious dimmer line inside the ring. A
   ring-shaped shadow that begins where the outline is cannot be expressed with
   box-shadow, so the halo is gone rather than approximated.

   No border-radius is set here on purpose. Outlines already follow the
   element's own radius, so the circular .theme-toggle gets a circular ring;
   forcing a radius would square it off.

   COLOUR: `var(--palette-ink, var(--ink))` — the exact expression ::selection
   uses on line 145, so the focus ring and the text-selection highlight are
   always the same colour. That is the point: they are the same idea (the
   browser telling you what it is pointing at), so they must not drift.

   Note this is --palette-ink, NOT --accent. strip.js derives --palette-ink
   from the palette's textColor (strip.js:688), a value already chosen to read
   as text against the background; --accent is the decorative strip colour and
   carries no legibility guarantee. Every other "current palette" mark on the
   site — the kicker dot, the title's period, the active-nav dot — uses this
   same variable with the same --ink fallback. Do not introduce a separate
   token for it; a second source of truth is how these fall out of sync.

   WHY IT IS SCOPED, and why the scope is `:has(.admin-shell)` rather than a
   list of wrappers:

   1. admin/index.html loads THIS stylesheet (its line 8). Restyling focus
      globally would also restyle the whole Composer, whose own CSS already
      handles focus on its inputs (project-admin.css:245 and friends).
      `.admin-shell` is in the Composer's static markup, so excluding it is
      reliable at first paint, not just after hydration.

   2. Listing `.site-head, .content` would MISS the gallery lightbox, which
      gallery.js appends to document.body (gallery.js:89, `.gal-lightbox`) —
      its close and arrow buttons live outside both wrappers. Scoping at the
      root catches anything mounted at body level, now or later.

   If :has() is ever unsupported the whole block is dropped and the default
   ring comes back. That is the correct way for this to fail: the affordance
   survives, only the styling is lost.
   ============================================================ */
html:not(:has(.admin-shell)):not([data-focus-committed]) :focus-visible{
  outline:1px solid var(--palette-ink, var(--ink));
  outline-offset:3px;
  transition:outline-color .12s ease;
}
html:not(:has(.admin-shell)) :focus:not(:focus-visible){
  outline:none;
}

/* Once the user has COMMITTED to a control (focus.js sets this on Enter/Space
 * or a pointer press, and clears it on the next Tab) the ring is dismissed —
 * it should say "where you are heading", not "what you just did".
 *
 * This rule is REQUIRED, not redundant: the rules above stop matching while
 * committed, and with nothing setting outline the browser falls back to its own
 * blue UA ring. Sets outline only, never box-shadow: the inset rings below are
 * box-shadows, and resetting it here would erase them too. */
html:not(:has(.admin-shell))[data-focus-committed] :focus-visible{
  outline:none;
}

/* Controls inside an overflow:hidden ancestor cannot show an OUTSIDE ring —
 * the ancestor clips it and you are left with one stray edge. The Grid/List
 * toggle was exactly this: .viewmode (line ~338) sets overflow:hidden to clip
 * the .on background to its radius, and its buttons sit flush against that
 * edge, so a 3px-offset ring survived only where the two buttons meet.
 *
 * These use an INSET box-shadow rather than a negative outline-offset. A
 * negative offset (-2px) drew the ring visibly smaller than the control, with
 * a band of button showing outside it — it read as the wrong size. An inset
 * shadow lands exactly on the border box, matches the control's bounds, and
 * follows its border-radius, so .gal-arrow gets a true circle.
 *
 * Clipping ancestors audited: .viewmode (338) and .gal-viewport (541).
 * .vid-viewport clips too and now DOES contain focusable controls, but they
 * are not listed: the custom bar sits 28px inside the frame, so a 3px-offset
 * ring has room and is never clipped. Only a control flush against a clipping
 * edge needs the inset treatment.
 * Deliberately NOT listed: .gal-dot and .gal-lb-close,
 * which sit outside their clipping ancestors and take the normal ring; and
 * .card, whose overflow clips its children but never its own outline. */
html:not(:has(.admin-shell)):not([data-focus-committed]) .viewmode button:focus-visible,
html:not(:has(.admin-shell)):not([data-focus-committed]) .gal-arrow:focus-visible{
  outline:none;
  box-shadow:inset 0 0 0 1px var(--palette-ink, var(--ink));
}

/* NATIVE VIDEO CONTROLS — removed, along with the bar they styled
   ------------------------------------------------------------
   Seven `outline-color` rules used to live here, one per ::-webkit-media-
   controls-* pseudo-element, because the browser's own bar sits in a shadow
   root the site's :focus-visible rule cannot reach. They are gone with it:
   video-controls.js drops the `controls` attribute and builds the bar out of
   real <button>s and role=slider divs in the page's own DOM, so every one of
   those tab stops now takes the normal ring from the block above, in Firefox
   too — which never exposed the hooks at all.

   The one line kept is .vid-el::-webkit-media-controls-enclosure up in the
   .vid block. That is NOT dead: mount() leaves `controls` in place if the
   custom layer cannot be built, and the native bar it styles is the fallback
   the whole replacement rests on. */


/* ============================================================
   PROJECT PAGE v2 — PAGE TABS + PAGE INDEX
   ============================================================
   Both features are additive and both are off by default: a project with no
   `tabs` array and no `pageIndex` object renders exactly as it did before any
   of this existed. Nothing below restyles an existing selector.
   ============================================================ */

/* ------------------------------------------------------------
   FEATURE 2 — PAGE TABS
   Sits between the hero (brief/approach) and the block stream.
   Voice is the site's existing micro-type: mono, 10.5px, .18em,
   uppercase — the same values as .nav, .chip and .section-kicker.
   The active marker is a 2px rule in --palette-ink, which is the
   same move .nav a.is-active makes with its dot: label goes to
   --ink, the palette carries the state.
   ------------------------------------------------------------ */
/* The wrapper holds the bar AND the panels: a sticky element can only stay
   stuck inside its own parent's box, so wrapping the bar alone would let it
   scroll away immediately. The zero-height anchor keeps a handle on the bar's
   natural position, which is where a tab switch scrolls back to. */
.ptabs-wrap{ position:relative; }
.ptabs-anchor{ height:0; }
.ptabs{
  position:sticky; top:0; z-index:40;
  background:var(--bg);
  margin:34px 0 0;
  /* The rule under the bar, drawn INSIDE the padding box rather than as a
     border-bottom. Geometrically the two are identical — 1px of padding and
     1px of border occupy the same row and give the same offsetHeight, which
     PageTabs.barHeight() reads — but only one of them can be lit.
     overflow-x:auto makes this a scroll container, a scroll container clips
     its descendants at the PADDING box, and a border lies outside that. Every
     overlay in here is therefore sealed away from a border and can never reach
     it: the cursor glow and the active tab's pool both sat one pixel above the
     line for exactly that reason. Moved inside, the line is reachable and both
     land on it.
     Do not "simplify" this back to border-bottom. */
  padding-bottom:1px;
  box-shadow:inset 0 -1px 0 var(--rule);
  display:flex; align-items:stretch;
  overflow-x:auto; scrollbar-width:none;
  -webkit-overflow-scrolling:touch;
}
.ptabs::-webkit-scrollbar{ display:none; }

.ptab{
  appearance:none; background:none; border:0; cursor:pointer;
  position:relative; flex:none;
  /* One and a half times the old 11 + 10. All of the bar's height is padding —
     the 13px label keeps its size and its tracking either way — so this buys
     the row air on both sides without touching the type: at 13px the bar goes
     from 39.5px to 50px.
     The half pixel is not a rounding artefact. The split is exactly 11:10,
     because the top still has to carry the extra to balance the label against
     a marker that sits ON the bottom edge, and holding that ratio through the
     scale is what keeps the row looking centred rather than one nudge low.
     Nothing measures these: PageTabs.barHeight() reads offsetHeight, so the
     scroll-spy trigger and the scroll-back on a tab switch follow on their
     own. */
  padding:16.5px 0 15px; margin-right:44px;
  color:var(--muted);
  font-family:var(--font-mono); font-weight:500;
  font-size:13px; letter-spacing:.14em; text-transform:uppercase;
  transition:color .15s;
  -webkit-tap-highlight-color:transparent;
}
.ptab:last-child{ margin-right:0; }
.ptab:hover{ color:var(--ink-soft); }
.ptab.is-active{ color:var(--ink); }
.ptab.is-active::after{
  /* 3px sitting flush ON TOP of the rule, which is what this has always
     rendered as. It used to be written as height:4px at bottom:-1px and lose
     its last row to the scroll container's clip; now that the rule has moved
     inside that clip (see .ptabs) nothing would trim it any more, so the
     visible size is stated outright rather than left to a clip to produce.
     Same pixels as before — if you want the 4px the old declaration asked for,
     this is the one place to change it. */
  content:""; position:absolute; left:0; right:0; bottom:0;
  height:3px; background:var(--palette-ink, var(--ink));
  /* Above BOTH lights on the bar (see CURSOR GLOW at the end of the file):
     the cursor's grey strip is a later sibling and would otherwise composite
     over the marker's bottom row, greying the one thing on the bar that is
     supposed to be a solid state readout. The marker is the source; it does
     not get lit by anything. */
  z-index:2;
}
.ptab-panel{ display:none; }
.ptab-panel.is-active{ display:block; padding-top:40px; }
/* The lead-in under the bar belongs to the PANEL, not to whichever block
   happens to be first. Otherwise a tab that opens on a Section Title (88px of
   its own padding), a Space, or a Link each start at a different height, and
   switching tabs looks misaligned. Zero the first block's own top spacing and
   let the panel own the gap. */
.ptab-panel > :first-child{ margin-top:0; }
.ptab-panel > .block-section-title:first-child{ padding-top:0; }
/* A leading spacer is left over from the single-stream layout, where it
   separated the hero from the content. The bar does that job now. */
.ptab-panel > .block-space:first-child{ height:0; }

@media (max-width:640px){
  .ptab{ margin-right:30px; font-size:12px; }
}


/* ------------------------------------------------------------
   FEATURE 1 — PAGE INDEX
   A framed rail in the left margin. The header — dot, "INDEX",
   caret — is ALWAYS visible and never moves; collapsing hides the
   structure, not the component. Circular nodes throughout, the
   same dot .nav a.is-active, .proj-dot and .index-kicker use.
   THE SPINE IS NEVER TINTED. State lives in the dots alone; the
   line is structure, not a readout. The frame ring is --rule-strong
   in light and --rule in dark — see the override by the tokens.
   ------------------------------------------------------------ */
.pindex{
  position:fixed; z-index:70;
  top:50%; transform:translateY(-50%);
  width:var(--rail-w);
  /* Horizontally centred in the left margin. Equal gap either side, at every
     viewport width and every shell width, with no exceptions.

     The margin the eye sees runs from the viewport edge to where content
     starts: 50vw less --shell-edge, which is half the shell less the padding
     inside it. Centre in that and you have half the margin, less half the
     frame's own width. Written against --shell-edge rather than hand-computed
     from it, so resizing the shell re-centres the rail on its own — which is
     the whole expectation this has to meet. The literals it replaced (576 and
     810, for a 1200 shell) did not, and a shell moved to 1120 left the rail
     30px off the viewport edge and 70px off the copy.

     There WAS a second term capping how far the rail could drift, holding it
     30px from the content column and letting the surplus collect outboard, on
     the theory that a rail belongs beside the text it indexes. It is gone: it
     only bound past a crossover at (shell + 480px), so the rail centred on one
     display and pinned on a wider one, and which behaviour you got depended on
     the window rather than on anything you set. Predictable beats clever here.

     The cost is real at very wide viewports, because the margin grows at a
     quarter of every pixel while the shell does not grow at all: at a 1120
     shell the rail sits 110px from the copy at 1920 and 270px at 2560. If that
     ever reads as adrift, the answer is a cap on the GAP, not a return to a
     crossover — but it will always be a choice between centred and close, and
     this picks centred.

     max() with the 12px floor is all that is left: it keeps the frame on
     screen when the margin runs out entirely — it can never take the rail off
     the left of the window, whatever the shell and viewport work out to. */
  left:max(12px,
           calc((50vw - var(--shell-edge)) / 2 - var(--rail-w) / 2));
  /* No fill: the rail sits on the page ground itself, so the frame is the
     hairline ring and nothing else. It reads as a region of the margin rather
     than a surface laid on top of one — which is only honest while it IS in
     clear margin. The moment it can overlap content (below 1672px) it takes a
     real fill again, further down. */
  background:transparent;
  border-radius:var(--radius);
  box-shadow:0 0 0 1px var(--rule-strong);
  overflow:hidden;
}
/* Under 1672px the frame can overlap content when open, so it takes a fill —
   but no elevation. Occlusion alone separates it from the copy behind; the
   hairline ring in the base rule is the whole edge treatment at every width,
   so the component reads the same in the margin and over the page. */
/* 1671 = (--shell + 2 x --rail-w + 72px) - 1. Below it the centred rail is
   within 30px of the copy and can overlap it once open, so it takes a fill.

   The derivation, so this can be recomputed rather than guessed at: the rail
   is centred, so its inboard gap always equals its outboard one, and both are
   (margin - rail) / 2. Requiring that gap to clear 30px gives margin >= rail +
   60, and the margin is viewport/2 - (shell/2 - 24) — which rearranges to the
   line above.

   Media queries cannot read a custom property — `@media (max-width:
   var(--shell))` is invalid and fails silently — so this figure follows
   NEITHER --shell NOR --rail-w and must be moved by hand with either. It was
   1599 for the same 1120 shell with a 204px rail. */
@media (max-width:1671px){
  .pindex{
    /* A floating surface has to occlude what it floats over. Transparent here
       would put the page's own copy straight through the rail's labels, so the
       fill comes back — as --bg, the same ground it sits on above this width,
       so the component does not change colour as the viewport narrows.
       At 85% it still occludes enough to read against copy, but the page
       clearly carries on behind it — and enough of the backdrop survives for
       the blur below to be worth having. color-mix with `transparent` lowers the
       ALPHA and nothing else — the base colour is still exactly --bg, which a
       hand-written rgba() of the same swatch would not guarantee once the
       theme's value changes. The opaque declaration above it is the fallback
       for anything that cannot parse color-mix. */
    background:var(--bg);
    background:color-mix(in srgb, var(--bg) 85%, transparent);
    /* Blur what shows through, so the tenth of the page that does read as
       depth behind a surface rather than as copy the rail failed to cover.
       The radius is large because the fill is doing most of the work: at 90%
       only a tenth of the backdrop is visible, and a small radius inside that
       tenth is not perceptible at all. Prefixed as well — Safari only shipped
       this unprefixed recently, and without the prefix it silently falls back
       to the flat fill, which is a fine degradation but not the intent. */
    -webkit-backdrop-filter:blur(18px);
            backdrop-filter:blur(18px);
  }
}

/* ---- Vertical justification: Top / Middle / Bottom ----
   Middle is the base rule above and stays the default: a project that never
   picked a position renders exactly as it did before this existed. The other
   two anchor ONE EDGE of the frame to the viewport instead of its centre, so
   the body grows away from that edge — down from the top, up from the bottom.
   `transform:none` is what un-centres them; without it the frame would still
   be pulled up half its own height. */
.pindex[data-align="top"]{
  /* Not top:0. At rest the site header is still on screen, and a rail pinned
     to the viewport's top edge would sit across it. So the anchor is the
     header's own bottom edge, measured in place() and written here as
     --pindex-top. It is recomputed on resize and never on scroll: once the
     header scrolls away the rail simply stays where it was, which is a stable
     offset rather than a frame that slides as you read. */
  top:var(--pindex-top, 88px); bottom:auto; transform:none;
}
.pindex[data-align="bottom"]{ top:auto; bottom:24px; transform:none; }

/* Anchored at the bottom means it opens upward: the head goes under the list
   so it stays against the edge the frame is pinned to. data-align is the
   EFFECTIVE anchoring, not the authored setting — below 1100px a middle frame
   has no margin to sit in and place() relabels it `bottom`, because from that
   point on it is bottom-aligned in every way that matters: same anchor, same
   fold, same edge to drag. One attribute, so nothing downstream has to
   re-derive it. */
.pindex[data-align="bottom"]{ display:flex; flex-direction:column-reverse; }

/* Narrow: the margin is gone, so the frame leaves it and docks to the left
   edge. The vertical anchoring is already settled by then — a middle frame
   arrives here relabelled `bottom` — so all that is left is the inset and the
   width. */
/* Also fixed, also not derived from --shell: below roughly the shell itself
   there is no margin left to sit in, so the rail leaves it and docks to the
   left edge. Narrow the shell and this can come down with it. */
@media (max-width:1099px){
  .pindex{
    left:16px; right:auto;
    /* A DEFINITE width, not `width:auto`. Shrink-to-fit measured the widest
       line in the rail — and collapsed sub-lists are `max-height:0`, not
       `display:none`, so their labels still count. The rail was being sized by
       text nobody could see: on an 800px viewport it pinned itself to its own
       320px maximum on the strength of a hidden "Build Pathways/Assets vs.
       Tools". Same 204px as every other width, so the component does not
       change size as the viewport crosses this breakpoint; the min() is only
       for phones narrower than the frame plus its margins. */
    width:min(var(--rail-w), calc(100vw - 32px));
  }
  .pindex[data-align="bottom"]{ bottom:16px; }
}
/* ---- Resize grips ----
   You drag the body's FREE edge — the one away from the head. A top-anchored
   frame grows downward, so that edge is the bottom; a bottom-anchored frame is
   the mirror of it. A centred frame has no free edge: it is pinned by its
   middle, so both edges are live and either one moves both (startDrag explains
   why that is a factor of two rather than one). */
.pindex-grip{
  position:absolute; left:0; right:0; height:9px; z-index:2;
  display:none; cursor:ns-resize; touch-action:none;
  -webkit-tap-highlight-color:transparent;
}
.pindex-grip[data-edge="top"]{ top:0; }
.pindex-grip[data-edge="bottom"]{ bottom:0; }
.pindex[data-align="top"]    .pindex-grip[data-edge="bottom"],
.pindex[data-align="bottom"] .pindex-grip[data-edge="top"],
.pindex[data-align="middle"] .pindex-grip{ display:block; }
/* Nothing to resize once the body is closed. */
.pindex[data-state="min"] .pindex-grip{ display:none; }

/* The affordance appears on approach and not before: a permanently drawn
   resize edge would put a second hairline alongside the head's divider and
   read as a seam in the frame. */
.pindex-grip::after{
  content:''; position:absolute; left:50%; margin-left:-13px;
  width:26px; height:2px; border-radius:1px;
  background:var(--rule-strong); opacity:0; transition:opacity .15s ease;
}
.pindex-grip[data-edge="top"]::after{ top:3px; }
.pindex-grip[data-edge="bottom"]::after{ bottom:3px; }
.pindex-grip:hover::after,
.pindex-grip.is-dragging::after{ opacity:1; }
.pindex-grip:focus{ outline:none; }
.pindex-grip:focus-visible::after{ opacity:1; background:var(--ink); }

/* No easing while a drag is in flight. A 280ms transition on the body means
   the edge arrives a third of a second after the pointer does, which is read
   as stutter rather than as smoothing. */
.pindex.is-resizing .pindex-scroll{ transition:none; }
.pindex.is-resizing .pindex-sub{ transition:none; }

/* ---- Header: title + entry point, present in every state ---- */
.pindex-head{
  display:flex; align-items:center; gap:10px;
  width:100%; margin:0; padding:11px 14px;
  appearance:none; background:none; border:0; cursor:pointer;
  text-align:left;
  /* inset, so the divider appearing on expand costs no layout */
  box-shadow:inset 0 0 0 0 var(--rule);
  position:relative;   /* containing block for the glow strip on that divider */
  transition:box-shadow .2s ease;
  -webkit-tap-highlight-color:transparent;
}
.pindex[data-state="open"] .pindex-head{ box-shadow:inset 0 -1px 0 0 var(--rule); }
/* Opening upward puts the head under the list, so its divider moves to the
   edge the list is actually on. */
.pindex[data-align="bottom"][data-state="open"] .pindex-head{ box-shadow:inset 0 1px 0 0 var(--rule); }
.pindex-eyebrow{
  font-family:var(--font-mono); font-weight:500; font-size:10px;
  letter-spacing:.2em; text-transform:uppercase; color:var(--muted);
  white-space:nowrap; transition:color .15s;
}
.pindex-caret{
  flex:none; display:block; margin-left:auto; color:var(--muted);
  transition:transform .24s ease, color .15s;
}
/* The caret points where the list will appear, so it follows the fold. */
.pindex[data-state="min"] .pindex-caret{ transform:rotate(-90deg); }
.pindex[data-align="bottom"][data-state="min"] .pindex-caret{ transform:rotate(90deg); }
.pindex-head:hover .pindex-eyebrow,
.pindex-head:hover .pindex-caret{ color:var(--ink); }

/* ---- Structure: revealed on expand ---- */
/* THE BODY HAS ONE HEIGHT AND KEEPS IT.
 *
 * It used to be `max-height`, so the frame grew and shrank as each section's
 * sub-list opened and closed — on Nodo that is a 170px box at the top of the
 * page and a 401px box inside Design, resizing at every section boundary in a
 * component that never moves otherwise. The height was doing the work of
 * showing you where you are, and it was the most distracting thing on screen.
 *
 * Now sizeBody() writes one figure into --pindex-body and the frame keeps it;
 * bringing the current section into view is the LIST's job, by panning inside
 * the frame (see frameActive). The figure is the height of the outline with
 * every section collapsed, so the frame is exactly full at rest rather than a
 * tall box with air in it — clamped, in sizeBody, to the room the alignment
 * actually leaves and to 520px. */
.pindex-scroll{
  height:var(--pindex-body, 320px);
  padding:0 14px;
  overflow-y:auto; overscroll-behavior:contain;
  scrollbar-width:none;
  transition:height .28s ease, opacity .2s ease;
  scroll-behavior:smooth;
}
.pindex-scroll::-webkit-scrollbar{ width:0; }
@media (prefers-reduced-motion: reduce){
  .pindex-scroll{ scroll-behavior:auto; transition:opacity .2s ease; }
  .pindex-sub{ transition:none; }
}
.pindex[data-state="min"] .pindex-scroll{
  height:0; opacity:0; overflow:hidden; pointer-events:none;
}

.pindex-list{ list-style:none; margin:0; padding:12px 0; position:relative; display:none; }
.pindex-list.is-active{ display:block; }
/* One spine, every node on it. Always --rule: it is structure, not state.
   It runs from the first node's centre to the LAST node's centre and stops
   there — a line that overshoots into the padding reads as an unfinished
   list. Both ends are measured (see fitSpine) because the last node is the
   last child of the active section when one is expanded, and the last section
   itself when none is: no static offset can express that, and a CSS cover
   painted inside .pindex-sub is clipped by its own overflow:hidden. */
.pindex-list::before{
  content:""; position:absolute; left:3px;
  top:var(--spine-top, 24px); height:var(--spine-h, 0);
  width:1px; background:var(--rule);
}

.pindex-item{ position:relative; }
.pindex-item > a{
  position:relative;
  display:flex; align-items:flex-start; gap:12px;
  padding:5px 0; text-decoration:none; color:var(--muted);
  transition:color .15s;
  -webkit-tap-highlight-color:transparent;
}
/* ---- Dot alignment ----
   Rows are align-items:flex-start so a dot tracks the FIRST line of a label
   that wraps, rather than drifting to the middle of a two-line block. That
   makes the top margin load-bearing, and it is not an eyeballed nudge:

       margin-top = capCentre - dotSize / 2

   where capCentre is the centre of the CAPITALS within the first line box, not
   the centre of the line box itself. The two are not the same — a line box
   includes descender space the uppercase labels never use, so centring on it
   parks every dot low. It matters most in the body-font sub-rows: at d1 the cap
   centre sits 7.81px down a 16.1px line, while the line centre is 8.05px.
   Recompute these four values if a font-size or line-height here changes. */
.pindex-node{
  flex:none; width:7px; height:7px; border-radius:50%;
  margin-top:3.61px;                      /* 9.5px / 1.5 line / 7px dot */
  /* Every dot is solid. The three states are a brightness ladder rather than
     an outline/fill split, so the rail reads as a row of marks of varying
     weight instead of a mix of two different kinds of object:

       unread  --rule-strong   dull, present
       read    --muted         brighter — you have been past it
       active  --palette-ink   hue, not just weight

     Fill and ring move together at every step, so a dot is never a dim centre
     inside a brighter edge. */
  background:var(--rule-strong);
  box-shadow:0 0 0 1px var(--rule-strong);
  transition:background .15s, box-shadow .15s;
}
.pindex-num{
  flex:none; font-family:var(--font-mono); font-size:9.5px; font-weight:500;
  letter-spacing:.1em; color:var(--muted); margin-right:-4px;
  transition:color .15s;
}
.pindex-item.is-active > a .pindex-num{ color:var(--palette-ink, var(--ink)); }
.pindex-label{
  font-family:var(--font-mono); font-size:9.5px; font-weight:500;
  letter-spacing:.14em; text-transform:uppercase; line-height:1.5;
}
.pindex-item > a:hover{ color:var(--ink); }
/* Hover fills the dot rather than ringing it. A hover that only changed the
   box-shadow read as a stroke drawn around an unchanged dull centre — two
   colours in one 7px mark — which is the exact "dim centre inside a brighter
   edge" the state ladder above rules out. Fill and ring move together here
   too, so hover is simply the dot at a brighter weight. */
.pindex-item > a:hover .pindex-node{
  background:var(--ink);
  box-shadow:0 0 0 1px var(--ink);
}
.pindex-item.is-active > a{ color:var(--ink); }
.pindex-item.is-active > a .pindex-node{
  background:var(--palette-ink, var(--ink));
  box-shadow:0 0 0 1px var(--palette-ink, var(--ink));
}
/* There is deliberately NO "already passed" state.
   The rail used to brighten every section above the active one, which drew the
   eye backwards up the list — the sections you have finished with became the
   loudest thing in the component after the marker itself. Position in the list
   already says what came before; the dots only need to say where you ARE. So
   every dot is the same dull --rule-strong and exactly one of them carries the
   palette. Do not reintroduce a read state without a reason that survives
   looking at it. */

/* Sub-levels mirror the page's own type ladder — section-title 44, lg 33,
   md 26, sm 20 — so the rail expresses the same hierarchy the page does.
   Every node stays on the one spine; depth is carried by node size, label
   indent and type scale, which keeps a 4-deep rail readable at 204px. */
.pindex-sub{
  list-style:none; margin:0; padding:0;
  max-height:0; opacity:0; overflow:hidden;
  transition:max-height .26s ease, opacity .18s ease, margin .26s ease;
}
.pindex-item.is-active > .pindex-sub{
  max-height:1600px; opacity:1; margin:1px 0 8px;
}
.pindex-sub li{
  display:flex; align-items:flex-start; gap:12px;
  padding:3.5px 0; color:var(--muted);
  transition:color .15s;
}
.pindex-subnode{
  flex:none; width:5px; height:5px; border-radius:50%;
  margin:5.31px 1px 0 1px;                /* 11.5px / 1.4 line / 5px dot;
                                             the 1px sides keep the 7px
                                             footprint level one has, so every
                                             dot still sits on the one spine */
  background:var(--rule-strong);          /* solid, see .pindex-node */
  box-shadow:0 0 0 1px var(--rule-strong);
  transition:background .15s, box-shadow .15s;
}
/* Two half-width blocks are ONE entry: they occupy one scroll position, and
   stacking them as separate rows would imply a sequence that is not there.
   One node, both titles, joined by the slash the breadcrumb already uses. */
.pindex-subrow{
  flex:1; min-width:0; padding-left:9px;
  font-family:var(--font-body); font-weight:400; font-size:11.5px; line-height:1.4;
}
.pindex-sub li.d2 .pindex-subrow{ padding-left:20px; font-size:11px; }
.pindex-sub li.d3 .pindex-subrow{ padding-left:30px; font-size:10.5px; }
.pindex-sub li.d2 .pindex-subnode{ width:4px; height:4px; margin:5.64px 1.5px 0; }
.pindex-sub li.d3 .pindex-subnode{ width:3px; height:3px; margin:5.48px 2px 0; }

/* Tier luminance. Each depth steps down slightly in brightness on top of the
   node-size and indent cues, so the hierarchy still reads at a glance where a
   long label wraps and the indent is no longer obvious. Opacity rather than a
   fixed colour per tier: it tracks both themes with one value, and it dims the
   node along with its label so the two never disagree. Active and hover return
   to full — state has to outrank depth. */
.pindex-sub li.d1{ opacity:.88; }
.pindex-sub li.d2{ opacity:.76; }
.pindex-sub li.d3{ opacity:.66; }
.pindex-sub li.is-active,
.pindex-sub li:hover{ opacity:1; }
.pindex-subrow a{
  color:inherit; text-decoration:none; transition:color .15s;
  -webkit-tap-highlight-color:transparent;
}
.pindex-subsep{ color:currentColor; opacity:.4; padding:0 5px; }
/* The row is one entry, so it hovers and lights as one. Never highlight a
   single title inside a merged pair: that would re-draw the split the merge
   exists to remove. */
.pindex-sub li:hover{ color:var(--ink); }
.pindex-sub li:hover .pindex-subnode{   /* filled, see .pindex-node:hover */
  background:var(--ink-soft);
  box-shadow:0 0 0 1px var(--ink-soft);
}
.pindex-sub li.is-active{ color:var(--ink); }
.pindex-sub li.is-active .pindex-subnode{
  background:var(--palette-ink, var(--ink));
  box-shadow:0 0 0 1px var(--palette-ink, var(--ink));
}


/* ============================================================
   CURSOR GLOW ON LINES
   ============================================================
   The pointer casts light into the page's grey lines: a soft pool of a dim
   grey that follows the cursor and falls off with distance in BOTH axes, so a
   line 200px from the cursor is barely touched and one under it is lit
   through.

   THIS SECTION IS LAST IN THE FILE ON PURPOSE. It decorates .pindex, .ptabs,
   .block-list and the video controls, all of which are defined further up, and several of the
   lines it lights are painted with `background` shorthands that would reset a
   background-image set before them. Source order is the whole mechanism; do
   not move it.

   ---- How one pair of coordinates lights every line on the page ----

   The glow is a radial-gradient positioned `at var(--mx) var(--my)` — the
   pointer in VIEWPORT coordinates, written to :root by rule-glow.js — and
   every element that uses it sets `background-attachment:fixed`.

   Fixed attachment moves the background's positioning area from the element to
   the viewport. That is what makes this cheap: one pair of viewport-space
   coordinates is correct for every lit edge at once, so there is no
   per-element geometry to measure, nothing to cache, nothing to invalidate on
   resize, and no work proportional to how many lines are on screen.
   rule-glow.js writes two custom properties per frame and the compositor does
   the rest.

   It also makes scrolling behave the way the metaphor demands: the light
   source is pinned to the viewport, so lines slide THROUGH the pool as the
   page moves and light up as they cross it, with no scroll handler at all.

   ---- Why the masks ----

   A gradient can only live in a background, and half the lines on this site
   are not backgrounds: .block-list and .pindex draw their frames with
   `box-shadow:0 0 0 1px`, .block-list-item and .ptabs with `border`. Neither a
   border nor a box-shadow can hold a gradient, and `border-image` — which
   can — is sized to the element's own border box, so its copy of the gradient
   would be centred per element rather than in the viewport, which is exactly
   what breaks.

   So the shape is carved with a mask instead. The overlay is a plain box
   filled with the viewport-space gradient, and the mask — which IS in element
   space — keeps only the 1px band that lies over the real line. Background in
   viewport space, mask in element space, composited together.

   The lines themselves are never replaced. Every rule below paints ON TOP of a
   border or shadow that is still doing its own job, so with the glow off,
   unsupported, or masked out by prefers-reduced-motion, the page is exactly
   what it was.

   ---- --ring and --sep ----

   A list block draws different lines in each of its four data-lines modes, and
   an overlay that guessed at them would eventually disagree with the real
   border. So it does not guess: every rule in the LIST BLOCK section that
   draws a line sets --sep:1 or --ring:1, and every rule that kills one sets it
   to 0, in the same declaration block as the border it is talking about. The
   overlays below just read that. Add a mode and the glow follows it as long as
   the marker is set next to the border, which is the only thing to remember.

   ---- Colour ----

   --glow-ink is a theme token, not the palette accent: this is the line
   itself lifting under the cursor, so it stays the line's own family of grey.
   See the theme blocks near the top for why light and dark move in opposite
   directions.

   ---- Gating ----

   On (hover:hover) and (pointer:fine), like every other rollover in this file:
   there is no cursor to cast light on a touchscreen, and
   background-attachment:fixed is the one declaration mobile browsers are most
   likely to mishandle. Off again under prefers-reduced-motion — this is
   motion, and it tracks the pointer.
   ------------------------------------------------------------ */

/* Registered so it can be TRANSITIONED. A plain custom property animates as a
   discrete swap — the glow would pop on and off rather than fade. */
@property --glow-a{
  syntax:'<number>';
  inherits:true;
  initial-value:0;
}

@media (hover:hover) and (pointer:fine){
  :root{
    --glow-a:0;
    transition:--glow-a .22s ease-out;
  }
  html.glow-lit{ --glow-a:1; }

  /* ---- 1. Elements that ARE the line ----
     One pixel tall already, so there is nothing to mask: the gradient goes
     straight into background-image over the flat background-color. */
  .block-divider hr,
  .section-kicker .kicker-rule,
  .block-text-body hr,
  .pindex-list::before{           /* the rail's vertical spine — 1px WIDE */
    background-image:var(--glow);
    background-attachment:fixed;
    background-repeat:no-repeat;
  }

  /* ---- 2. Frames drawn with box-shadow ----
     inset:0 with padding:1px gives an outer box and a content box exactly one
     pixel apart; masking one out of the other leaves the frame band.

     THE LIT PIXEL IS THE ONE INSIDE THE HAIRLINE, not the hairline itself, and
     that is deliberate. `box-shadow:0 0 0 1px` with no inset paints OUTSIDE
     the border box, and both of these components clip to it with
     overflow:hidden — which they need, to hold their cells and their scroll
     inside a rounded frame. So inset:-1px, which would land the band exactly
     on the shadow, is clipped away to nothing.

     The alternative was to move both rings to `inset 0 0 0 1px` so the drawn
     frame comes inside the border box where the overlay can reach it. That
     shrinks each component's visible footprint by 2px, and a list block is
     routinely paired with a fixed-aspect video in a .block-row — it would
     stop matching its neighbour's width. Not worth one pixel.

     What it renders as: the grey hairline with a lit pixel immediately inside
     it, which reads as the frame glowing rather than as two lines. */
  .block-list::after,
  .pindex::after,
  .proj-stats::after{
    /* Lands one pixel inside the frame, on the page ground rather than on
       the line — both lift toward the same ink, so no second source. */
    content:''; position:absolute; inset:0; pointer-events:none;
    border-radius:inherit;
    padding:1px;
    background:var(--glow);
    background-attachment:fixed;
    opacity:var(--ring, 1);
    -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
            mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite:xor;
            mask-composite:exclude;
  }

  /* ---- 3. Single edges drawn with border or inset shadow ----
     A 1px strip laid over the real edge. left/right rather than a width so it
     tracks the element at every shell width. */
  .block-list-item::before,
  .proj-stats .col::after,
  .ptabs::after,
  .pindex[data-state="open"] .pindex-head::after{
    content:''; position:absolute; left:0; right:0; height:1px;
    pointer-events:none;
    background:var(--glow);
    background-attachment:fixed;
  }
  /* top:-1px, not 0: for a bordered box an absolute child's 0 is the PADDING
     box, which is already inside the border it is meant to sit on. */
  .block-list-item::before{ top:-1px; opacity:var(--sep, 1); }
  /* Off unless a breakpoint switches it on: at full width the stats box has
     no horizontal rules at all, only column dividers. */
  .proj-stats .col::after{ top:-1px; opacity:var(--sep-h, 0); }
  /* bottom:0 IS the line now. .ptabs draws its rule as an inset shadow on its
     last padding row rather than as a border-bottom, precisely so this strip
     can reach it — a scroll container clips at the padding box, so a border
     was permanently out of reach and this sat one pixel above it. */
  .ptabs::after{ bottom:0; }
  /* ---- 4. Vertical single edges ----
     The stats box divides its columns with border-right. right:-1px, for the
     same reason the horizontal strips use top:-1px: an absolute child's 0 is
     the padding box, which is already inside the border it has to sit on.
     Not clipped by the box's overflow:hidden — the divider it covers is
     between two columns, so the strip is still inside the frame. */
  .proj-stats .col::before{
    content:''; position:absolute; top:0; bottom:0; right:-1px; width:1px;
    pointer-events:none;
    background:var(--glow);
    background-attachment:fixed;
    opacity:var(--sep-r, 1);
  }

  /* The head's divider is an inset shadow, so it sits inside the box — and it
     swaps to the top edge when the rail opens upward. Follow it. */
  .pindex[data-state="open"] .pindex-head::after{ bottom:0; }
  .pindex[data-align="bottom"][data-state="open"] .pindex-head::after{
    bottom:auto; top:0;
  }

  /* ---- 5. Media frames — ON HOVER ONLY ----
     Gallery blocks, video blocks and the project cover, whose rollover is
     defined with the rest of the media stroke in the GALLERY CONSTRUCT
     section. Colour from --media-glow, not --glow: see the token.

     THE ONE GLOW HERE THAT IS GATED ON :hover. Every rule above lights its
     line whenever the pointer is on the page, because those lines are page
     furniture and the light is ambient. A frame around someone's work is not
     furniture — it is the rollover affordance for that piece — so its light
     arrives with the rollover and the resting frame is exactly what it was.

     THE LIT PIXEL IS THE ONE INSIDE THE HAIRLINE, for a harder reason than in
     2 above. All three surfaces are overflow:hidden — they have to be, to hold
     media inside a rounded corner — so a band at inset:-1px, sitting exactly
     on the border, is clipped away to nothing before it paints. inset:0 is the
     padding box, one pixel in. At --radius:2px the lit pixel is touching the
     stroke, so the two read as one 2px line lifting under the cursor rather
     than as a line with a second line beside it.

     border-radius:inherit rather than calc(var(--radius) - 1px), which is the
     geometrically exact inner corner: at a 2px radius the difference is a
     fraction of one pixel in four corners, and inherit follows the box through
     fullscreen and any future radius with nothing to keep in sync.

     ABOVE the video controls (z-index:2) rather than under them. This is the
     frame around everything the player holds, the controls included, and the
     controls reach the bottom edge — at z-index:auto the bar would cut the
     bottom of the ring. It is a 1px band with no pointer-events, so nothing
     about the controls changes. */
  .block-gallery .gal-viewport::after,
  .block-video .vid-viewport::after,
  .proj-cover::after{
    content:''; position:absolute; inset:0; z-index:3;
    pointer-events:none;
    border-radius:inherit;
    padding:1px;
    background:var(--media-glow);
    background-attachment:fixed;
    /* .15s is the frame's own border-color transition, so the light and the
       colour arrive together and leave together. The gradient keeps tracking
       the pointer while this fades — --glow-a is a separate, longer fade for
       the pointer leaving the WINDOW, which is a different event. */
    opacity:0; transition:opacity .15s;
    -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
            mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite:xor;
            mask-composite:exclude;
  }
  .block-gallery .gal-viewport:hover::after,
  .block-video .vid-viewport:hover::after,
  .proj-cover:hover::after{ opacity:1; }

  /* No frame, no glow. Both of these zero the border up in GALLERY CONSTRUCT;
     the light has to follow, or it draws a ring around a borderless box —
     which for fullscreen means a hairline around the whole screen, the exact
     thing that rule exists to prevent. */
  .block-gallery.no-stroke .gal-viewport::after,
  .vid-viewport.is-fullscreen::after,
  .vid-viewport:-webkit-full-screen::after,
  .vid-viewport:fullscreen::after{ display:none; }

  /* ---- 6. ONTO the line, for the boxes that clip ----
     Everything above lands exactly on the line it lights, with one family of
     exceptions: six boxes that clip their own contents and draw their frame
     where the clip cannot reach. Their overlays were pinned one pixel off —
     inside the frame for the five rings, and that was visible.

     THE CLIP IS THE WHOLE PROBLEM. An absolutely positioned child is laid out
     against its ancestor's PADDING box, and overflow clips there too, so a
     frame drawn anywhere outside that box is unreachable from within it:
       - .block-list, .pindex and .proj-stats ring themselves with
         `box-shadow:0 0 0 1px`, which paints OUTSIDE the border box;
       - the three media surfaces use `border:1px`, which with border-box
         sizing occupies the outermost row INSIDE it.
     Either way the overlay's furthest reach, inset:0, stopped one pixel short.

     overflow:clip takes a margin, and that is the whole fix: one pixel of
     clip-margin extends each box's clip edge from the padding box out to
     exactly where its frame is drawn, and the overlay follows with inset:-1px.
     Nothing about the boxes themselves moves — same border box, same frame in
     the same pixels, same layout. Only the reach of the clip changes, and only
     where a glow is possible at all.

     WHY IT IS SAFE HERE: a clip margin lets contents paint into that pixel
     too. None of these six have anything to put there — cells and media are
     inset by the frame they sit in, and the gallery's off-screen slides start
     at the padding edge, so the margin is exactly the width of the frame that
     covers them. Tested; see the note on .gal-viewport if that ever changes.

     overflow:clip is `hidden` without scrollability, and none of the six is
     scrolled: the rail's scrolling belongs to .pindex-scroll inside it, which
     is untouched. The tab bar IS scrolled and so cannot use any of this —
     it moves its rule inside the clip instead, up in the PROJECT TABS section.

     @supports, and confined to the hover block, so a browser without
     overflow-clip-margin keeps precisely what it has today rather than losing
     the glow: the fallback is the old one-pixel-inside placement, which is
     wrong by a pixel but present. */
  @supports (overflow-clip-margin:1px){
    .block-list,
    .pindex,
    .proj-stats,
    .block-gallery .gal-viewport,
    .block-video .vid-viewport,
    .proj-cover{ overflow:clip; overflow-clip-margin:1px; }

    /* Out onto the frame. The radius has to grow with the box: a spread shadow
       rounds at the element's radius PLUS its spread, and inset:-1px is a box
       one pixel larger on every side, so both want --radius + 1px. */
    .block-list::after,
    .pindex::after,
    .proj-stats::after{
      inset:-1px;
      border-radius:calc(var(--radius) + 1px);
    }
    /* The media frames are already drawn at the border box, which is what
       inset:-1px spans and what the element's own border-radius describes —
       so inherit is exact here rather than one pixel generous. */
    .block-gallery .gal-viewport::after,
    .block-video .vid-viewport::after,
    .proj-cover::after{ inset:-1px; }

    /* ---- The frame moves ON TOP of the work ----
       The clip margin opens one pixel of the frame's row to the contents, and
       a child paints over its parent's border. Most of these have nothing out
       there, but a GALLERY does: its slides sit in a flex row wider than the
       viewport and slid by transform, so the next slide begins exactly at the
       padding edge — precisely the pixel the margin opens. Left alone, every
       gallery wears a one-pixel sliver of the NEXT PHOTO down its right-hand
       border. Measured, not theorised: a red slide two painted 255,0,0 along
       that column.

       So the frame is redrawn above the contents. The border stays for its
       geometry — it is what keeps the media inset, and what .vc measures its
       gap from — but hands its colour to a ring that paints after the
       children, which is where a frame around a piece of work belongs anyway.
       A plain 1px border on a box at inset:-1px IS that ring; no mask, because
       unlike the glow it is a flat colour, and a border can hold one.

       Uniform across all three rather than special-casing the gallery: they
       share every other rule, one frame that always paints above the work is
       simpler to hold in mind than two, and a video gains the same protection
       the day anything is ever positioned at its edge. */
    .block-gallery .gal-viewport,
    .block-video .vid-viewport,
    .proj-cover{ border-color:transparent; }
    .block-gallery .gal-viewport::before,
    .block-video .vid-viewport::before,
    .proj-cover::before{
      content:''; position:absolute; inset:-1px; z-index:2;
      pointer-events:none;
      border:1px solid var(--media-stroke);
      border-radius:inherit;
      transition:border-color .15s;   /* the frame's own, now living here */
    }
    .block-gallery .gal-viewport:hover::before,
    .block-video .vid-viewport:hover::before,
    .proj-cover:hover::before{ border-color:var(--media-stroke-hover); }

    /* The frame follows the border it replaced wherever that border is zeroed,
       or a hairline outlives the box it framed. */
    .block-gallery.no-stroke .gal-viewport::before,
    .vid-viewport.is-fullscreen::before,
    .vid-viewport:-webkit-full-screen::before,
    .vid-viewport:fullscreen::before{ display:none; }
  }

  /* ---- 7. The player's own edges ----
     The control base and the volume panel. Same masked band as 2 and 5,
     same white ink as 5 but its own copy of the light,
     for exactly the reason given at --media-glow: these sit on a wash of black
     over whatever the footage happens to be, which is not a ground this
     stylesheet chose, and white is the only ink that behaves on one it did
     not choose. The strokes themselves are rgba(0,0,0,.2) and stay put; this
     paints over them the way every rule above paints over its line.

     REUSING --media-glow RATHER THAN DECLARING A THIRD GRADIENT is the point
     and not a saving. This is ONE player. The frame around it already lights
     from that token in 5, the frame is a pixel from the bar at the bottom of
     the picture, and a bar lit by a second slightly different light would read
     as two light sources on one object. One pool crosses the frame and the
     controls together, which is what a glint is.

     So the strength dial is an OPACITY on the band, not an alpha of its own:
     --media-glow folded --media-glow-alpha in back on :root, where it is
     declared, and a custom property is substituted where it is declared rather
     than where it is used — the same rule that forced --media-glow to be a
     second gradient instead of a re-tint of --glow. Scaling the finished
     gradient is the only dial available from down here, and it is enough.

     NOT GATED ON :hover, unlike 5, and it needs no gate. These elements are
     only on screen while the pointer is over the player: .vc-in fades 0 -> 1
     with .vc.is-visible and the band is inside it, so the glint arrives with
     the controls, leaves with them, and does it on their timing rather than a
     second one that could drift out of step. Nothing here has to know that.

     NOT THE TIMEBAR, though it carries the same stroke and did glint at
     first — taken off after seeing it, not overlooked. Do not add
     .vc-track::after back without asking. It is an 8px box whose whole job is
     to read as a filled gauge, and a light running round its outline competed
     with the fill for the same glance; the bar underneath is furniture and can
     take an ambient light, a gauge cannot. Everything else in this rule still
     applies to it unchanged if that judgement ever reverses: it is border-box,
     it does not clip, and its ::before is only the hit target.

     GEOMETRY. The negative inset is the border ROW. Both are border-box, so
     an absolutely positioned child's 0 is the padding box, which is already
     one pixel inside the stroke it is meant to sit on — the same correction
     the strips in 3 and 4 make with top:-1px. Unlike the six boxes in 6,
     neither of these clips its contents, so the band reaches its own border
     with no clip-margin and nothing about the boxes moves.

     --vc-border rather than a literal 1px, in both the inset and the padding,
     so the band cannot come unstuck from the hairline it is tracing. It is 1
     at every tier today, fullscreen included, which is why the export's 2px
     stroke halves to it.

     border-radius:inherit is exact here rather than a pixel generous: the band
     spans the border box, which is what the element's own radius describes.

     ::after specifically, and absolutely positioned — which is what keeps it
     from becoming a flex item of .vc-row, which is a flex container. It needs no z-index: ::after is generated last, so it
     already paints over everything either box holds.

     THE ONE PLAYER THIS CANNOT REACH is the gallery LIGHTBOX. A fixed
     background resolves against the nearest ancestor that establishes a
     containing block for fixed descendants, and .gal-lb-track carries
     `will-change:transform` for its swipe — measured, that captures a fixed
     background exactly as a real transform does. So a lightbox player's
     coordinates land against the track, which is itself translated by a
     viewport width per slide, and the pool is simply somewhere else. It
     degrades to no glint rather than to a wrong one, which is the same place
     light theme and reduced motion land, so it is left alone: dropping the
     will-change to buy this back would trade the swipe's smoothness for a
     highlight. container-type on .vc does NOT capture it — that was measured
     too, and is why the bar works at all.

     THE BUTTONS ARE NOT HERE BECAUSE THEY HAVE NO EDGE. mk3 draws play, sound
     and fullscreen as a radius and a backdrop-filter with no stroke at all, so
     there is no line for the pointer to catch. If one is ever added, add the
     selector here, to the reduced-motion list and to OFF IN LIGHT. */
  /* ---- 8. The bar's SURFACE, not only its edge ----
     A soft pool of the same light across .vc-row's fill, so the backer lifts
     under the pointer instead of only its outline doing so. Strength is
     --vc-lift; everything about the light itself is shared with 7, which is
     --vc-glow rather than the --media-glow both rules first used.

     ::before, not a second ::after, and the paint order is the point: a
     ::before is generated as the FIRST child, so this lands under the
     timecode and the three icons — which is where a backer belongs. They keep
     their flat #fff and their full contrast against a lifted ground.
     Absolutely positioned, so it is not a flex item of .vc-row.

     inset:0 IS THE FILL AREA, and it is deliberately not the border box. An
     absolutely positioned child resolves against the padding box, so 0 stops
     exactly where the stroke begins — the pool and the edge band in 7
     therefore cover disjoint pixels and cannot compound into a double-bright
     rim. 7 reaches its own row with a negative inset for this same reason.

     border-radius:inherit is a fraction of a pixel generous here rather than
     exact: the true inner corner is --vc-radius minus the border, which at a
     1px radius and a 1px border is 0. Following the box beats keeping a calc
     in sync for a quarter of a pixel in four corners — the same trade the
     media frames make in 5.

     NO MASK, unlike every rule above it. Those all had to carve a hairline
     out of a box; this one wants the whole box, so the gradient goes straight
     in and the mask machinery is simply absent. It is the only rule in this
     section that paints a FIELD rather than a LINE, which is also why it
     needs its own much lower dial — see --vc-lift.

     No blend mode. .vc-in carries an opacity, which makes it a stacking
     context, so a mix-blend-mode here would compose against the bar rather
     than the picture and read differently at every player size. Plain alpha
     over the fill is predictable at all of them. */
  .vc-row::before{
    content:''; position:absolute; inset:0;
    pointer-events:none;
    border-radius:inherit;
    background:var(--vc-glow);
    background-attachment:fixed;
    opacity:var(--vc-lift, 0);
  }

  /* .vc-volpanel used to be listed here. It was a floating pill with its own
     --vc-edge border, and this drew the cursor glint along it. The panel is a
     bare inline container for the volume line now — no border, no background —
     so the ring had nothing to trace and would have hung in the air around the
     slider. The control row still lights. */
  .vc-row::after{
    content:''; position:absolute;
    inset:calc(var(--vc-border) * -1);
    padding:var(--vc-border);
    pointer-events:none;
    border-radius:inherit;
    background:var(--vc-glow);
    background-attachment:fixed;
    opacity:var(--vc-glint, 1);
    -webkit-mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
            mask:linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite:xor;
            mask-composite:exclude;
  }
}

/* ---- The active tab casts its own light along the bar ----
   Not part of the cursor effect, and deliberately NOT gated with it: this one
   is static, it marks page state rather than tracking a pointer, so it is
   present on touch and under prefers-reduced-motion like any other accent.

   No JS and no measurement. The pool hangs off the ACTIVE TAB itself, so it
   follows the marker to whichever tab is selected, at any width, through a
   relayout or a horizontal scroll of the bar, with nothing to keep in sync.
   .ptab stretches to the full height of .ptabs, so bottom:0 here is the same
   pixel row .ptabs::after uses for the cursor glow — the two land on the same
   line by construction rather than by a shared offset someone must remember.

   It spreads past its own tab on both sides and is clipped by the bar, which
   is the right extent for a light source sitting on that line.

   z-index:1 is load-bearing. .ptabs::after is a later sibling in tree order,
   so by default the cursor glow paints OVER this and mix-blend-mode would have
   nothing to blend with — the grey would simply cover the accent. Raised above
   it, this strip's backdrop includes the cursor glow and the blend combines
   the two. .ptabs is already a stacking context (sticky + z-index), so the
   blending stays inside the bar and never reaches the page behind it.

   The colour is --palette-ink, the same token the marker itself uses, so the
   cast light can never disagree with the thing casting it. color-mix rather
   than a hand-written alpha because strip.js hands this over as a whole
   colour, not as separate parts. */
.ptab.is-active::before{
  /* Measured OUTWARD FROM THE MARKER, never a fixed total width.

     The marker is as wide as its own label, and a fixed-width pool centred on
     the tab hides under it by however much the label happens to be long: at a
     200px pool the visible spill ran 80.8px on "WHY?" and 42.3px on "PITCH
     PACKET" — the same glow every time, but nearly twice as much of it showing
     on one tab as the other, which read as the effect being inconsistent
     between tabs. Sizing from the marker's edges makes the spill --tab-glow-
     reach on every tab regardless of the label.

     It is also the right physical model: a longer strip of light casts a
     longer pool, but the distance its light carries past the ends is a
     property of the medium, not of the strip.

     Three background layers rather than one radial gradient, because a radial
     gradient's radius is a share of the box and would go straight back to
     scaling with the label. The outer two are exactly --tab-glow-reach wide
     and hold the falloff; the middle is sized to what is left, which is the
     marker's own width, and sits at full strength under it. Layers rather than
     a background-color for the middle: a colour paints under the whole box and
     would fill the falloff strips back in. */
  --tab-glow-c:color-mix(in srgb,
      var(--palette-ink, var(--ink)) var(--tab-glow-alpha, 40.5%), transparent);
  --tab-glow-r:var(--tab-glow-reach, 70px);
  content:''; position:absolute; z-index:1;
  /* bottom:-1px, not 0. .ptab stretches to the bar's CONTENT box, and the rule
     is the padding row just below it (see .ptabs), so 0 would hang this one
     pixel above the line it is supposed to be running along — which is what it
     used to do. The scroll container's clip reaches the padding box, so -1px
     is inside it and paints. */
  bottom:-1px; height:1px;
  left:calc(var(--tab-glow-r) * -1);
  right:calc(var(--tab-glow-r) * -1);
  /* The falloff curve, written as explicit stops rather than an interpolation
     hint. A hint only moves the half-way point of what stays essentially one
     ramp: measured, that curve was still at 0.31 of full strength 42px out and
     0.15 at 56px, which spreads the light evenly across the whole reach and
     reads as a wash rather than as a glow with a centre.

     These stops decay faster than linear — roughly (1 - d/reach) squared — so
     the light has a bright core at the marker and thins quickly, keeping only
     a faint tail at the far end. Same reach, different distribution.

     Positions run from the OUTER end (0%) to the marker (100%), so the same
     stop list serves both sides: 90deg for the left strip, 270deg mirrors it
     for the right. The nested color-mix scales the alpha of --tab-glow-c
     without touching its hue, so every stop stays the palette colour and the
     whole curve still rides on --tab-glow-alpha. */
  --tab-glow-fade:
    transparent 0%,
    color-mix(in srgb, var(--tab-glow-c)  3%, transparent) 20%,
    color-mix(in srgb, var(--tab-glow-c) 14%, transparent) 40%,
    color-mix(in srgb, var(--tab-glow-c) 33%, transparent) 60%,
    color-mix(in srgb, var(--tab-glow-c) 61%, transparent) 80%,
    var(--tab-glow-c) 100%;
  background:
    linear-gradient(90deg, var(--tab-glow-fade))
      left / var(--tab-glow-r) 100% no-repeat,
    linear-gradient(var(--tab-glow-c), var(--tab-glow-c))
      center / calc(100% - var(--tab-glow-r) * 2) 100% no-repeat,
    linear-gradient(270deg, var(--tab-glow-fade))
      right / var(--tab-glow-r) 100% no-repeat;
  mix-blend-mode:var(--glow-blend, normal);
  pointer-events:none;
}

/* Motion off: no overlay is painted and no line is touched. The masked
   pseudo-elements are suppressed outright rather than left transparent, so
   nothing is composited for an effect that is switched off. */
@media (prefers-reduced-motion:reduce){
  .block-divider hr,
  .section-kicker .kicker-rule,
  .block-text-body hr,
  .pindex-list::before{ background-image:none; }
  .block-list::after,
  .pindex::after,
  .proj-stats::after,
  .block-list-item::before,
  .proj-stats .col::before,
  .proj-stats .col::after,
  .ptabs::after,
  .pindex-head::after,
  .gal-viewport::after,
  .vid-viewport::after,
  .proj-cover::after,
  .vc-row::before,
  .vc-row::after{ display:none; }
}


/* ---- OFF IN LIGHT ----
   Every glow in this section is dark-theme only: the cursor glint on the lines
   AND the pool the active tab casts along the bar. The solid tab marker is not
   a glow and stays — it is the page's state readout, not light.

   :not([data-theme="dark"]) rather than [data-theme="light"], because light is
   also what renders before theme.js has set the attribute at all, and the
   effect must not flash on in that window.

   Two mechanisms, deliberately. The tokens are the GUARANTEE: with both alphas
   at zero every colour in this section mixes to fully transparent, so a glow
   added here later is off in light without anyone remembering to list it. The
   selectors are the SAVING: a transparent gradient still costs its paint, and
   background-attachment:fixed is the expensive kind, so the overlays are taken
   out rather than left invisible. The list is the reduced-motion list above,
   and a test fails if the two ever diverge. */
html:not([data-theme="dark"]){
  --glow-alpha:0;
  --media-glow-alpha:0;
  --tab-glow-alpha:0;
}
html:not([data-theme="dark"]) .block-divider hr,
html:not([data-theme="dark"]) .section-kicker .kicker-rule,
html:not([data-theme="dark"]) .block-text-body hr,
html:not([data-theme="dark"]) .pindex-list::before{ background-image:none; }
html:not([data-theme="dark"]) .block-list::after,
html:not([data-theme="dark"]) .pindex::after,
html:not([data-theme="dark"]) .proj-stats::after,
html:not([data-theme="dark"]) .block-list-item::before,
html:not([data-theme="dark"]) .proj-stats .col::before,
html:not([data-theme="dark"]) .proj-stats .col::after,
html:not([data-theme="dark"]) .ptabs::after,
html:not([data-theme="dark"]) .pindex-head::after,
html:not([data-theme="dark"]) .gal-viewport::after,
html:not([data-theme="dark"]) .vid-viewport::after,
html:not([data-theme="dark"]) .proj-cover::after,
html:not([data-theme="dark"]) .ptab.is-active::before{ display:none; }

/* THE VIDEO CONTROLS ARE NOT IN THAT LIST, and are the one exception in this
   file. They were, and it was wrong: their ground is --vc-skin, the same rgba
   in both themes, so there is nothing here for light to mirror and nothing to
   switch off. They ride --vc-glow, which carries its own alpha and is
   therefore untouched by the three zeroes above — see that token for the
   substitution rule that makes a separate gradient the only way to do this.
   They are still gated on (hover:hover), on (pointer:fine), and on
   prefers-reduced-motion, which are the gates that actually apply. */
