/* ==========================================================================
   gmgm.media — one stylesheet, two pages, no build step.
   Order: font → tokens → reset → primitives → fold → portfolio.
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. Font
   Poppins, self-hosted, with Inter kept underneath it as the fallback.

   Poppins is the reference Roland asked for (acquisition.com). Worth knowing
   before touching this: acquisition.com *declares* `font-family: Poppins`
   everywhere and never loads it — there is no @font-face and no Google Fonts
   link on the site — so most of their visitors see a system fallback and only
   people with Poppins installed locally see the design. Matching the intent
   therefore means shipping the font ourselves, which is also the only option
   here: a CDN or Google Fonts tag would break the `file://` rule.

   Poppins is OFL-1.1, so self-hosting is a licence the project can hold.

   TEN files rather than Inter's two, because Google ships Poppins as static
   weights and not as a variable font. Only the weights the stylesheet actually
   asks for are here — 400/500/600/700/800, confirmed by grep, and the browser
   downloads only the ones a page renders. Two subsets each, same reasoning as
   Inter: latin-ext arrives only if a glyph needs it. All ten together are 65K
   against Inter's 202K, so this is smaller on the wire, not larger.

   No weight below 400 is supplied and none is used. If you ever set
   `font-weight: 100`, CSS font matching will resolve it to 400 rather than
   synthesising a thin — add the file instead of assuming it worked.

   Inter stays in --font-sans behind Poppins. It costs nothing at runtime (a
   browser downloads only fonts it needs glyphs from) and it means a single
   failed Poppins request degrades to a real typeface instead of Times.
   -------------------------------------------------------------------------- */

@font-face {
  font-family: "Poppins";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/poppins-400-latin.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: "Poppins";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("../fonts/poppins-500-latin.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: "Poppins";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("../fonts/poppins-600-latin.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: "Poppins";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("../fonts/poppins-700-latin.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: "Poppins";
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url("../fonts/poppins-800-latin.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: "Poppins";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("../fonts/poppins-400-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
    U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
    U+A720-A7FF;
}

@font-face {
  font-family: "Poppins";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("../fonts/poppins-500-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
    U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
    U+A720-A7FF;
}

@font-face {
  font-family: "Poppins";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("../fonts/poppins-600-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
    U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
    U+A720-A7FF;
}

@font-face {
  font-family: "Poppins";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("../fonts/poppins-700-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
    U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
    U+A720-A7FF;
}

@font-face {
  font-family: "Poppins";
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url("../fonts/poppins-800-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
    U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
    U+A720-A7FF;
}

@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("../fonts/inter-var-latin.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: swap;
  src: url("../fonts/inter-var-latin-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7,
    U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F,
    U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F,
    U+A720-A7FF;
}

/* --------------------------------------------------------------------------
   2. Tokens
   -------------------------------------------------------------------------- */

:root {
  /* gmgm.media — light only. Green (--brand) carries the band and the solid
     CTA; --primary stays near-black for text and rings. */
  --background: #ffffff;
  --foreground: #111113;
  --brand: #006039;
  /* Text sitting on the brand band. Pure white for the headline; the muted
     one carries the subheadline without competing with it. */
  --on-brand: #ffffff;
  --on-brand-muted: #b9d6c8;
  --muted: #f4f4f5;
  --muted-foreground: #6f6f76;
  --border: #e7e7e5;
  --ring: #111113;

  /* One corner radius for the whole site — the CTA's. Buttons, the back
     control, video boxes, grid tiles and chips all use it. */
  --radius: 10px;

  /* Poppins first, Inter behind it. This one line is the whole typeface
     switch: put "Inter" back in front to revert the site to how it read
     before 2026-08-13, or scope Poppins to the fold by moving it off this
     variable and onto .band__headline / .band__sub. */
  --font-sans: "Poppins", "Inter", ui-sans-serif, system-ui, -apple-system,
    "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

  /* Widths. The shell matches the o-vegas content wrapper; the inner caps
     keep line length readable inside it. */
  --shell: 1170px;
  --w-headline: 900px;

  /* Type scale. clamp() throughout — no breakpoint ladder.
     Raised across the board on 2026-08-14, headlines most: h1 was 36→64px and
     is now 44→80px, h2 was 28→40px and is now 32→48px. Body went to 17px,
     which is where it reads on a phone without becoming a large-print setting.
     The ratio between the steps is unchanged; the whole scale moved up. */
  --t-eyebrow: 0.8125rem;
  --t-micro: 0.9375rem;
  --t-body: 1.0625rem;
  --t-h1: clamp(2.75rem, 1.75rem + 4.2vw, 5rem);
  --t-h2: clamp(2rem, 1.6rem + 1.8vw, 3rem);

  /* Vertical rhythm. --gap-lg and --gap-xs were referenced by .prose for
     months without ever being declared, so every .prose h2 margin resolved to
     nothing and the privacy page ran as one undifferentiated block. */
  --gap-page: clamp(4rem, 3rem + 4vw, 10rem);
  --gap-xl: clamp(3rem, 2.4rem + 2.4vw, 4rem);
  --gap-lg: clamp(2rem, 1.6rem + 1.6vw, 2.75rem);
  --gap-md: 1.5rem;
  --gap-sm: 0.75rem;
  --gap-xs: 0.4rem;

  /* ---- The fold, which must fit one screen ----------------------------
     Everything here is capped against svh as well as vw. Width alone is not
     enough: a 1440x800 laptop and a 1440x1200 monitor are the same width and
     need different type. min() takes whichever limit binds first. */
  /* 4.2rem, not 4.25rem. .shell tops out at 1170px, so the headline's usable
     width stops at 1106px however wide the window gets — while this cap kept
     growing the type. At 4.25rem "HOW LONG DOES IT TAKE YOU TO" wants 1108px
     and lost by two, which re-broke Roland's line into three rows on every
     display wider than about 1430px and nowhere else. */
  /* Roland asked for this much bigger, so all three numbers are now set to the
     ceiling rather than to a comfortable value, and the ceiling was measured by
     sweeping the size and watching for the two things that actually break.

     5.4rem (86px) is the WIDTH limit. .shell stops at 1170px, so the headline
     never gets more than 1106px however large the display; the whole string is
     23.9em, and two rows need type no wider than twice that width over it —
     87px. One pixel more is a third row.

     The second term is the HEIGHT limit, and it is what binds on a laptop.
     Measured ceilings before the fold overflows: 58px at 760 tall, 58 at 768,
     64 at 800, 74 at 900, 83 at 982. Those do not sit on a line through the
     origin, so no plain svh value fits them — the ratio climbs from 7.6% to
     8.5% as the screen grows, because the buttons and the logo row are a fixed
     cost that a taller screen dilutes. A single multiplier tuned for 760px
     therefore throws away 6px at 900px, and one tuned for 900px overflows at
     760. Hence the offset form: it is the straight line those measurements
     actually lie on, and it tracks the ceiling to within a few px at every
     height. Re-measure it if anything else on the fold changes size.

     Net: 67px -> 73px on a 1440x900 laptop, and -> 86px on a large display.
     Not more, because at 900px the fold is simply full. Going properly bigger
     needs one of the three rules to give — a third row, a fold that scrolls,
     or a smaller video. The video is the one with room, at 378px of a 900px
     screen against the headline's 155px. */
  /* EVERY NUMBER HERE IS MEASURED. Do not raise one to make the headline
     bigger — it is already at the ceiling, and past it the headline silently
     takes a third row or the fold stops fitting one screen. Both have shipped.

     Two rules bind, and the tighter of the two wins at any given size:

     WIDTH, for Roland's two-row rule. .shell tops out at 1170px, so the
     headline never gets more than 1106px however wide the display. Two rows is
     NOT "half the string fits": the break has to land on a word boundary, and
     the best one is "HOW LONG DOES IT TAKE" / "YOU TO POST A VIDEO?", whose
     longer half is 12.4em rather than the 11.95em an even split implies. So the
     limit is avail/12.4 — about 4% tighter than it looks, which is how 7.4vw
     overshot at 768px by one pixel and bought a third row. Measured ceilings:
     54px at 768 wide, 74px at 1024, 86px from 1280 up. Hence 6.9vw and 5.4rem.

     HEIGHT, for the one-screen rule, and it only binds on short windows now.
     Measured: 76px at 700 tall, 80px at 720, 80px at 740, 84px at 760, and no
     limit at all by 900 — the width cap gets there first. Hence 10.6svh.

     A previous version read min(clamp(2.25rem, 7.9vw, 6.2rem), …) on the
     reasoning that raising only the clamp is safe because the svh cap still
     holds the fold. It is not: at 1920x1080 that resolves to 93px, which is
     past the 86px width ceiling, so the headline took THREE rows and the fold
     overflowed by 85px. The svh cap cannot protect a rule it does not measure.

     Re-measure all of it if --video-h, the sub, the buttons or the logo row
     change size. These ceilings are a property of the whole composition, not
     of the headline. */
  --fold-h1: min(clamp(2rem, 6.9vw, 5.4rem), 10.6svh);
  --fold-sub: min(clamp(1.15rem, 2vw, 1.75rem), 3.1svh);
  --fold-body: min(clamp(0.95rem, 1.15vw, 1.125rem), 2.3svh);

  /* Video height drives the whole composition: the band's spacer and the
     video's negative offset are both derived from it, so the colour break
     always lands at the same point on the video.

     34svh, down from the 42svh set on 2026-08-14 and up from the 29svh before
     that. 42 was the immediate cause of the headline having nowhere to grow:
     at 42svh the film takes 454px of a 1080px screen, and the fold's height
     ceiling for the headline fell to 70px at 1440x900 — BELOW what was already
     live. Roland's call was that the headline wins. At 34svh the height rule
     stops binding on any normal display and the headline is limited by width
     instead, which is worth 86px at 1440x900 against 73px before. The film is
     still materially bigger than it was this morning. */
  --video-h: min(34svh, 56vw);
  /* What the box will ACTUALLY be, once the width cap is taken into account.
     On a phone width binds first, and the overlap has to follow the real
     height or the colour break lands below the video instead of inside it. */
  --video-actual-h: min(var(--video-h), calc((100vw - 3rem) * 9 / 16));
  --video-overlap: calc(var(--video-actual-h) * 0.85);
}

/* --------------------------------------------------------------------------
   3. Reset + base
   -------------------------------------------------------------------------- */

*,
*::before,
*::after {
  box-sizing: border-box;
}

* {
  margin: 0;
}

html {
  color-scheme: light;
  -webkit-text-size-adjust: 100%;
}

/* Canvas colour, which fills both overscroll gutters when you drag the page
   past its end. Set on <html> rather than body because only the root propagates
   to the canvas, and per-page via a class because call.html, 404.html and the
   case studies are white at the top.

   ---- The strip behind the status bar: read this before touching either rule --

   It is white above the green band on an iPhone 17 Pro / iOS 26, and TWO fixes
   have already failed:

     1. This rule. The canvas colour used to tint that strip on older iOS. It
        does not any more.
     2. viewport-fit=cover plus env(safe-area-inset-top) padding, meant to put
        the page under the strip so .band paints it. Still white.

   KEEPING THIS RULE IS ABOUT OTHER BROWSERS, NOT SAFARI. Safari 26 ignores an
   html background for the strip and the gutters — see the long note on
   `html.brand-top body` below, which is what actually fixed it. This stays
   because it is still the correct canvas colour per CSS everywhere else.

   Also measured with debug.html on the device: env(safe-area-inset-top) is 0 on
   this phone, so viewport-fit=cover buys nothing here and anything sized to that
   variable is a no-op. The cover meta and the safe-area padding stay anyway —
   correct on devices that do report insets, free where the inset is 0 — but they
   are not what colours the strip either.

   The safe-area padding on .shell, .band, .fold__below, .call and .case is a
   separate concern from the strip. Note the (pointer: coarse) block re-declares
   padding-top for three of them, which is the easiest place to undo it by
   accident; check.py guards that. */
/* ---- CHANGED 2026-08-14: this is WHITE now, and the class stays ------------

   It was var(--brand), and it was painting a 1-2px green line along the bottom
   of /portfolio and / on both mobile and desktop. Roland saw it and it was
   real: html owns the canvas, body is white, and body's box does not always
   reach the end of the document. --gap-page is clamp(4rem, 3rem + 4vw, 10rem),
   which lands on a fractional pixel at most viewport widths, so the document
   ends mid-pixel and a sliver of green canvas shows under it.

   Setting the canvas white removes the only green that CAN show through, at
   any rounding, on any page. That is the right shape of fix for a bug whose
   exact sub-pixel cause is not worth chasing.

   WHAT THIS DOES NOT BREAK, and the note below says so at length: this rule was
   never what colours the Safari status-bar strip or the toolbars. Safari 26
   reads a fixed element at the edge, or body. The green at the top comes from
   .edge-tint, a real fixed element, and body stays white for the bottom. Both
   are untouched.

   WHAT IT COSTS: on non-Safari browsers, dragging past the TOP of the page now
   shows white in the overscroll gutter instead of green. That is the whole
   price, and it buys a line nobody sees at the bottom of every page.

   The class is kept rather than deleted — .edge-tint is scoped to it, and that
   is the rule doing the real work. */
html.brand-top {
  background: var(--background);
}

/* THE STATUS-BAR STRIP. Safari 26 reads BODY's background-color for it.
   Not html's. Not theme-color. Body's.

   Safari 26 replaced the old behaviour entirely: <meta name="theme-color"> is
   ignored, and so is the <html> background that CSS says owns the canvas. The
   tint for the strip behind the status bar — and for the overscroll gutters —
   comes from a position:fixed or :sticky element near that edge if there is one,
   and otherwise from the background-color of <body>.

   That single fact explains every failed attempt on this:

     html.brand-top green      ignored, because it is on html
     viewport-fit=cover        irrelevant; env(safe-area-inset-top) is 0 on the
                               device anyway, measured with debug.html
     theme-color green         ignored outright by Safari 26
     a green body::before      not picked up; the scan wants a real element
     body white                <- THIS is what was painting the strip, all along

   It also explains the overscroll gutter coming back white while html was
   green, which is the observation that should have given it away sooner.

   Body therefore stays WHITE, which is what gives the bottom toolbar and both
   overscroll gutters their white — and the green for the top comes from the
   fixed element below instead. Making body green tinted both toolbars at once,
   because Safari samples each edge separately and body is the fallback for
   both.

   Two things were established the expensive way and are worth not repeating:

     * The scan ignores PSEUDO-ELEMENTS. A green body::before at top:0 did
       nothing, which is why .edge-tint is real markup in the three pages.
     * The scan ignores elements it cannot see. The same strip at z-index:-1,
       tucked behind main, was not sampled either — so this cannot be hidden
       behind content to avoid the scroll artifact noted below.

   Sources, since Apple documents none of this:
     https://nasedk.in/blog/ios26-safari-toolbar-colors/
     https://1ar.io/updates/safari-26-liquid-glass-web/

   One more trap from there: Safari samples at INITIAL RENDER and does not
   re-sample when JavaScript changes a colour. And a semi-transparent background
   samples unpredictably — keep every colour in this section solid. */

/* The green for the status-bar strip. Fixed, full width, solid, and visible —
   every one of those is load-bearing, see above.

   At rest it sits exactly on top of .band, green on green, so it cannot be seen
   on any of these three pages. The one cost is on /portfolio, which scrolls: a
   12px green line stays pinned at the top edge while the grid moves under it.
   That is the price of splitting the two toolbars, and it is the reason this is
   12px rather than the ~60px the strip actually occupies. */
html.brand-top .edge-tint {
  position: fixed;
  inset: 0 0 auto 0;
  height: 12px;
  background: var(--brand);
  z-index: 9999;
  pointer-events: none;
}

body {
  background: var(--background);
  /* Reach the bottom of the display, whatever the browser chrome is doing.
     .fold is min-height:100svh — the SMALL viewport, measured with the URL bar
     expanded — which is deliberate, so the fold is guaranteed to fit. But
     viewport-fit=cover made the page span the whole screen, and svh is shorter
     than that, so on an iPhone the body ended partway up and the green canvas
     behind it showed as a band across the bottom.

     dvh, not svh, precisely because it tracks the chrome: body always reaches
     the bottom edge, the canvas green stays where it belongs — the overscroll
     gutters — and .fold keeps its own svh budget untouched. */
  min-height: 100dvh;
  color: var(--foreground);
  font-family: var(--font-sans);
  font-size: var(--t-body);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* Inter's optical-size axis. Small text gets the Text cut; headings opt
     into the Display cut individually below. */
  font-variation-settings: "opsz" 16;
}

img,
svg,
video {
  display: block;
  max-width: 100%;
}

a {
  color: inherit;
}

::selection {
  background: var(--foreground);
  color: var(--background);
}

:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* --------------------------------------------------------------------------
   4. Primitives
   -------------------------------------------------------------------------- */

/* The gutters take max() against the safe-area insets because the pages now
   ship viewport-fit=cover — see the note on html.brand-top. In portrait those
   insets are 0 and this is exactly 1.5rem. It is landscape that needs it: on a
   notched iPhone held sideways the inset is around 59px, comfortably more than
   the gutter, so without this the first line of every paragraph would run under
   the camera housing. */
.shell {
  width: 100%;
  max-width: var(--shell);
  margin-inline: auto;
  padding-inline: max(1.5rem, env(safe-area-inset-left))
    max(1.5rem, env(safe-area-inset-right));
}

@media (min-width: 640px) {
  .shell {
    padding-inline: max(2rem, env(safe-area-inset-left))
      max(2rem, env(safe-area-inset-right));
  }
}

/* The work grid only. Prose wants a short measure and the band headline is
   set against the 1170px column, but a portfolio is showing films — those
   want the width. Same gutters, so the two columns stay visually related. */
.shell--wide {
  max-width: 1560px;
}

/* Small all-caps label. Differentiates from body by size, tracking and case
   — not by typeface. There is only one typeface on this site. */
/* 0.08em, down from 0.2em. That was set for Inter, which is narrow enough to
   need pulling apart at label size; Poppins is a wide geometric face and 0.2em
   scattered it into loose letters rather than a word — which is what Roland
   was seeing on the "34,977 VIEWS" above each embed. Weight up a step at the
   same time, so the label reads as small rather than as faint. */
.eyebrow {
  font-size: var(--t-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted-foreground);
  font-weight: 600;
}


.muted {
  color: var(--muted-foreground);
}

/* Emphasised text in brand green. Text only — never a surface, border or icon.
   Was removed once as unused; call.html's headline brought it back. */
.accent {
  color: var(--brand);
}

/* The counting number on /portfolio. NO colour of its own — green was
   invisible on the green band and the white-on-muted split was rejected, so
   it renders exactly as the h1 does and the movement is the emphasis.
   inline-block gives initViewsCounter() a box to hold steady: during the
   count the digits are painted in an absolutely-positioned layer while this
   box keeps the final figure's exact width, because nothing weaker held —
   tabular-nums is not in Poppins, and min-width cannot stop a WIDER frame
   from shoving the sentence. */
.views-counter {
  display: inline-block;
  white-space: nowrap;
}


/* letter-spacing: normal, not the -0.03em this carried for Inter. Inter is a
   narrow neo-grotesque that wants pulling in at display size; Poppins is wide
   and geometric and reads as cramped at the same value. The fold's headline
   was corrected on 2026-08-13 after measuring the reference; this is the rest
   of the headings catching up, which is the "be consistent" half of what
   Roland asked for. Anything still carrying a negative here was tuned for a
   typeface the site no longer uses. */
h1,
h2,
h3 {
  font-weight: 600;
  letter-spacing: normal;
  line-height: 1.05;
  text-wrap: balance;
  font-variation-settings: "opsz" 32;
}

h1 {
  font-size: var(--t-h1);
  max-width: var(--w-headline);
}

h2 {
  font-size: var(--t-h2);
  line-height: 1.1;
}

/* Buttons ---------------------------------------------------------------- */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  height: 48px;
  padding-inline: 24px;
  border: 1px solid transparent;
  border-radius: var(--radius);
  font: inherit;
  font-size: 1rem;
  font-weight: 500;
  line-height: 1;
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  user-select: none;
  transition: background-color 0.15s ease, border-color 0.15s ease,
    color 0.15s ease;
}

.btn:active {
  transform: translateY(1px);
}

/* The one CTA size, shared by both pages so they match exactly. svh-capped
   because on the fold the button competes with everything else for one
   screen; the portfolio inherits the same value at the same viewport. */
.btn--lg {
  height: clamp(56px, 8.4svh, 76px);
  padding-inline: clamp(28px, 3.6vw, 48px);
  font-size: clamp(1.05rem, 1.35vw, 1.25rem);
  font-weight: 600;
}

.btn--solid {
  background: var(--brand);
  color: var(--on-brand);
}

.btn--solid:hover {
  background: #004d2e;
}

.btn--outline {
  background: var(--background);
  color: var(--foreground);
  border-color: var(--border);
}

.btn--outline:hover {
  background: var(--muted);
}

/* Ratio-locked media box.
   The box reserves its height whether or not a source exists, so dropping a
   file in later shifts nothing below it. Set --ratio to 9/16 for verticals. */
.ratio {
  position: relative;
  width: 100%;
  aspect-ratio: var(--ratio, 16 / 9);
  background: var(--muted);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  overflow: hidden;
}

/* The hero frame, and a DELIBERATE exception to the one-radius rule the rest
   of the site follows — do not "fix" this back. Square and edgeless is the
   whole point of the flat, full-bleed framing; a rounded, hairlined box reads
   as a card sitting on the band rather than a hole cut into it. Portfolio grid
   tiles keep their radius, because there they ARE cards. */
.ratio--bare {
  border: 0;
  border-radius: 0;
  background: #0b3d28;
}

/* The empty state inverts on the dark box — the light-surface treatment would
   vanish against it. .eyebrow sets its own colour, so it needs naming here
   too: inheritance alone loses to a class selector on the element itself. */
.ratio--bare .ratio__pending,
.ratio--bare .eyebrow {
  color: var(--on-brand-muted);
}

.ratio--bare .ratio__glyph {
  background: transparent;
  border-color: color-mix(in srgb, var(--on-brand) 35%, transparent);
}

.ratio > video,
.ratio > img,
.ratio > iframe {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Empty state for a ratio box with no source yet. */
.ratio__pending {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--gap-md);
  color: var(--muted-foreground);
}

.ratio__pending svg {
  width: 20px;
  height: 20px;
}

.ratio__glyph {
  display: grid;
  place-items: center;
  width: 56px;
  height: 56px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--background);
}

/* --------------------------------------------------------------------------
   5. The fold (index.html)
   Single centred column. Deliberately NOT min-height:100svh + centring: with
   a real video the column runs taller than a short laptop viewport, and
   centring would push the headline off the top of the screen.
   -------------------------------------------------------------------------- */

.fold {
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  text-align: center;
}

/* The brand band. It ends partway down the video rather than between two
   elements — that overlap is the whole visual idea, so the video reads as
   sitting *in* the colour rather than under it. */
/* The band is the first thing in the document on every green-topped page, so
   with viewport-fit=cover it is what paints the strip behind the status bar.
   That is the whole mechanism now — the inset is added to its padding so the
   colour reaches the top edge while the headline still clears the notch. */
.band {
  background: var(--brand);
  color: var(--on-brand);
  padding-top: calc(env(safe-area-inset-top) + clamp(1.1rem, 3.2svh, 2.5rem));
}

/* The hard breaks in the fold's three blocks of copy.

   56rem, and the number is derived rather than picked. The headline's first
   segment needs 16.3em; type is 5.6vw through this range and .shell costs a
   fixed 64px, so the segment fits only where 0.088 x viewport exceeds 64px —
   about 727px, with no useful slack until ~900px. Below that the <br> is
   honoured and then immediately re-wrapped, which costs a third row instead
   of buying a cleaner second. So it is switched off and text-wrap: balance
   takes over, which lands on two rows across this whole range. */
.fold-break {
  display: inline;
}

@media (max-width: 56rem) {
  .fold-break {
    display: none;
  }
}

.band__headline {
  /* 17em, not the 15em this carried when the site was Inter. "HOW LONG DOES
     IT TAKE YOU TO" measures 16.3em in Poppins 800, so 15em silently re-broke
     Roland's line and the <br> above looked like it had been ignored. */
  max-width: 17em;
  margin-inline: auto;
  font-size: var(--fold-h1);
  font-weight: 800;
  /* Both values measured off the reference rather than guessed.
     acquisition.com's hero is <h2 style="font-size:50px;font-weight:800">
     with an uppercase span, declaring NO letter-spacing and NO line-height,
     so it inherits normalize's html{line-height:1.15}.

     letter-spacing: normal is confirmed empirically, not assumed. Rendering
     "DO YOU WANT TO SCALE" in Poppins 800 at the reference's own scale gives
     a 1605px line against a 95px cap band; the same string measured off
     their page is 1605px against 95px. At -0.02em — what this rule carried
     while the site was set in Inter — it renders 1554px, so a 51px error
     would have been invisible to the eye and obvious to a ruler. */
  line-height: 1.15;
  letter-spacing: normal;
  text-transform: uppercase;
  text-wrap: balance;
  /* Inert under Poppins, which is static and has no axes. Kept because Inter
     is still the fallback in --font-sans and does have an opsz axis, so this
     is what the headline needs on the degraded path. */
  font-variation-settings: "opsz" 32;
}

.band__sub {
  /* Long enough for "200+ videos for 50 of the biggest crypto companies",
     which measures 25.7em — 26em cleared it by 8px, which is not a margin
     worth trusting across font rounding. */
  max-width: 28em;
  margin-inline: auto;
  margin-top: clamp(0.4rem, 1.4svh, 0.9rem);
  font-size: var(--fold-sub);
  /* 1.3, which is what the reference actually declares — its subhead span
     carries an explicit line-height:1.3. This rule said 1.5 for a day on the
     reasoning that acquisition.com declared nothing and so fell to Poppins'
     normal leading; that was true of the headline and wrong here, because
     the sub is the one element on their hero that does set the property.
     Confirmed against their rendering: sub lines sit 71px apart at a 55px
     size, which is 1.29. */
  line-height: 1.3;
  color: var(--on-brand-muted);
  text-wrap: balance;
}

/* Reserves the strip of band the video is pulled up into. Keeping this as a
   real element (rather than padding) means the two values can never drift. */
.band__spacer {
  height: calc(var(--video-overlap) + clamp(0.6rem, 1.6svh, 1.25rem));
}

.fold__video {
  margin-top: calc(var(--video-overlap) * -1);
  margin-inline: auto;
  /* Width-driven, capped by whichever runs out first: the horizontal room, or
     the height budget expressed as the width that produces it. */
  width: min(calc(100% - 3rem), calc(var(--video-h) * 16 / 9));
}

.fold__video .ratio {
  width: 100%;
  height: auto;
}

/* Leftover height is shared evenly around this group rather than dumped into
   one gap. The client row used to be pinned to the bottom with margin-top:auto,
   which on a phone left a crater between the buttons and the logos. */
.fold__below {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(0.6rem, 1.7svh, 1.15rem);
  flex: 1;
  /* The last thing on the fold, so it is what the home indicator would sit on
     top of now that the viewport covers the display. */
  padding-block: clamp(0.85rem, 2.3svh, 1.6rem)
    calc(env(safe-area-inset-bottom) + clamp(0.85rem, 2.3svh, 1.6rem));
}

.fold__explainer {
  max-width: 46em;
  font-size: var(--fold-body);
  line-height: 1.5;
  color: var(--muted-foreground);
  /* balance, not pretty. Roland's rule for this line is two rows and it was
     landing on three, with "for less." alone on the last. pretty wraps
     greedily and then shuffles the tail to avoid an orphan, which is exactly
     how a block that fits two rows ends up occupying three; balance solves for
     the number of rows first and evens them out. The text is 34.7em and its
     balanced halves are ~17.4em, so two rows fit wherever the line is at least
     that wide — true down to 320px at this size. Browsers cap balance at a
     handful of lines, which this is well inside. */
  text-wrap: balance;
}

.fold__actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.75rem;
}


/* Client logo row -------------------------------------------------------- */

.clients {
  width: 100%;
}

.clients__row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  /* Row gap tighter than column gap: on a narrow screen this wraps to four or
     five lines, and matching the generous horizontal spacing vertically is
     what tipped a 375x667 phone over the one-screen budget.

     The column gap was capped at 2.75rem when this row was two marks and seven
     names set in type. Eight real wordmarks are far wider — the row measured
     1212px into 1106px of shell on a 1280px laptop and broke onto a second line
     carrying two items, which reads as an accident rather than a layout. Capped
     again at 1.25rem when Celestia and CrunchDAO made it ten (2026-08-26) and
     Crunch wrapped alone under a ~1000px window on Roland's screen — the vw
     term below and --logo-h's width term are one mechanism, sized together;
     see the fit arithmetic on .clients__logo before touching either. */
  gap: clamp(0.45rem, 1.4svh, 0.9rem) clamp(0.5rem, 1.3vw, 1.25rem);
  margin-top: clamp(0.35rem, 1.1svh, 0.7rem);
  list-style: none;
  padding: 0;
}

/* One flat grey, whatever the source SVG's own colours are — a logo row reads
   as a set only when nothing in it competes. */
/* Names for the brands with no mark available. Sized and weighted to sit at
   the same optical level as the logos beside them, so the row reads as one
   set rather than logos plus leftovers. */
.clients__name {
  display: block;
  font-size: clamp(0.95rem, 1.9svh, 1.2rem);
  font-weight: 600;
  letter-spacing: -0.01em;
  line-height: 1;
  color: var(--muted-foreground);
  white-space: nowrap;
}

.clients__logo {
  /* Two per-brand corrections, both from data/clients.js.

     --logo-scale sizes it. Every viewBox is cropped to its ink, so a shared
     height gives every lockup the same ink height — which is not the same as
     equal optical weight, because a lowercase wordmark reads smaller than one
     with a tall mark. Set by eye.

     --logo-nudge centres it. align-items:center centres each BOX, and a box is
     only balanced if the ink inside it is. It usually is not: EigenCloud's `g`
     descender drags its box down, so its visible mass sat high — which is what
     Roland spotted. Initia is the opposite and sat low. The value is
     (0.5 − ink centroid), measured by rasterising each file to a 200px canvas
     and taking the alpha-weighted centre of every row, so it is derived rather
     than guessed. Multiplied by the rendered height, it becomes the offset that
     puts every logo's visual mass on one line.

     --logo-h is a variable so the phone override can change the size without
     restating the height and transform.

     The width term (6px + 1.1vw) exists because ten logos must hold ONE line
     on any desktop window — Roland's call 2026-08-26, after Crunch wrapped
     alone under a ~1000px window. Height alone cannot promise that: a tall
     narrow window kept 24px logos while the width shrank under them. The
     numbers are derived, not decorative: the ten lockups sum to 41.8x the
     logo height in width (measured via getBoundingClientRect, 2026-08-26),
     so fit means 41.8h + 9g <= min(1170, vw) - 64, worst case over heights.
     This pair holds that with ~50px to spare from 783px of window width up,
     checked numerically across 641-1600 x 600-1400. Adding an ELEVENTH logo
     raises the 41.8 — re-measure the sum and re-derive both clamps, or the
     wrap quietly returns on exactly the mid-width windows this fixed. */
  --logo-h: clamp(15px, min(2.3svh, 6px + 1.1vw), 21px);
  height: calc(var(--logo-h) * var(--logo-scale, 1));
  transform: translateY(
    calc(var(--logo-h) * var(--logo-scale, 1) * var(--logo-nudge, 0))
  );
  width: auto;
  /* brightness(0) crushes every colour to black; opacity then lifts it to the
     row's grey. 0.58 on black lands on roughly --muted-foreground, so marks and
     names read as one grey rather than two.

     This was grayscale(1), which only ever worked by accident. grayscale
     desaturates but PRESERVES lightness — so it greyed the dark marks and left
     the white ones invisible on a white page. That is not an edge case: brand
     kits ship the on-dark version by default, so five of the eight logos here
     arrived white and simply did not render. brightness(0) makes the comment
     above this rule true instead of aspirational — the source file's colours
     genuinely stop mattering.

     A CSS filter applies to the rasterised result, after the SVG has drawn
     itself, so masks inside these files (Solana's, for one) still do their job.
     That is why the fix belongs here and not in seven hand-edited files. */
  filter: brightness(0);
  opacity: 0.58;
}

.clients__chip {
  display: block; /* it is a <span> — without this the box collapses to 0x0 */
  width: clamp(64px, 8vw, 104px);
  height: clamp(14px, 2svh, 22px);
  border-radius: var(--radius);
  background: #e4e4e7;
}

/* Below ~480px the two buttons stop sitting side by side and each takes the
   full width — a 44px-tall target squeezed to half a phone screen is a miss
   waiting to happen. */
/* Full-width stacked buttons are the better phone target — but only where
   there is height to spend. On a short phone the extra row is 54px the fold
   does not have, and side-by-side buttons beat a page that scrolls. */
@media (max-width: 30rem) and (min-height: 44rem) {
  .fold__actions {
    flex-direction: column;
    align-self: stretch;
  }

  .fold__actions .btn {
    width: 100%;
  }
}

/* --------------------------------------------------------------------------
   6. Portfolio (portfolio.html)
   -------------------------------------------------------------------------- */

/* Shorter band than the fold's — this page has a grid to get to. */
.band--work {
  padding-bottom: clamp(2rem, 6svh, 4rem);
  text-align: center;
}

/* /portfolio's sub is one sentence and Roland wants it on ONE row on
   desktop; .band__sub's own 28em cap wrapped it. Phones wrap naturally. */
.band--work .band__sub {
  max-width: none;
}

/* Its own row, left-aligned against the centred column. It used to float,
   which made the headline wrap around it. */
.band__top {
  text-align: left;
  margin-bottom: clamp(1.5rem, 5svh, 3rem);
}

/* The only control on this page: an icon-only rounded square.
   Tinted white rather than the grey --muted it started as. A light-grey chip
   on a deep green ground reads as a sticker pasted over the band; a wash of
   the band's own white belongs to it. */
.back {
  display: grid;
  place-items: center;
  width: 54px;
  height: 54px;
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--on-brand) 11%, transparent);
  border: 1px solid color-mix(in srgb, var(--on-brand) 24%, transparent);
  color: var(--on-brand);
  transition: background-color 0.18s ease, border-color 0.18s ease,
    transform 0.18s ease;
}

.back:hover {
  background: color-mix(in srgb, var(--on-brand) 20%, transparent);
  border-color: color-mix(in srgb, var(--on-brand) 48%, transparent);
}

/* The arrow leads on hover — a back control that moves back. */
.back:hover svg {
  transform: translateX(-2px);
}

.back:active {
  transform: translateY(1px);
}

/* The global focus ring is near-black, which all but disappears on the band. */
.back:focus-visible {
  outline-color: var(--on-brand);
}

/* Touch platforms already have a back gesture and a browser back control, so
   this button is duplicating something the OS does better — and it is doing it
   with the most valuable 90-odd pixels on the page, the ones directly above
   the headline. Gone on phones and tablets, kept wherever the primary pointer
   is a mouse.

   Keyed on pointer, not width: the affordance being replaced is the swipe, not
   a narrow screen. A phone held in landscape is wider than most width
   breakpoints but still swipes back, and a half-width desktop window is narrow
   but does not. Resizing a desktop browser will therefore NOT reproduce this —
   check it on a real handset.

   The two pages' spacing is topped back up so the headline keeps its breathing
   room rather than jumping to the very top edge. Net reclaim is roughly 80px. */
@media (pointer: coarse) {
  .call__top,
  .case__top,
  .band__top {
    display: none;
  }

  /* Every padding-top in this block must carry the safe-area inset, because
     (pointer: coarse) IS the notched phone — an override here that forgets it
     silently undoes the inset on the only devices that have one. */
  .call,
  .case {
    padding-top: calc(env(safe-area-inset-top) + clamp(1.5rem, 4.5svh, 2.5rem));
  }

  /* --work, not .band: index.html uses the same band for its hero, and that
     one's top padding is load-bearing for keeping the fold on one screen. */
  .band--work {
    padding-top: calc(env(safe-area-inset-top) + clamp(1.5rem, 4svh, 2.75rem));
  }
}

.back svg {
  width: 21px;
  height: 21px;
  transition: transform 0.18s ease;
}

/* One <ul> per shape, emitted by renderWork(): landscape, then squares, then
   verticals. Grouping is what solves the tall-row problem — grid rows size to
   the tallest item, so a square mixed in among 16:9s used to strand its whole
   row in whitespace. Same shape per row, no stranding, and each group gets the
   column count that suits it.

   Explicit column counts, not auto-fill. auto-fill sizes to whatever will fit,
   which at 1560px is three 16:9 tiles — so a two-entry grid rendered two tiles
   and an empty third track, left-aligned with a hole beside it. These counts
   are a design decision, not a consequence of the available width, so they are
   stated rather than derived. minmax(0, 1fr) rather than 1fr: a bare 1fr floors
   at min-content, which a long caption can push past its share of the row. */
.work-grid {
  display: grid;
  align-items: start;
  gap: var(--gap-xl) var(--gap-md);
  list-style: none;
  padding-inline: 0;
  padding-block: 0 var(--gap-xl);
  text-align: left;
}

.work-grid:first-child {
  padding-top: var(--gap-xl);
}

/* 2-up at full width: ~760px a tile, roughly what these films are graded for. */
.work-grid--wide {
  grid-template-columns: repeat(2, minmax(0, 1fr));
}

/* 3-up. A square at half of 1560px would stand ~760px tall, nearly twice its
   16:9 neighbours; at a third it lands close to their height and the page
   rhythm holds across the join. */
.work-grid--square {
  grid-template-columns: repeat(3, minmax(0, 1fr));
}

/* 4-up. A 9:16 box is taller than it is wide whatever you do, so the answer is
   to make it narrow: phone-shaped video, phone-shaped box. This replaces the
   old .ratio--vertical max-width hack, which centred a narrow box inside a wide
   column and left dead air either side of it. */
.work-grid--tall {
  grid-template-columns: repeat(4, minmax(0, 1fr));
}

/* Below the wide layout the denser rows give up a column first — a 1:1 tile at
   a third of a 900px window is 280px, which is too small to read as a film. */
@media (max-width: 60rem) {
  .work-grid--square,
  .work-grid--tall {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

/* A MIXED-SHAPE row — renderWork() emits one for any run of non-16:9 tiles.

   Flex, not grid, because the whole point is UNEQUAL widths: every tile gets
   flex-grow AND flex-basis proportional to its aspect ratio (--tile-aspect,
   set inline by renderWork(), the only place that knows it). Width settling
   proportional to aspect is the same thing as every video in the row being
   the SAME HEIGHT — a 1:1 beside two 9:16s comes out 47% / 26.5% / 26.5%,
   all three tops and bottoms aligned. That height-matching is what lets
   array order put a square and two verticals in one row without the
   stranded-whitespace problem the old per-shape grouping existed to prevent.

   The basis being proportional (not 0) is what keeps wrapping sane: when the
   row wraps, each line re-balances among its own members and stays
   height-matched per line.

   Captions are not height-matched, only the media boxes are: a two-line
   one-liner under a narrow vertical is taller than one line under the
   square, and align-items: flex-start keeps that ragged edge at the bottom
   where it reads as text, not as a broken row. */
.work-grid--row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
}

.work-grid--row > * {
  flex: var(--tile-aspect, 1) 1 calc(var(--tile-aspect, 1) * 9rem);
  min-width: 0;
}

/* Phones: the 40rem block below flattens .work-grid to one column, but that
   rule is grid-template-columns and this row is flex — it needs saying in
   flex terms or phones get a row of slivers. */
@media (max-width: 40rem) {
  .work-grid--row {
    flex-direction: column;
  }

  .work-grid--row > * {
    flex: none;
    width: 100%;
  }
}

/* Phones: one film at a time, whatever its shape. Same 40rem breakpoint the
   fold uses for --video-h, so the two pages change gear together. */
@media (max-width: 40rem) {
  .work-grid {
    grid-template-columns: minmax(0, 1fr);
  }
}

.work-item__link {
  display: block;
  text-decoration: none;
  border-radius: var(--radius);
}

/* The media box is the positioning context for the hover overlay, and it
   clips it — .ratio is already overflow:hidden, which is what keeps the blur
   and the wash inside the tile's corner radius. */
.work-item__link .ratio {
  transition: border-color 0.18s ease;
}

.work-item__link:hover .ratio {
  border-color: var(--muted-foreground);
}

/* Hover state: the film blurs back and a green wash carries the label.
   DELIBERATE exception to the "green is text only, never a surface" rule in
   AGENTS.md — Roland asked for this one specifically. It is the only surface
   on the site allowed to be green.

   The blur is filter on the video, not backdrop-filter on the overlay: cheaper
   over a decoding video, and better supported. scale(1.06) goes with it because
   a blur samples past the element's own edges, so an unscaled video shows a
   soft transparent rim where the frame runs out.

   Behind (hover: hover) so a touch device never latches into it — a phone has
   no way to leave a hover state once a tap has entered it. */
.work-item__overlay {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  opacity: 0;
  pointer-events: none;
  /* 62%, not the 35-40% this started at. The wash lands on a BLURRED frame
     whose brightness is whatever that film happens to be, and a light tint over
     a bright frame left the white label at about 2:1 — unreadable on exactly
     the shots that photograph best. At 62% the blurred film still reads through
     it and the label holds contrast on anything. */
  background: color-mix(in srgb, var(--brand) 62%, transparent);
  transition: opacity 0.22s ease;
}

.work-item__overlay-label {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  color: var(--on-brand);
  font-size: clamp(1.25rem, 2vw, 1.875rem);
  font-weight: 500;
  letter-spacing: normal;
}

.work-item__overlay-label svg {
  width: 1em;
  height: 1em;
}

@media (hover: hover) {
  .work-item__link .ratio > video,
  .work-item__link .ratio > img {
    transition: filter 0.22s ease, transform 0.22s ease;
  }

  .work-item__link:hover .ratio > video,
  .work-item__link:hover .ratio > img {
    filter: blur(8px);
    transform: scale(1.06);
  }

  .work-item__link:hover .work-item__overlay,
  .work-item__link:focus-visible .work-item__overlay {
    opacity: 1;
  }

  /* A tile with no film yet still hovers, and "FILM PENDING" printing through
     the wash behind "Case study" is two messages in the same space. The film
     itself gets blurred; this has nothing to blur, so it steps aside. */
  .work-item__link .ratio__pending {
    transition: opacity 0.22s ease;
  }

  .work-item__link:hover .ratio__pending {
    opacity: 0;
  }
}

.work-item__caption {
  margin-top: var(--gap-sm);
}

.work-item__client {
  font-weight: 500;
}

.work-item__stage {
  color: var(--muted-foreground);
  font-weight: 400;
}

.work-item__line {
  margin-top: 0.25rem;
  font-size: var(--t-micro);
  color: var(--muted-foreground);
  text-wrap: pretty;
}

/* One line of text does not need the grid's full row rhythm around it — with
   an empty array the page was showing ~190px of nothing. */
.work-empty {
  padding-block: var(--gap-xl);
  text-align: center;
  color: var(--muted-foreground);
}

/* Closing block: muted archive link, then the one real CTA. No rule above it
   — the whitespace already separates it. */
.work-close {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--gap-md);
  padding-block: var(--gap-md) var(--gap-page);
}

/* Present for anyone who wants depth, invisible to anyone who doesn't. */
.archive-link {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.45rem;
  min-height: 44px;
  padding-inline: 0.75rem;
  font-size: var(--t-micro);
  color: var(--muted-foreground);
  text-decoration: none;
  transition: color 0.15s ease;
}

.archive-link:hover {
  color: var(--foreground);
}

.archive-link__mark {
  width: 22px;
  height: 22px;
}


/* On a phone 26vw leaves the video tiny with dead space under it. Give it
   more — but stay height-driven: a short phone has no slack at all, so a
   width-driven value that helps a 844px screen overflows a 667px one. */
@media (max-width: 40rem) {
  :root {
    --video-h: min(26svh, 78vw);

    /* Roland's rule for the headline is two rows, always. The whole string is
       23.9em wide, so two rows need type no larger than twice the available
       width divided by that — and the 2rem floor in :root was well over it,
       which is why a phone got three rows. 6.6vw holds the ratio at about
       1.8 rows-worth of text across 320-430px, so balance has real slack to
       work with rather than landing on the edge of a third row.

       It costs size: about 26px on a 390px phone against the 32px this used
       to render. That is the trade Roland asked for in as many words. */
    --fold-h1: min(6.6vw, 2rem);

    /* Same width floor the short-viewport block carries, and here for the same
       reason: below ~330px the explainer's 0.95rem floor is two pixels too big
       to keep "Never settle for less." out of a third row. No effect at 360px
       and up. */
    --fold-body: min(4.6vw, clamp(0.95rem, 1.15vw, 1.125rem));
  }

  /* Nine wordmarks wrapped to THREE rows on a phone, which reads as a list
     rather than a row of logos. Two is the target, and it takes all three of
     these together — shrinking the logos alone would have needed 14px type to
     get there, which is past legible.

     The binding constraint is the three widest lockups: EigenCloud, BitRobot
     and Solana are ~116/104/102px and nearly fill a row between them. So the
     row is allowed most of the screen width instead, which is normal for a
     logo strip and costs the layout nothing.

     Measured against the real aspect ratios, and re-measured when Fluent's new
     lockup arrived 39% wider than the old one and pushed LFJ onto a third row
     by itself: 5 + 4 at 430, 402, 390 and 360px. Re-run that check after ANY
     logo changes shape — the fit has no slack left in it. */
  .clients__row {
    /* -16px against .shell's 24px leaves an 8px gutter. max() rather than a
       bare negative keeps the landscape safe-area inset intact — there the
       padding is ~59px and this still clears the camera housing. */
    margin-inline: -16px;
    column-gap: 8px;
  }

  .clients__logo {
    --logo-h: clamp(15px, 1.9svh, 24px);
  }

  /* The gap under the video was 72px on a 390x844 phone and 104px on a 430x932
     one, against 14px above it — measured, not eyeballed. Nothing declared
     either number: .fold__below is flex:1 with justify-content:center, so all
     the leftover fold height is split evenly and half of it lands directly
     under the video. The taller the phone, the wider that gap gets, which is
     why it reads as a hole rather than as spacing.

     So on a phone the group stops centring and sits at the top of its box,
     with the padding set to the SAME expression that produces the gap above
     the video (see .band__spacer's trailing clamp). The two gaps are now one
     value in two places rather than one value and an accident.

     The leftover goes below the client row instead. That is deliberately not
     the rejected margin-top:auto — that pinned the logos to the bottom and
     left a crater between them and the buttons. Here the whole group stays
     together and the slack ends up after it, where it reads as bottom margin.

     Desktop keeps centring: its leftover is small (41px at 1440x900) and
     centring is what keeps the composition balanced on a tall window. */
  .fold__below {
    justify-content: flex-start;
    padding-block-start: clamp(0.6rem, 1.6svh, 1.25rem);
    /* Not centring means the slack has to land somewhere, and all of it at the
       bottom left 182px of dead white under the logo row while the explainer
       sat 14px off a 77px-tall button. So the group's own gaps take a share
       first: this roughly doubles them, which the CTA stack wanted anyway, and
       brings the strip under the logos down with it. The rest stays at the
       bottom, where it reads as the page ending rather than as a hole in the
       middle of it. */
    gap: clamp(0.9rem, 4.5svh, 3rem);
  }
}

/* Short viewports — landscape phones, and small old portrait ones.
   Below about 660px of height the fold genuinely cannot hold a legible
   headline, a video, two CTAs and a logo row on one screen. Shrinking type
   until it fits crosses into unreadable, so stop pinning to the viewport and
   let the page scroll — which is what someone holding a phone sideways
   expects anyway. Sizing goes width-driven, since height is the scarce axis. */
@media (max-height: 660px) {
  :root {
    /* 6.6vw is the two-row rule again, and it has to be repeated here because
       this block re-declares --fold-h1 and would otherwise reinstate the 2rem
       floor that put three rows on a 320px phone. The headline is 23.9em of
       text, so two rows need type no wider than twice the available width over
       that; 6.6vw satisfies it from about 300px up. The 4.2rem cap still binds
       on a short landscape window, where width is plentiful. */
    --fold-h1: min(6.6vw, 4.2rem);
    --fold-sub: clamp(1.05rem, 1.85vw, 1.6rem);
    /* Floored by width as well as by rem, for the same two-row reason as the
       headline. The explainer's best word-boundary split leaves a 18.1em half,
       so it needs about 15.0px at 320px wide and the plain 0.95rem floor is
       15.2 — two pixels, and "for less." drops to a third row. min() only
       bites below ~330px; every phone above that still gets the 0.95rem. */
    --fold-body: min(4.6vw, clamp(0.95rem, 1.15vw, 1.125rem));
    --video-h: min(34vw, 300px);
  }

  .fold {
    min-height: 0;
  }

  .fold__below {
    flex: none;
    justify-content: flex-start;
  }
}

/* --------------------------------------------------------------------------
   7. Hero VSL player
   Built by initVsl() in site.js. Everything is absolutely positioned inside
   the .ratio box, so the player adds no height — the fold's budget is
   unchanged whether a video is present or not.
   -------------------------------------------------------------------------- */

.vsl__video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The overlay is a real <button>: it is the click target that both unmutes
   and supplies the user gesture browsers require before allowing sound. */
.vsl__unmute {
  position: absolute;
  inset: 0;
  /* Above the control bar, which is a later sibling and would otherwise paint
     over it. The overlay is a lid over the whole frame and must be the thing
     a tap hits — see the note on .vsl__bar's z-index. */
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: clamp(0.75rem, 2.5svh, 1.5rem);
  width: 100%;
  border: 0;
  /* Light enough that the film reads through it — the overlay is a prompt
     over playing video, not a lid on top of it. */
  background: color-mix(in srgb, var(--brand) 66%, transparent);
  color: var(--on-brand);
  font: inherit;
  cursor: pointer;
  text-align: center;
  padding: 1rem;
  transition: opacity 0.2s ease, visibility 0.2s ease;
}

/* The tint covers the whole frame; the bordered panel sits inside it. Two
   layers, like the reference — a bare full-bleed wash reads as an error state
   rather than a prompt. */
/* Everything inside the panel is measured against --video-actual-h, the real
   height of the box it sits in, NOT svh. On a phone the video's height comes
   from its width, so a tall handset was producing a 59px speaker inside a
   192px-tall video and the panel overflowed the frame it was supposed to sit
   within. Viewport units are the wrong ruler for a box the viewport does not
   size. */
.vsl__unmute-panel {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: calc(var(--video-actual-h) * 0.045);
  width: min(80%, 42rem);
  padding: calc(var(--video-actual-h) * 0.085) clamp(0.75rem, 3vw, 2rem);
  border: 2px solid color-mix(in srgb, var(--on-brand) 85%, transparent);
  border-radius: var(--radius);
}

.vsl__unmute svg {
  width: clamp(22px, calc(var(--video-actual-h) * 0.19), 56px);
  height: clamp(22px, calc(var(--video-actual-h) * 0.19), 56px);
}

/* The speaker's arcs breathe outward in sequence. The overlay claims the video
   is playing while the frame sits silent — a moving icon is what makes that
   claim believable rather than a still graphic that looks stuck. */
@keyframes vsl-wave {
  0%,
  100% {
    opacity: 0.2;
  }
  45% {
    opacity: 1;
  }
}

.vsl__wave {
  animation: vsl-wave 1.9s ease-in-out infinite;
  transform-origin: 30% 50%;
}

.vsl__wave--2 {
  animation-delay: 0.28s;
}

/* A slow breath on the panel. Enough to read as "this is a button", small
   enough not to jitter behind the text sitting on it. */
/* Transform only. The previous version also animated box-shadow, which forces
   a repaint on every frame — cheap enough on a desktop GPU, visibly rough on a
   phone. Scale alone runs on the compositor. */
@keyframes vsl-breathe {
  0%,
  100% {
    transform: scale(1);
  }
  50% {
    transform: scale(1.025);
  }
}

.vsl__unmute-panel {
  animation: vsl-breathe 2.8s ease-in-out infinite;
}

/* Once the sound is on, both animations have said what they had to say. */
.vsl.is-started .vsl__unmute-panel,
.vsl.is-started .vsl__wave {
  animation: none;
}

.vsl__unmute-text {
  display: flex;
  flex-direction: column;
  gap: 0.15em;
  line-height: 1.25;
}

.vsl__unmute-lead {
  font-size: clamp(0.8rem, calc(var(--video-actual-h) * 0.083), 1.35rem);
  font-weight: 600;
}

.vsl__unmute-cta {
  font-size: clamp(0.8rem, calc(var(--video-actual-h) * 0.083), 1.35rem);
  font-weight: 700;
}

/* Once the sound is on the overlay has done its job. */
.vsl.is-started .vsl__unmute {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

/* Control bar --------------------------------------------------------- */

.vsl {
  /* One value drives the bar, the buttons and the scrubber hit area. */
  --vsl-bar-h: 36px;
  /* Without this, every tap waits out the browser's double-tap-zoom timer —
     roughly 300ms of nothing happening, which was most of why the player felt
     sluggish on a phone. `manipulation` keeps scrolling and pinch-zoom and
     drops only the double-tap gesture. */
  touch-action: manipulation;
  /* The grey box iOS and Android flash over a tapped element. It lands on top
     of the film and reads as a rendering fault rather than feedback. */
  -webkit-tap-highlight-color: transparent;
  /* Otherwise a long press on the video raises the text-selection callout. */
  -webkit-user-select: none;
  user-select: none;
}

/* A mouse is precise; a fingertip is not. 44px is the accessible minimum for
   touch, and this is the only place the two need different answers. */
@media (pointer: coarse) {
  .vsl {
    --vsl-bar-h: 44px;
  }
}

.vsl__bar {
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  /* Below the unmute overlay. initVsl appends the bar after the overlay, so
     without this it wins the paint order and sits on top of it. That is what
     made unmuting take two taps on a phone: wake() reveals the bar on
     pointerdown, and on a short frame the centred unmute panel overlaps the
     bar's 44px strip — so between pointerdown and click a new element slid
     under the finger and swallowed the click the overlay was waiting for. */
  z-index: 1;
  display: flex;
  align-items: center;
  /* Zero, and it has to stay zero. The controls are square cells that light up
     on hover (see .vsl__btn), and a gap turns a row of lit segments into a row
     of floating tiles with slivers of green between them. Flush is the whole
     point of the square corners. Space the icons with the cell size, never with
     a gap here. */
  gap: 0;
  /* Zero here too, so the first and last cells run into the corners of the
     frame instead of hovering a few pixels off them. The control row is the
     full width of the bar; the rail gets its breathing room from its own
     margin, which is safe because the rail is the one child with no hover
     wash to leave a sliver in. */
  padding-inline: 0;
  /* A scrim, not a slab. A solid bar hides the bottom of the film; a gradient
     darkens just enough to keep white controls legible over bright footage. */
  /* Solid brand green. The padding-top that used to exist here only gave the
     old gradient room to fade out; with a flat fill it would just be more
     green, so the bar is exactly the control row and nothing else. */
  background: var(--brand);
  color: #fff;
  opacity: 0;
  visibility: hidden;
  /* Asymmetric on purpose. Going away is a fade; arriving is as close to
     instant as a transition gets, because a control you just asked for should
     already be there. The show duration lives on the visible rules below. */
  transition: opacity 0.16s ease, visibility 0.16s ease;
}

/* Visible while paused, and for a beat after any interaction (.is-active,
   owned by wake() in site.js). */
.vsl.is-ready.is-active .vsl__bar,
.vsl.is-ready.is-paused .vsl__bar {
  opacity: 1;
  visibility: visible;
  transition-duration: 0.09s;
}

/* Hover — for real pointers ONLY, and never merged back into the list above.
   A touch leaves :hover stuck on whatever was tapped until something else is
   tapped, so an unguarded :hover rule pinned this bar open permanently after
   the first tap on the film: wake()'s timer dutifully removed .is-active and
   CSS kept the bar on screen regardless. That was the "controls never hide"
   bug, and this media query is the fix. */
@media (hover: hover) and (pointer: fine) {
  .vsl.is-ready:hover .vsl__bar {
    opacity: 1;
    visibility: visible;
    transition-duration: 0.09s;
  }
}

/* Keyboard focus, and only keyboard focus. :focus-within used to be in the
   list above and was the other half of the same bug — tapping play left that
   button focused, which pinned the bar open for good. Its own rule block on
   purpose: a browser without :has() drops this one declaration, whereas an
   unsupported selector sitting inside a comma list would take every other
   selector in that list down with it. */
.vsl.is-ready:has(:focus-visible) .vsl__bar {
  opacity: 1;
  visibility: visible;
  transition-duration: 0.09s;
}

/* …but never while the unmute overlay is still up. The overlay asks for one
   action and owns the frame until it gets it; a control bar behind it is only
   something for a tap to go wrong on. Four classes, same weight as the rules
   above, and last — so it wins the tie and overrides all three. */
.vsl.is-ready:not(.is-started) .vsl__bar {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

.vsl__btn {
  display: grid;
  place-items: center;
  width: var(--vsl-bar-h);
  height: var(--vsl-bar-h);
  flex: none;
  padding: 0;
  border: 0;
  /* Square, deliberately — the one place on the site that does not take
     --radius. These are cells in a control bar, not buttons on a page: the
     hover wash reads as a lit segment of the bar when it runs corner to
     corner, and as a floating pill when it is rounded off. */
  border-radius: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  font-weight: 600;
  cursor: pointer;
  transition: background-color 0.15s ease;
}

/* Same sticky-hover reasoning as the bar: on touch this would latch on after a
   tap and leave a button looking permanently pressed. */
@media (hover: hover) and (pointer: fine) {
  .vsl__btn:hover {
    background: rgb(255 255 255 / 0.18);
  }
}

.vsl__btn svg {
  width: 18px;
  height: 18px;
}

.vsl__rate {
  width: auto;
  min-width: var(--vsl-bar-h);
  padding-inline: 0.4rem;
  font-size: 0.75rem;
  font-variant-numeric: tabular-nums;
}

/* Scrubber ------------------------------------------------------------ */

/* A readout, not a control — the drag scrubber is gone and the −10s button is
   the only way back through the film. So: no cursor: pointer, which would
   promise a click that does nothing, and no touch-action: none, which existed
   to stop a drag scrolling the page. Keeping that line would mean a swipe
   starting on the rail could not scroll the fold at all. */
.vsl__track {
  position: relative;
  flex: 1;
  height: var(--vsl-bar-h);
  min-width: 3rem;
  /* The cells either side are flush; this is what keeps the white rule from
     running straight into the speaker icon and the speed label. It sits on the
     rail rather than as a gap on .vsl__bar because a gap there would put green
     slivers between the lit hover cells. */
  margin-inline: 0.5rem;
  display: flex;
  align-items: center;
}

.vsl__rail {
  position: relative;
  width: 100%;
  height: 4px;
  border-radius: 2px;
  background: rgb(255 255 255 / 0.28);
  transition: height 0.12s ease;
}

/* The swell stays — it is the only life left in the rail — but for real
   pointers only, and for the reason this file keeps repeating: on touch,
   :hover latches onto whatever was last tapped and never lets go, so an
   unguarded rule leaves the rail permanently fat after one tap on the film.
   Hover is a desktop idea anyway, so guarding it is how you keep it. */
@media (hover: hover) and (pointer: fine) {
  .vsl__track:hover .vsl__rail {
    height: 6px;
  }
}

/* How much has actually downloaded. Without it, a stall looks like a broken
   player rather than a slow connection. */
.vsl__buffered {
  position: absolute;
  inset-block: 0;
  left: 0;
  width: 0;
  border-radius: inherit;
  background: rgb(255 255 255 / 0.3);
}

.vsl__played {
  position: absolute;
  inset-block: 0;
  left: 0;
  width: 0;
  border-radius: inherit;
  background: #fff;
}

/* The global focus ring is near-black, which vanishes against video. The rail
   is no longer in the tab order, so only the buttons need one. */
.vsl__btn:focus-visible {
  outline: 2px solid #fff;
  outline-offset: -2px;
}


/* Resume ------------------------------------------------------------- */

/* A paused film reads as stopped only if something says so. The control bar
   alone is 36px of green along the bottom edge of a still frame, and on a
   phone it was the only way back into the film at all.

   Borrowed vocabulary from the unmute overlay — brand green, a white ring —
   but NOT its full-frame wash. That overlay is selling an action on top of
   playing video; this one is reporting a state on a frame the viewer has
   chosen to stop on, and blanketing that frame in green hides the thing they
   stopped to look at.

   Sized against --video-actual-h for the reason written out at .vsl__unmute-panel:
   on a phone the box's height comes from its width, so svh is the wrong ruler
   and a viewport-sized button overflows the frame it sits in. */
.vsl__resume {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  /* Under the bar (z-index 1) and under the unmute overlay (2). This is the
     fat target; the bar is the precise one, and where they overlap on a short
     frame the precise one has to win the tap. */
  z-index: 0;
  display: grid;
  place-items: center;
  width: clamp(52px, calc(var(--video-actual-h) * 0.26), 92px);
  height: clamp(52px, calc(var(--video-actual-h) * 0.26), 92px);
  padding: 0;
  /* Solid white and a step thicker than the site's 2px, because this is a ring
     rather than a hairline — the shape people already know from every play
     button they have ever pressed. At 85% over pale footage it read as a grey
     edge and the whole control looked switched off. */
  border: 3px solid var(--on-brand);
  /* A circle, and the third documented exception to the one-radius rule —
     do not "fix" this back to --radius. Roland asked for the X treatment
     specifically, and it is the right instinct: a rounded square at this size
     reads as a card sitting on the frame, where a circle reads as a button on
     it. .ratio__glyph is already round, so this is not a new shape on the site,
     and .film__start matches it so the two players' play buttons agree. */
  border-radius: 999px;
  /* Fully solid, where the unmute overlay is 66%. That overlay spreads its tint
     over the whole frame, so it reads as a deliberate wash; the same value in
     an 84px circle over pale footage just looks muddy and the white triangle
     loses its edge. It was 92% and that last 8% was the difference between a
     button and a smudge. Matches .vsl__bar, which is solid --brand — the two
     are the same control system and should look it. */
  background: var(--brand);
  color: var(--on-brand);
  cursor: pointer;
  opacity: 0;
  visibility: hidden;
  /* visibility, not display: it keeps the button out of the tab order while
     hidden, so this adds no second "Play" stop for a keyboard, and it lets the
     fade actually run. */
  transition: opacity 0.16s ease, visibility 0.16s ease;
}

.vsl__resume svg {
  width: 38%;
  height: 38%;
  /* Optical centring, MEASURED rather than guessed. ICONS.play is
     "M8 5.5v13l11-6.5z" — vertices (8,5.5) (8,18.5) (19,12) in a 24-wide box,
     so the triangle's centroid sits at x=11.67 and wants to move RIGHT by 0.33
     units. That is 1.4% of the ICON's width.

     It used to be `margin-left: 6%`, and a percentage margin resolves against
     the containing block — the button — so at a 44% icon it pushed the triangle
     about ten times too far. That is why the play button looked off-centre, and
     it is the whole of the bug: Roland saw it as "not centered well" and it
     was, by roughly 4% of the button. Percentage on transform resolves against
     the element itself, so this says what it means. */
  transform: translateX(1.4%);
}

/* Paused, and only once the unmute overlay has been dealt with — two prompts
   over one frame is one too many, and .is-blocked is exactly that case. */
.vsl.is-ready.is-started.is-paused .vsl__resume {
  opacity: 1;
  visibility: visible;
}

/* Out of the way while the pointer is on the player: the bar is up, the
   precise controls are right there, and the frame should be readable. Guarded
   for real pointers, like every other :hover in this file — on touch, :hover
   latches onto whatever was last tapped, and an unguarded rule here would mean
   the button never comes back on the one device that needs it most. */
@media (hover: hover) and (pointer: fine) {
  .vsl.is-ready.is-started.is-paused:hover .vsl__resume {
    opacity: 0;
    visibility: hidden;
  }
}

.vsl__resume:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
}

/* Autoplay refused (data saver, low power mode). The overlay relabels itself
   to a plain play prompt — claiming the video is playing when it isn't would
   just look broken. */
.vsl.is-blocked .vsl__unmute {
  background: color-mix(in srgb, var(--brand) 90%, transparent);
}

/* --------------------------------------------------------------------------
   8. Booking page (call.html)
   White ground rather than the brand band: the embed brings its own dense
   calendar UI, and putting that on green would be two loud surfaces fighting.
   -------------------------------------------------------------------------- */

.page-call {
  background: var(--background);
}

.call {
  display: flex;
  flex-direction: column;
  padding-block: calc(env(safe-area-inset-top) + clamp(1.25rem, 4svh, 2.5rem))
    calc(env(safe-area-inset-bottom) + clamp(2rem, 6svh, 4rem));
}

.call__top {
  text-align: left;
  margin-bottom: clamp(1.25rem, 4svh, 2.5rem);
}

/* The back control on a light ground. The band version is a wash of white on
   green; here it needs the inverse or it disappears. */
.back--light {
  background: var(--muted);
  border-color: var(--border);
  color: var(--foreground);
}

.back--light:hover {
  background: var(--background);
  border-color: var(--muted-foreground);
}

.back--light:focus-visible {
  outline-color: var(--ring);
}

.call__headline {
  max-width: none;
  text-align: center;
  font-size: clamp(1.75rem, 4.4vw, 3.25rem);
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: normal;
  margin-bottom: clamp(1.5rem, 4svh, 2.75rem);
}

.call__headline span {
  display: block;
}

/* The second line steps back so the first carries the instruction. */
.call__headline-sub {
  color: var(--foreground);
  font-weight: 600;
}

/* Reserve height before the embed mounts so the page does not jump when the
   calendar arrives — the same reasoning as the ratio boxes elsewhere. */
.call__embed {
  position: relative;
  width: 100%;
  min-height: clamp(520px, 68svh, 760px);
}

/* Sits in the reserved space until Cal reports linkReady. Absolute so it takes
   no height of its own — the embed mounts as a sibling and must not be pushed
   down by it. */
.call__pending {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0;
  /* Cal draws its own spinner dead centre in this very box, and it lives in a
     shadow root so the two cannot be laid out as one stack. Dropping the label
     clear of the centre line is what stops them printing over each other; it
     then reads as a caption under the spinner, which is what it is. */
  transform: translateY(2.75rem);
  animation: call-pending-pulse 1.8s ease-in-out infinite;
}

@keyframes call-pending-pulse {
  0%,
  100% {
    opacity: 0.45;
  }
  50% {
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .call__pending {
    animation: none;
  }
}

/* Written by initCal() only when the embed fails to appear, and empty on every
   other load. Quiet type rather than a button on purpose: this is an escape
   hatch, not a call to action, and the page already has its green accent in
   the headline. */
.call__fallback {
  margin-top: var(--gap-md);
  text-align: center;
  font-size: 0.9375rem;
  color: var(--muted-foreground);
}

.call__fallback-link {
  color: var(--foreground);
  font-weight: 600;
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* Long-form pages: privacy. Left-aligned against the centred column,
   because centred body copy is unreadable past a couple of lines. */
.prose {
  max-width: 42rem;
  margin-inline: auto;
  text-align: left;
  color: var(--foreground);
}

.prose h2 {
  margin-top: var(--gap-lg);
  margin-bottom: var(--gap-xs);
  font-size: 1.125rem;
  font-weight: 600;
}

.prose p {
  margin-block: 0 var(--gap-sm);
}

.call__fallback-note {
  display: block;
  padding-block: var(--gap-xl);
  text-align: center;
  color: var(--muted-foreground);
}

/* --------------------------------------------------------------------------
   9. Case study (work/*.html)
   White ground, one column, no band. The band belongs to pages that are
   selling; a case study has already been clicked into, so it can just be read.
   Nothing here is a new primitive — .ratio, .prose, .btn and .back--light all
   come from above.
   -------------------------------------------------------------------------- */

.page-case {
  background: var(--background);
}

.case {
  padding-block: calc(env(safe-area-inset-top) + clamp(1.25rem, 4svh, 2.5rem)) 0;
}

.case__top {
  margin-bottom: clamp(1.5rem, 5svh, 3rem);
}

/* .case__eyebrow lived here — "CLIENT · stage" above the headline. Removed
   2026-08-14: Roland's read was that it made no sense on the page, and he is
   right that it was saying nothing the reader did not already have. The tile
   they clicked, the <title> and the headline all name the client, and the
   stage was a label for its own sake. Both its rules went with it rather than
   being left as dead CSS. */

/* Set to match .band__headline on /portfolio, which Roland named as the
   correct treatment: caps, 800, no tracking, then the description under it.
   This was sentence case at weight 600, so the two banded pages disagreed
   with each other on the one element they most obviously share. */
.case__headline {
  max-width: none;
  font-size: var(--t-h1);
  font-weight: 800;
  line-height: 1.15;
  letter-spacing: normal;
  text-transform: uppercase;
}

/* The result, in one line, before any prose. If a piece cannot fill this in
   it is not a case study yet. */
.case__lead {
  max-width: 42rem;
  margin-top: var(--gap-md);
  font-size: clamp(1.05rem, 1.5vw, 1.375rem);
  color: var(--muted-foreground);
  text-wrap: pretty;
}

.case__hero {
  margin-top: var(--gap-lg);
}

/* Numbers, ruled off from the story. A definition list rather than a row of
   divs: these ARE label/value pairs, and it costs nothing to say so.

   GRID, NOT FLEX-WRAP, and the difference is the whole point. This was
   `flex-wrap: wrap` with no width, no flex and no flex-basis on .case__stat,
   so every item sized to its own text: VIEWS, LAUNCH VIDEOS, PRODUCT FILMS and
   STAGE each got a different width, the two rows disagreed about where their
   columns started, and the block read as broken on a phone. It was also the
   only layout on the site with no media query at all — it had nothing to
   adapt WITH.

   A track sets the width here, not the content, so changing a value from "4"
   to "Pre-mainnet" cannot move a column edge. That is the requirement Roland
   actually asked for: it has to keep working when the content changes, not
   just look right today. */
.case__stats {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--gap-md) var(--gap-sm);
  margin-top: var(--gap-lg);
  padding-block: var(--gap-md);
  border-block: 1px solid var(--border);
}

/* Nothing here counts the stats, and nothing should — the template says three
   or four, and a fifth should not need a CSS edit. An odd count would leave
   the last one alone in column 1 looking like a mistake, so it takes the row. */
.case__stats > :last-child:nth-child(odd) {
  grid-column: 1 / -1;
}

/* Above phones: N items, N equal columns, worked out by the browser.

   720px, and in px. It was 40rem, which is wrong twice: a media query resolves
   rem against the READER's font-size preference, so at a 12px root that is
   480px — and this number comes from a content fit measured in pixels, so a
   type preference must not move it. Every other min-width in this file is px.

   And 640 was too low. A track is (W - 64 - 12(N-1)) / N, so four columns at
   640px are 135px, while `Pre-mainnet` at the dd's 24px floor wants ~142px —
   a live overflow band from about 640 to 667px, which is iPad Split View and
   any half-width desktop window. 720px clears it with room. */
@media (min-width: 720px) {
  .case__stats {
    grid-template-columns: none;
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
  }

  .case__stats > :last-child:nth-child(odd) {
    grid-column: auto;
  }
}

.case__stat {
  display: flex;
  flex-direction: column;
  gap: var(--gap-xs);
}

/* Same label treatment as .eyebrow, and kept in step with it — these two are
   the same thing and drifted apart. See the note there for why 0.2em went. */
.case__stat dt {
  order: 2; /* value reads first, label sits under it */
  font-size: var(--t-eyebrow);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted-foreground);
  font-weight: 600;
}

.case__stat dd {
  order: 1;
  margin: 0;
  font-size: clamp(1.5rem, 2.6vw, 2rem);
  font-weight: 700;
  letter-spacing: normal;
  line-height: 1;
  font-variation-settings: "opsz" 32;
}

/* Centred, on Roland's instruction, and this reverses an earlier decision:
   the note on .band--case still says the body copy stays left, which was
   §9's call. It was defensible while the column hugged the left of a 1170px
   shell — but that left roughly 500px of nothing down the right of every case
   study, and the centred green block above it made the seam obvious. Centring
   the column and the words puts the whole page on one axis.

   The paragraphs here are three or four lines each, which is about the limit
   for centred body copy; if a section ever runs long, split it rather than
   letting a ragged left edge run down half a screen. */
.case .prose {
  /* 34rem, down from .prose's own 42 — Roland's 2026-08-20 rework: the words
     tighten, the shelves keep their full width. Centred as ever; the note
     above about centred copy's line-count limit matters MORE at this width. */
  max-width: 34rem;
  margin-inline: auto;
  margin-top: var(--gap-lg);
  text-align: center;
}

/* Caps, matching the headline above and the band on /portfolio — "be
   consistent with capital letters". Scoped to .case on purpose: .prose is
   also the FAQ and the privacy page, and shouting a question at somebody is
   not the same as labelling a section. */
.case .prose h2 {
  /* COMMENTS and the CTA line on every page — GOAL and VIDEOS survive only on
     bitrobot.html and near.html, the two pages the 2026-09-22 layout
     simplification left alone. Since 2026-08-20 these carry the landing h1's
     own voice at a smaller size, and they are BRAND GREEN on the white case
     body: the second standing exception to the one-green-span rule, Roland's
     call, recorded in AGENTS.md. Poppins is static, 800 real. */
  font-family: "Poppins", var(--font-sans);
  font-size: clamp(1.6rem, 3.2vw, 2.25rem);
  line-height: 1.15;
  letter-spacing: normal;
  text-transform: uppercase;
  font-weight: 800;
  color: var(--brand);
}

/* The goals list under GOAL — one "> " line per item in data/copy.md,
   rendered by tools/copy.py as this <ul>. Each line CENTRES, inheriting
   .case .prose's own text-align — Roland's 2026-08-26 call, reversing his
   2026-08-21 ragged-left spec after seeing it live: a flush-left block whose
   short lines trail off rightward read as misaligned between a centred
   heading and centred paragraphs (his Fluent screenshot). The old effect
   took a fit-content/auto-margin/text-align:left trio; do not quietly
   restore any of it. */
.case .prose .goals {
  list-style: none;
  padding: 0;
  margin-block: 0 var(--gap-sm);
}

.case .prose .goals li + li {
  margin-top: var(--gap-xs);
}

/* The close, since 2026-09-22: title, description, then the button in
   .case__close right after it. Wider than the narrative .prose column
   (34rem) — an invitation reads cramped at the same measure as a paragraph
   of body copy, and this is the last thing on the page, not something to
   read quickly and move past. Text stays centred, same as every other
   .case .prose block; only the measure changes.

   margin-top overridden down from .case .prose's own --gap-lg on Roland's
   ask (2026-09-22): the CTA was reading as a separate section instead of the
   close of the one above it. Nudged back up from --gap-md the same day —
   too tight read as cramped — to a fixed 2rem, between --gap-md (1.5rem) and
   --gap-lg's own floor (2rem): some daylight still separates it from a shelf
   of embeds or a COMMENTS heading, it is just not a fresh section's worth.
   Every page gets the same value, whatever sits above the CTA (a shelf, a
   COMMENTS block, Initia's own narrative) — that consistency was the other
   half of the ask. */
.case .prose.case__cta {
  max-width: 46rem;
  margin-top: 2rem;
}

/* --- X embeds ------------------------------------------------------------
   The one third-party surface on this site. widgets.js keeps the blockquote
   and mounts its iframe inside it, tagging it .twitter-tweet-rendered — so the
   fallback card below is scoped to the NOT-rendered case and disappears by
   itself the moment the real embed arrives.

   No height reservation here, deliberately. It had one, and it was the wrong
   trade twice over: this block sits well below the fold so a shift costs no
   CLS, and any height guessed for the embed became a permanent hole under the
   fallback card for every visitor whose embed never arrives. X's own iframe
   animates to its content height regardless.

   margin-inline: 0 because the widget centres itself, and centred is wrong —
   it should line up with the prose it follows. */
.case__proof {
  margin-top: var(--gap-lg);
}

.case__proof .twitter-tweet,
.case__proof .x-quote {
  margin-inline: 0;
}

/* .x-quote is paired with .twitter-tweet on every fallback selector below, and
   the pair is load-bearing rather than belt-and-braces.
 *
 * initXEmbeds() now strips .twitter-tweet at startup and hands it back one card
 * at a time, so that X's widgets.js cannot auto-scan the document and render all
 * 23 embeds at once. That means a blockquote waiting to be scrolled to is an
 * .x-quote and matches nothing here — it would sit as bare unstyled text, which
 * is the exact regression the comment below describes being fixed once already.
 *
 * With JavaScript off the class is never removed, so .twitter-tweet is what
 * matches. Neither selector covers both cases; both are needed. */

/* What a visitor sees with no network, with a blocker, or if the CSP ever
   refuses the script — which is the failure this site has shipped twice. It
   must read as a deliberate quotation, not as a broken embed.

   BOTH containers, and that is the fix rather than the tidy-up it looks like.
   This was scoped to .case__proof only, so every post in a .case__shelf — the
   videos on all four case studies — degraded to bare unstyled text with no
   card around it. It went unnoticed because the styling only shows when the
   embed does NOT arrive, which is exactly the case nobody looks at. The
   comments moved from a grid to a shelf on 2026-08-14 and would have taken
   their fallback with them; instead the shelf gained one. */
.case__proof .twitter-tweet:not(.twitter-tweet-rendered),
.case__shelf .twitter-tweet:not(.twitter-tweet-rendered),
.case__proof .x-quote:not(.twitter-tweet-rendered),
.case__shelf .x-quote:not(.twitter-tweet-rendered) {
  padding: var(--gap-md);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--muted);
  font-size: var(--t-micro);
}

/* The width cap is the grid's alone: a shelf item is already sized by the
   card's flex-basis, and a 550px cap inside a 360px card would do nothing
   except stop the fallback filling the card it sits in. */
.case__proof .twitter-tweet:not(.twitter-tweet-rendered),
.case__proof .x-quote:not(.twitter-tweet-rendered) {
  max-width: 550px;
}

.case__proof .twitter-tweet:not(.twitter-tweet-rendered) p,
.case__shelf .twitter-tweet:not(.twitter-tweet-rendered) p,
.case__proof .x-quote:not(.twitter-tweet-rendered) p,
.case__shelf .x-quote:not(.twitter-tweet-rendered) p {
  margin-bottom: var(--gap-sm);
  font-size: var(--t-body);
}

.case__proof .twitter-tweet:not(.twitter-tweet-rendered) a,
.case__shelf .twitter-tweet:not(.twitter-tweet-rendered) a,
.case__proof .x-quote:not(.twitter-tweet-rendered) a,
.case__shelf .x-quote:not(.twitter-tweet-rendered) a {
  color: var(--muted-foreground);
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* Same closing shape as the portfolio: whitespace, then the one CTA. */
/* The one CTA on a case study earns more presence than the stock large
   button; sized up from .btn--lg's own clamps, same idiom. Roland's call
   2026-08-20, with the section-heading rework. */
.case__close .btn {
  height: clamp(64px, 9.6svh, 88px);
  padding-inline: clamp(36px, 4.6vw, 60px);
  font-size: clamp(1.2rem, 1.6vw, 1.45rem);
}

.case__close {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: var(--gap-md);
  padding-block: var(--gap-xl) var(--gap-page);
}


/* --------------------------------------------------------------------------
   10. Case study — the recap band, and the shelf of posts
   Added 2026-08-14. Both exist because a case study grew 22 X embeds and the
   page stopped working on a phone. Nothing here is a new visual language: the
   band is the fold's own primitive and the shelf is a plain scroll-snap row.
   -------------------------------------------------------------------------- */

/* A green recap at the top of a case study, so the piece opens with the client,
   the claim and the numbers instead of easing into them.

   .case previously opened on white, and the note in §9 said the band belongs to
   pages that are selling while a case study has already been clicked into. That
   held while a case study was an article. It stopped holding once one carried
   the only real numbers on the site: the recap IS the selling, and burying it
   in body copy wasted it.

   This does not loosen the green rule in AGENTS.md. --brand as a SURFACE is
   .band, an established primitive shared with the fold and /portfolio; this is
   that primitive on a third page, not a new green thing. The one-accent-span
   rule still governs text. */
.band--case {
  padding-bottom: clamp(1.75rem, 5svh, 3rem);
  /* Centred, same as .band--work, so the three banded pages read as one set.
     The words centre; the BODY copy below stays left — §9's .prose note
     settled that and it has not stopped being true. */
  text-align: center;
}

.band--case .case__lead,
.band--case .band__sub {
  margin-inline: auto;
}

/* A centred header wants its figures centred under it rather than pushed to
   the left edge of a column nothing else shares. */
/* .band--case .case__stats { justify-content: center } lived here. It went with
   the move from flex-wrap to grid: every track is 1fr, so the tracks consume
   the free space and there is nothing left for justify-content to distribute.
   Removed rather than left inert — a declaration describing a mechanism the
   file no longer uses is worse than no declaration. The centring visitors
   actually see comes from .band--case { text-align: center }. */

/* .case and .call each set their own safe-area top inset, sized for being the
   first thing on the page. Under a band the band owns that inset, and leaving
   theirs stacks the notch clearance twice — a visible gap on exactly the
   devices the inset exists for. /work/near is .call under a band, which is why
   both selectors are here. */
.page-case .case,
.page-case .call {
  padding-top: 0;
}

.band--case .case__headline {
  color: var(--on-brand);
}

.band--case .case__lead {
  color: var(--on-brand-muted);
}

/* .band__sub is the fold's subheadline and centres itself, which is right on a
   centred band and wrong on this one — a case study header is left-aligned so
   the lead stays readable. Used where a piece has a subheadline rather than a
   result: /work/near has no number to lead with, and .case__lead is reserved
   for one. check.py enforces that distinction, which is how this came up. */
.band--case .band__sub {
  margin-inline: 0;
  color: var(--on-brand-muted);
}

/* The stats move inside the band, which is most of why the band earns its
   place. Rules go translucent white rather than --border: a fixed grey line
   drawn for a white page reads as dirt on green. */
.band--case .case__stats {
  border-block-color: rgba(255, 255, 255, 0.22);
}

.band--case .case__stat dt {
  color: var(--on-brand-muted);
}

.band--case .case__stat dd {
  color: var(--on-brand);
}

/* Figures line up in a row and are read against each other, so they get
   tabular figures. Poppins ships them; without this the 1s in 130,588 pull the
   column crooked. Applied to the view labels for the same reason. */
.case__stat dd,
.case__shelf .eyebrow,
.case__proof .eyebrow {
  font-variant-numeric: tabular-nums;
}

/* Thirteen video embeds stacked vertically is roughly eight thousand pixels of
   scrolling on a phone, and a visitor meets the thirteenth long after they have
   stopped caring. As a shelf it is one section they swipe, which is how video
   is browsed on a phone anyway.

   Items are min(78vw, 360px) so the next card always peeks — that peek is the
   only affordance saying the row scrolls, and it costs nothing. */
.case__shelf {
  display: flex;
  /* flex-start, NOT the default stretch, and this is the whole fix for the
     jump Roland reported: scroll right to a card for the first time and a
     second later the entire row nudged up 1-2px.

     Stretch couples every card to the tallest one — all six measured exactly
     832px. X's iframes report height 0 until they become visible and settle to
     their real height a beat after that, so the first card scrolled to changes
     the row's maximum and re-stretches all 22 others. One embed settling moved
     everything, which is why it read as the text jumping rather than as a card
     resizing.

     Nothing wanted the equal heights: .case__post has no background, so the
     card box is invisible and only the iframe is seen — already ragged. This
     does not stop X reflowing inside its own cross-origin iframe. It stops that
     reflow leaving the card it happens in. */
  align-items: flex-start;
  gap: var(--gap-md);
  margin-top: var(--gap-md);
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  /* Bleed to the screen edges, then pad back in, so a card can sit flush with
     the edge as it scrolls instead of colliding with the shell's gutter.

     Two values, matching .shell, which uses inset-left for the left and
     inset-right for the right. This used inset-LEFT for both sides of both
     properties — fine in portrait, where the insets are 0 and equal, and wrong
     in landscape on a notched iPhone, where they differ by tens of pixels: the
     border box computed to wider than the viewport and the row overflowed to
     the right. */
  margin-inline: calc(max(1.5rem, env(safe-area-inset-left)) * -1)
    calc(max(1.5rem, env(safe-area-inset-right)) * -1);
  padding-inline: max(1.5rem, env(safe-area-inset-left))
    max(1.5rem, env(safe-area-inset-right));
  padding-bottom: var(--gap-sm);
}

@media (min-width: 640px) {
  .case__shelf {
    margin-inline: calc(max(2rem, env(safe-area-inset-left)) * -1)
      calc(max(2rem, env(safe-area-inset-right)) * -1);
    padding-inline: max(2rem, env(safe-area-inset-left))
      max(2rem, env(safe-area-inset-right));
  }
}

/* --shelf-w lets a shelf opt into a wider item. 360px is right for an X embed,
   which is a column of text, and cramped for 16:9 video — 360 wide is 202 tall.
   A film shelf sets it larger; everything else inherits the default. */
.case__shelf > * {
  /* 78vw, not 88vw. It became 88vw in ee5a400 alongside --shelf-w, for every
     shelf on the site, and the commit message only mentions --shelf-w.

     The sums, because they are the whole argument. Start-aligned, the peek is
     W(1-k) minus BOTH gutters: the card does not begin at the screen edge, it
     begins one 24px gutter in. So on a 375px phone:

       88vw:  375 x 0.12 - 48 = -3px    card 2 starts PAST the right edge
       78vw:  375 x 0.22 - 48 = 34.5px

     The affordance did not shrink, it disappeared. Centred, the leading gutter
     is the padding instead and the peek is W(1-k)/2 - gap, so centring costs
     half of it: 17.25px at a 24px gap, 25.25px at 16px. That is why the coarse
     block below takes the gap to 1rem — the card is settled at 78vw, so the
     gap is the only term left to spend. */
  flex: 0 0 min(78vw, var(--shelf-w, 360px));
  scroll-snap-align: start;
  margin: 0;
}

/* Films are wider than posts, and taller for it. Set on the shelf so every
   item on that row agrees. */
.case__shelf--film {
  --shelf-w: 520px;
}

/* A film reserves its own height exactly, through .ratio's aspect-ratio. The
   420px on .case__post is there for X embeds, which mount lazily and would
   otherwise collapse the card and reflow the row — a film needs none of it,
   and at --shelf-w: 520px a 16:9 film is 292.5px tall, so the reservation was
   127px of nothing between the film and the scrollbar. That gap is what Roland
   saw. .case__grid takes the same escape for the same reason. */
.case__shelf--film .case__post {
  min-height: 0;
}

/* Centre-snapping, on a touch device at phone width.
   `proximity` snaps only if you happen to stop near a snap point, so a swipe
   could leave a video sitting half off the screen — which is exactly what
   Roland described. `mandatory` plus a centre alignment means one swipe moves
   one card and lands it centred.

   THE WIDTH GATE IS NOT OPTIONAL. `pointer: coarse` is not "phone" — every
   iPad and every Android tablet reports it. 460px is arithmetic, not taste:
   360 / 0.78 = 461.5, so 460 is the last width at which the 78vw term still
   wins the min(). Above it the card is a fixed 360px, and centring a 360px
   card in a 768px iPad leaves 204px of blank either side; at 1366px it is
   503px AND the card is not even centred, because the shell has hit its
   max-width and the scrollport no longer starts at the viewport edge. Below
   460 both shelves are 78vw — films too, since 520 / 0.78 = 667 — so one
   expression covers both. px rather than rem, because the number comes from a
   vw ratio and a reader's font-size preference must not move it.

   Not on a desktop at all: a trackpad flick to an arbitrary position is normal
   there, and snapping reads as the page grabbing at you.

   The padding is what lets the FIRST and LAST card reach the centre — without
   it `mandatory` clamps them to the scroll extents and the first card stays
   jammed against the leading gutter, which was the other half of what Roland
   saw. (100vw - 78vw) / 2 is 11vw, and written that way it cannot drift out of
   step with the card. vw rather than %, because a percentage resolves against
   the CONTAINING BLOCK and this shelf's negative margins deliberately make it
   wider than its containing block.

   No scroll-padding to match: that shrinks the scrollport to a snapport, and a
   SYMMETRIC shrink leaves the centre exactly where it was, so for
   `align: center` it is a no-op — it only matters for `align: start`. It would
   also silently govern scrollIntoView() and be a second value to keep in step
   for ever.

   The gap comes down because centring costs peek — see the sums below. */
@media (pointer: coarse) and (max-width: 460px) {
  .case__shelf {
    scroll-snap-type: x mandatory;
    padding-inline: 11vw;
    gap: 1rem;
  }

  .case__shelf > * {
    scroll-snap-align: center;
  }
}

/* Each card is a view count and its post. min-height is reserved because the
   embeds mount lazily and asynchronously: without it the row's height jumps
   every time one resolves, and anything the visitor was reading moves. */
.case__post {
  display: flex;
  flex-direction: column;
  gap: var(--gap-sm);
  min-height: 420px;
}

/* The embed itself must not exceed the card. widgets.js writes an inline width
   on the iframe it injects, so this is one of the few places on this site where
   !important is the honest tool rather than a shortcut. */
.case__post iframe,
.case__shelf iframe {
  max-width: 100% !important;
}

/* A phone screenshot in a shelf — the Telegram receipts on /work/bitrobot.
   The hairline and radius are the grid tile's, so a screenshot reads as one
   more card rather than a pasted image. Height comes from the img's own
   width/height attributes, so the row cannot reflow as they lazy-load. */
.case__shot {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--border);
  border-radius: var(--radius);
}

/* Poppins reaches the fallback, the labels and the headings around each post,
   and stops at the iframe: a rendered embed is cross-origin and X sets the type
   inside it. Nothing on this page can change that, and pretending otherwise
   with a font-family on the container just moves the mismatch. */
.case__shelf .twitter-tweet:not(.twitter-tweet-rendered),
.case__shelf .x-quote:not(.twitter-tweet-rendered) {
  flex: 1;
  margin: 0;
  max-width: none;
}

/* TikTok cards — /work/movement, added 2026-08-17.

   325px is TikTok's own stated minimum for the embed, and the reason this
   shelf sets --shelf-w at all: the 360px default is a column of X text, and a
   9:16 player in it is neither. Below 460px both this and the film shelf are
   78vw anyway — see the coarse-pointer block above — so on a phone the card is
   ~292px and TikTok letterboxes slightly inside its own frame rather than
   overflowing. That was measured, not assumed; the alternative was fighting
   the 78vw peek affordance, which is load-bearing.

   NOT a .case__shelf--film: that sets 520px for a 16:9 cut, which is the wrong
   axis entirely for a vertical player. */
.case__shelf--tiktok {
  --shelf-w: 325px;
}

/* KNOWN, BOUNDED, AND NOT WORTH FIXING — written down so it is not rediscovered
   as a bug. The centre-snap block above pads the scrollport by 11vw, which is
   (100vw - 78vw)/2 and therefore exactly enough for the first and last card to
   reach the centre WHEN the card is 78vw. It stops being 78vw once the min()
   flips to the fixed width, and for this shelf that is 325/0.78 = 417px rather
   than the 461px the default assumes. So between 417px and 460px the leading
   card lands up to 9px off centre. Below 417px — every phone — the card is
   78vw and the padding is exact. Widening the shared media query to fix 43px
   of viewport would put the 360px shelves it was computed for out of step. */

/* The box holds its height whether or not the iframe has mounted, which is the
   entire reason this reuses .ratio rather than letting the iframe size itself.
   Thirty cards mount lazily and asynchronously as the row is swiped; without a
   reservation the shelf's height would jump on every one, and .case__shelf is
   already align-items: flex-start precisely because a settling embed must not
   move the cards beside it.

   The ratio is NOT 9/16. TikTok's embed is the video PLUS its own chrome — the
   handle, the caption, the music row — so a clean 9:16 crops the caption off.
   325/755 is the shape their embed actually renders at that width, measured
   rather than reasoned. If TikTok changes their chrome this is the number that
   goes stale, and it will look like a letterbox, not like a bug. */
.tiktok {
  --ratio: 325 / 755;
  flex: 1;
}

/* The reservation on .case__post is sized for an X embed and is far shorter
   than a vertical player, so the card would clamp the box it is meant to hold.
   .ratio does the reserving here, exactly as it does on a film shelf. */
.case__shelf--tiktok .case__post {
  min-height: 0;
}

/* A gated card in THIS shelf would take the players' shape — 755px like every
   neighbour, rather than sizing to its two lines of text the way it rightly
   does on /work/lfj, where the embeds around it are already ragged heights.
   Kept because tools/tiktok-embed.py --gated-card can still emit one, and a
   158px tile in a row of 755px ones reads as broken rather than as a
   deliberate note. Nothing on the site uses it today: /work/movement drops a
   video TikTok will not serve instead of explaining it. */
.case__shelf--tiktok .case__gated {
  aspect-ratio: 325 / 755;
  justify-content: center;
}

/* The resting state of every card, and it is the state most visitors will see
   for most of the shelf — initTikTokEmbeds() mounts a frame only when one is
   pressed. So this is not a fallback that happens to be visible; it is the
   card, and it has to read as "press to play" rather than as a link that
   failed to become something.

   It is still a real anchor to the post, which is what keeps /work/movement
   readable from file:// and with JS off: the handler intercepts the click, and
   where there is no handler the link simply works. Nothing in CSS or JS
   creates the affordance, so nothing can take it away. */
.tiktok > a {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--gap-md);
  padding: var(--gap-md);
  font-size: 0.9375rem;
  line-height: 1.35;
  text-align: center;
  color: var(--foreground);
  text-decoration: none;
  background: var(--muted);
}

/* The play glyph, sized to .ratio__glyph so a TikTok card and a pending film
   box read as the same control. Drawn as a pseudo-element rather than shipped
   in the markup: it is decoration, it would otherwise be thirty more copies of
   an inline SVG in the page, and tools/tiktok-embed.py stays simple.

   Ring AND triangle in ONE masked shape, which is the part worth knowing. The
   obvious construction — border-radius and a border for the ring, a mask for
   the triangle — cannot work: a mask clips the WHOLE element, so the border
   and background it is meant to sit inside get clipped away with everything
   else and only the triangle survives. Two subpaths under one mask, tinted by
   background-color, keeps it a single element and keeps the colour on a
   token instead of hard-coding it inside a data URI. */
.tiktok > a::before {
  content: "";
  flex: none;
  width: 56px;
  height: 56px;
  background-color: var(--foreground);
  -webkit-mask: var(--play-glyph) center / 56px 56px no-repeat;
  mask: var(--play-glyph) center / 56px 56px no-repeat;
}

.tiktok {
  --play-glyph: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 56 56'%3E%3Cpath fill-rule='evenodd' d='M28 0a28 28 0 1 0 0 56 28 28 0 0 0 0-56zm0 1.6a26.4 26.4 0 1 1 0 52.8 26.4 26.4 0 0 1 0-52.8z'/%3E%3Cpath d='M23.5 18.5v19l15-9.5z'/%3E%3C/svg%3E");
}

.tiktok > a:hover,
.tiktok > a:focus-visible {
  text-decoration: underline;
}

/* Between the press and the first frame. TikTok's embed takes seconds to
   paint, so the card it replaces is held underneath and pulsed rather than
   cleared — borrowing .call__pending's keyframes, which cover the Cal booking
   embed while it boots, because this is the same situation and should not look
   like a different one. The frame is transparent until it has something to
   show; initTikTokEmbeds() removes the anchor on load. */
.tiktok--loading > iframe {
  opacity: 0;
}

.tiktok--loading > a {
  animation: call-pending-pulse 1.8s ease-in-out infinite;
}

@media (prefers-reduced-motion: reduce) {
  .tiktok--loading > a {
    animation: none;
    opacity: 0.6;
  }
}

/* --------------------------------------------------------------------------
   11. The testimonial wall's heading
   The About block lived here — portrait, name, bio, keyed to
   acquisition.com's "About our founders". Removed 2026-08-14 on Roland's call,
   and its rules went with it rather than being left as dead CSS, the same way
   .case__eyebrow's did. Only the heading survives, renamed: the wall was
   already borrowing .about__heading, and a class named for a block that no
   longer exists is a small lie in a file where the names are the map.
   -------------------------------------------------------------------------- */

/* Same voice as .case .prose h2 — GOAL / VIDEOS / COMMENTS — on Roland's
   2026-08-26 call: "TESTIMONIALS should be same like the GOAL". Keep the two
   rules in step by hand; they are separate because .prose carries case-page
   margins this heading must not inherit. Brand green on text extends the
   standing exception AGENTS.md records for the case headings — still not
   permission for green surfaces. */
.wall__heading {
  font-family: "Poppins", var(--font-sans);
  font-size: clamp(1.6rem, 3.2vw, 2.25rem);
  line-height: 1.15;
  letter-spacing: normal;
  text-transform: uppercase;
  font-weight: 800;
  color: var(--brand);
}

/* Two-up for the short posts. The replies and the two team pieces are single
   lines of text, so a full-width column each wastes the page and makes seven of
   them feel like a scroll. The videos stay in the shelf — they are tall, and
   two columns of tall embeds is the stack this was escaping.

   One column below 40rem: X will not render an embed under 220px, and two
   columns on a 375px phone leaves about 160px each. */
.case__grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--gap-md);
  align-items: start;
  margin-top: var(--gap-md);
}

@media (max-width: 40rem) {
  .case__grid {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* widgets.js writes an inline width on the iframe it injects, which would
   otherwise push a grid column wider than its track. */
.case__grid .case__post,
.case__grid iframe,
.case__grid .twitter-tweet,
.case__grid .x-quote {
  max-width: 100% !important;
  margin-inline: 0;
}

/* Cards in a grid size to their content — no reserved height, because these
   are short and a min-height would leave a hole under each one. */
.case__grid .case__post {
  min-height: 0;
}

/* --------------------------------------------------------------------------
   12. A booking headline whose second line steps down (near.html, call.html)
   -------------------------------------------------------------------------- */

/* Written for NEAR: /call's two lines were taglines — "Book the call." over
   "Bring your launch date.", 23 characters — and NEAR's second line is a
   sentence at 52, which at the shared size wrapped to three rows on a phone
   and pushed the calendar off screen. On a page whose only job is the
   booker, that is the page failing.

   So the sentence steps down against the line above it instead of matching
   it, and the pair is measured so it breaks into two even rows rather than
   three ragged ones. Sized in em, so it stays in proportion as the h1 clamps.

   call.html carries the same class since 2026-09-22, on Roland's ask that the
   two pages' sub-line read the same way — muted and stepped down — rather
   than differing because one happened to be short enough not to need it. */
.call__headline--sentence {
  margin-bottom: clamp(1rem, 2.5svh, 1.75rem);
}

.call__headline--sentence .call__headline-sub {
  margin-top: 0.55em;
  font-size: 0.56em;
  font-weight: 500;
  line-height: 1.3;
  letter-spacing: normal;
  color: var(--muted-foreground);
  /* Holds the sentence to two rows at phone width instead of three, and stops
     it running the full 1170px on a desktop where it would read as body copy
     that wandered into a headline. */
  max-width: 26ch;
  margin-inline: auto;
  text-wrap: balance;
}

/* The eyebrow is gone from this page, so the headline is the first thing under
   the back control and can sit closer to it. */
.call__headline--sentence {
  margin-top: clamp(0.5rem, 2svh, 1.25rem);
}

/* A post that exists, counted, and cannot be embedded. X returns a
   TweetTombstone for anything age-restricted or login-walled, so widgets.js
   has nothing to mount — a .twitter-tweet blockquote there would sit forever
   looking like a broken embed rather than a deliberate one.

   Same box as the unrendered-blockquote fallback, so it reads as part of the
   set rather than an error. The view count above it is the point: the number
   is real whether or not X will show the video to a logged-out visitor. */
/* A line about the post, inside its card. /work/honorable-mentions needs it:
   four unrelated pieces on one page, where the interesting part is what is
   worth saying about each rather than the run of numbers. The name and the
   count share the .eyebrow above it, so this is the only prose in the card.

   .case .prose centres its text; a card is not prose and left-aligns, which
   also keeps it in line with the embed underneath. Muted, because the post is
   the evidence and this is the annotation on it. */
.case__note {
  margin: 0;
  text-align: left;
  font-size: var(--t-micro);
  line-height: 1.5;
  color: var(--muted-foreground);
  text-wrap: pretty;
}

.case__gated {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: var(--gap-sm);
  padding: var(--gap-md);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--muted);
  font-size: var(--t-micro);
  color: var(--muted-foreground);
}

.case__gated strong {
  color: var(--foreground);
  font-weight: 600;
}

.case__gated a {
  color: var(--muted-foreground);
  text-decoration: underline;
  text-underline-offset: 0.2em;
}

/* --------------------------------------------------------------------------
   13. Testimonial wall (portfolio.html)
   -------------------------------------------------------------------------- */

/* Wider than --shell on purpose. --shell is 1170px because that is a readable
   MEASURE for text; the widest shot here is 1474px and would still be shown at
   79% inside it. This is a gallery, so it gets gallery width. Everything else
   on the page keeps the shell. */
.wall {
  max-width: min(96vw, 1560px);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--gap-md);
  padding-block: var(--gap-page) 0;
  text-align: center;
}

/* Each shot is shown at the width it was CAPTURED at, not at a column width.

   This wall was a three-column flow until 2026-08-14 and it was unreadable.
   The screenshots were taken at scales from 228px to 1474px — a 6.5x spread,
   every one recorded in data/testimonials.tsv — so a uniform column rendered
   the widest conversation at 24% and blew the narrowest crop up to 150%. One
   column of huge soft text next to one column of microscopic text.

   These are screenshots, not photographs: each already has a scale at which
   its own text is legible, and forcing them to a common width is precisely
   what destroys that. So the layout gives each item its native width instead
   and lets them wrap. Small punchy crops end up two or three to a row, wide
   conversations take a row each, and nothing is rescaled to illegibility.

   --shot-w is written per item by tools/testimonials.py from the TSV. */
.wall__columns {
  width: 100%;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-items: flex-start;
  gap: var(--gap-md);
}

.wall__item {
  /* min() is what keeps the promise on a phone: never wider than the space,
     never wider than the file. Upscaling a screenshot only adds blur. */
  width: min(100%, var(--shot-w, 22rem));
  flex: 0 1 auto;
  margin: 0;
  border-radius: var(--radius);
  overflow: hidden;
  border: 1px solid var(--border);
  background: var(--muted);
  transition: border-color 120ms ease, transform 120ms ease;
}

.wall__shot {
  display: block;
  width: 100%;
  height: auto;
}

/* On a phone, lead with the Shad "hola bangers" crop.

   Roland's call: below 40rem nothing fits beside anything else, so the wall
   stops being a wall and becomes a queue — and the first thing in that queue
   is the whole first impression. This one is short, loud and legible at 294px;
   the two above it are a 96px sliver and a 260px block of conversation.

   Desktop is untouched. Those three were chosen to sit on one row together
   (see the note in data/testimonials.tsv), and reordering them there would
   break a composition rather than fix a queue.

   The selector names BOTH the position and the person on purpose. The markup
   is generated from data/testimonials.tsv by tools/testimonials.py --sync, so
   a reordered TSV would leave a bare :nth-child(3) silently promoting whatever
   landed there. Naming the href too means a reorder makes this rule stop
   matching — the wall falls back to source order, which is merely not the
   improvement, rather than promoting a stranger's screenshot. */
@media (max-width: 40rem) {
  .wall__columns > .wall__item:nth-child(3)[href$="/madladshad"] {
    order: -1;
  }
}

/* These are links to a real person's profile, so they should look clickable.
   Hover only — on a phone there is no hover and a permanent lifted state would
   just be a border nobody can explain. */
@media (hover: hover) and (pointer: fine) {
  a.wall__item:hover {
    border-color: var(--foreground);
    transform: translateY(-2px);
  }
}

a.wall__item:focus-visible {
  outline: 2px solid var(--ring);
  outline-offset: 3px;
}

@media (prefers-reduced-motion: reduce) {
  .wall__item {
    transition: none;
  }
  a.wall__item:hover {
    transform: none;
  }
}

/* --------------------------------------------------------------------------
   14. Film player (work/*.html)

   The evidence block for a case study with no X post to embed. Built by
   initFilms() in site.js from a [data-film] base path; everything inside the
   box below is JS.

   This is NOT the VSL. The fold's player exists to hold someone who has not
   decided yet, so it autoplays muted, hides its timeline behind a deliberate
   pace curve and offers no seek at all. A visitor reading a case study has
   already decided and wants the opposite: a poster that costs nothing until
   they press play, an honest timeline they can drag, sound, and fullscreen.

   MONOCHROME, and that is Roland's call rather than an oversight. §7's bar is
   a solid --brand plate; this one is a black gradient scrim with white
   controls, so nothing competes with the footage. Do not "bring it in line"
   with the VSL by making it green.
   -------------------------------------------------------------------------- */

.film {
  /* 36px matches .vsl__btn; the coarse bump below is the accessible touch
     minimum, and this is the only place the two need different answers. */
  --film-bar-h: 36px;
  /* The ceiling a capped film is allowed to reach. A 9:16 cut in a 42rem
     column is otherwise two screens tall and nobody scrolls past it. */
  --film-max-h: 72svh;
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  -webkit-user-select: none;
  user-select: none;
}

/* .ratio > video is object-fit: cover, which is right for a grid tile and
   wrong for a player: it silently crops. contain is identical when the
   declared ratio matches the file and fails safe when it does not — a
   letterbox is a visible mistake, a crop is an invisible one. Black ground so
   any bar reads as intentional. */
.film > video {
  object-fit: contain;
  background: #000;
}

/* Before the poster is handed over — see showPoster() — the element has nothing
   to paint, and the black ground above would show as a black rectangle sitting
   ON TOP of the .ratio__pending placeholder, because the video is a later
   sibling and both are positioned. Transparent until then, so the empty state
   underneath is what shows. In practice nobody sees it: the observer hands the
   poster over 400px before the card arrives. */
.film__video:not([poster]) {
  background: transparent;
}

/* NO BORDER, and this is arithmetic rather than taste — do not put it back.
   .ratio is box-sizing: border-box, so its aspect-ratio governs the BORDER
   box; the video is inset: 0, so it fills the PADDING box, which is 2px
   smaller in each direction. And (W-2)/(H-2) is always slightly WIDER than
   W/H, so a 16:9 box gave the video 670x376 — 1.7819 against 16/9's 1.7778.
   object-fit: contain duly pillarboxed a 1280x720 film into that, painting
   0.78px of the black ground down each edge: invisible in a screenshot,
   a clear black hairline at 2x, and the thing Roland spotted immediately.

   cover used to hide this by cropping the 0.78px away, which is exactly the
   silent-failure behaviour the switch to contain exists to stop — so the fix
   is to make the box honest, not to go back to cropping. With no border the
   padding box IS the border box and the declared ratio is exact.

   It costs no layout: border-box sizing means the outer size is unchanged, so
   nothing moves. .ratio--bare drops its border for a related reason. */
.film {
  border: 0;
}

/* Ratio and width cap travel together, which is why these are modifier
   classes rather than an inline --ratio: a vertical film needs both, and two
   separate authoring steps is one that gets forgotten. The three match the
   three data/work.js already groups by. */
.film--square {
  --ratio: 1 / 1;
  width: min(100%, var(--film-max-h));
  margin-inline: auto;
}

.film--tall {
  --ratio: 9 / 16;
  width: min(100%, calc(var(--film-max-h) * 9 / 16));
  margin-inline: auto;
}

/* The scrim IS the bar's background — padding-top is the fade zone, so there
   is no separate scrim node to keep in sync.

   Unlike .vsl__bar this takes padding-inline and a gap. That rule exists
   because the VSL's bar is a solid green plate whose lit cells must run flush
   into the corners; there is no plate here, so controls floating clear of the
   edges is the correct reading of the same idea. */
.film__bar {
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  z-index: 1;
  display: flex;
  flex-direction: column;
  gap: var(--gap-xs);
  padding: 1.75rem 0.5rem 0.25rem;
  background: linear-gradient(to top, rgb(0 0 0 / 0.78), rgb(0 0 0 / 0));
  color: #fff;
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.16s ease, visibility 0.16s ease;
}

/* Asymmetric, same as §7: going away is a fade, arriving is as close to
   instant as a transition gets. */
.film.is-ready.is-active .film__bar,
.film.is-ready.is-paused .film__bar {
  opacity: 1;
  visibility: visible;
  transition-duration: 0.09s;
}

@media (hover: hover) and (pointer: fine) {
  .film.is-ready:hover .film__bar {
    opacity: 1;
    visibility: visible;
    transition-duration: 0.09s;
  }
}

/* Its own block on purpose: a browser with no :has() support drops this one
   declaration rather than taking a comma-list down with it. Same reason as
   the matching rule in §7. */
.film.is-ready:has(:focus-visible) .film__bar {
  opacity: 1;
  visibility: visible;
  transition-duration: 0.09s;
}

/* Last, so it wins the tie against all three above: nothing over the poster
   but the play button. */
.film.is-ready:not(.is-started) .film__bar {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

.film__row {
  display: flex;
  align-items: center;
  gap: var(--gap-xs);
}

/* Square, like .vsl__btn — these are cells in a control bar, not buttons on a
   page — but with no hover wash. A lit square floating on a transparent scrim
   reads as a stray tile; on the VSL's solid plate it reads as a segment. Here
   the icon brightens instead. */
.film__btn {
  display: grid;
  place-items: center;
  width: var(--film-bar-h);
  height: var(--film-bar-h);
  flex: none;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: transparent;
  color: rgb(255 255 255 / 0.86);
  font: inherit;
  cursor: pointer;
  transition: color 0.15s ease;
}

.film__btn svg {
  width: 18px;
  height: 18px;
}

@media (hover: hover) and (pointer: fine) {
  .film__btn:hover {
    color: #fff;
  }
}

.film__time {
  margin-inline: 0.25rem;
  font-size: 0.75rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: rgb(255 255 255 / 0.86);
  white-space: nowrap;
}

.film__spacer {
  flex: 1;
}

/* --- the timeline ------------------------------------------------------- */

/* touch-action: none is a DELIBERATE divergence from .vsl__track, which omits
   it so a swipe starting on the rail still scrolls the page. The VSL has no
   scrubber and this one does — a drag cannot work without it. Contained by
   keeping the strip short: 20px on a mouse, 28px on a finger, which is what
   YouTube gives it too.

   Sized with height rather than padding on purpose. check.py parses the first
   @media (pointer: coarse) block and fails the build on a padding-top with no
   safe-area inset; height stays clear of that check rather than satisfying it
   by accident. */
.film__scrub {
  position: relative;
  display: flex;
  align-items: center;
  height: 20px;
  cursor: pointer;
  touch-action: none;
}

.film__rail {
  position: relative;
  width: 100%;
  height: 3px;
  border-radius: 2px;
  background: rgb(255 255 255 / 0.3);
  transition: height 0.12s ease;
}

.film__buffered,
.film__played {
  position: absolute;
  inset-block: 0;
  left: 0;
  width: 0;
  border-radius: inherit;
}

.film__buffered {
  background: rgb(255 255 255 / 0.45);
}

.film__played {
  background: #fff;
}

/* Transform-only, so it animates on the compositor. The site has no
   box-shadow anywhere and this does not introduce one. */
.film__handle {
  position: absolute;
  top: 50%;
  left: 0;
  width: 11px;
  height: 11px;
  margin-left: -5.5px;
  border-radius: 999px;
  background: #fff;
  transform: translateY(-50%) scale(0);
  transition: transform 0.12s ease;
}

@media (hover: hover) and (pointer: fine) {
  .film__scrub:hover .film__rail {
    height: 5px;
  }
  .film__scrub:hover .film__handle {
    transform: translateY(-50%) scale(1);
  }
}

.film.is-scrubbing .film__handle,
.film__scrub:focus-visible .film__handle {
  transform: translateY(-50%) scale(1);
}

/* --- volume -------------------------------------------------------------- */

.film__vol {
  display: flex;
  align-items: center;
}

/* Collapsed until the cluster is hovered or focused, so the bar is not a
   permanent stripe on a page that is mostly reading. width + opacity rather
   than display, so it can animate and so the slider keeps its tab stop. */
.film__volbar {
  display: flex;
  align-items: center;
  width: 0;
  height: var(--film-bar-h);
  opacity: 0;
  overflow: hidden;
  cursor: pointer;
  touch-action: none;
  transition: width 0.16s ease, opacity 0.16s ease;
}

.film__volrail {
  position: relative;
  width: 100%;
  height: 3px;
  border-radius: 2px;
  background: rgb(255 255 255 / 0.3);
}

.film__volfill {
  position: absolute;
  inset-block: 0;
  left: 0;
  width: 100%;
  border-radius: inherit;
  background: #fff;
}

@media (hover: hover) and (pointer: fine) {
  .film__vol:hover .film__volbar {
    width: 64px;
    opacity: 1;
    margin-inline-end: 0.35rem;
  }
}

/* :focus-visible, NOT :focus-within, and in its own block for the :has()
   reason. :focus-within latches onto whatever was last tapped, and the mute
   button is inside this cluster — so one tap on a phone pinned the volume
   slider open until something else was tapped. That is the same bug §7 records
   twice, arriving by a different door. */
.film__vol:has(:focus-visible) .film__volbar {
  width: 64px;
  opacity: 1;
  margin-inline-end: 0.35rem;
}

/* --- the poster state ---------------------------------------------------- */

.film__start {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  z-index: 0;
  display: grid;
  place-items: center;
  width: clamp(60px, 11vw, 88px);
  height: clamp(60px, 11vw, 88px);
  padding: 0;
  border: 3px solid #fff;
  /* A circle, not --radius. Every player a visitor has ever used puts a round
     button here, and it is the same shape .vsl__resume now takes so the two
     read as one family. .ratio__glyph is already round for the same reason —
     this is not a new idea, it is the existing one applied. */
  border-radius: 999px;
  background: rgb(0 0 0 / 0.55);
  color: #fff;
  cursor: pointer;
  transition: opacity 0.16s ease, visibility 0.16s ease,
    background-color 0.15s ease;
}

/* ICONS.play is M8 5.5v13l11-6.5z — vertices (8,5.5) (8,18.5) (19,12), so its
   centroid sits at x=11.67 in a 24-wide box. It wants to move RIGHT by 0.33
   units, which is 1.4% of the icon's own width and nothing like the 6% of the
   BUTTON's width that used to be here. Percentage on transform resolves
   against the element itself, so this says what it means. */
.film__start svg {
  width: 40%;
  height: 40%;
  transform: translateX(1.4%);
}

@media (hover: hover) and (pointer: fine) {
  .film__start:hover {
    background: rgb(0 0 0 / 0.72);
  }
}

/* visibility, not display: it stays out of the tab order while hidden, so a
   keyboard user does not hit a second dead "Play" stop, and the fade can
   still run. Same reasoning as .vsl__resume. */
.film.is-started .film__start,
.film.is-started .film__badge {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

/* The duration, hand-declared via data-duration because preload="none" means
   the file has not been touched yet and video.duration is NaN until it is. */
.film__badge {
  position: absolute;
  right: 0.6rem;
  bottom: 0.6rem;
  z-index: 0;
  padding: 0.15rem 0.4rem;
  border-radius: 4px;
  background: rgb(0 0 0 / 0.72);
  color: #fff;
  font-size: 0.75rem;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  line-height: 1.4;
  transition: opacity 0.16s ease, visibility 0.16s ease;
}

/* --- focus --------------------------------------------------------------- */

/* The global ring is --ring, near-black, which disappears against video.
   Inset on the bar controls so it stays inside its cell; outset on the centre
   button, which has room. Same split as §7. */
.film__btn:focus-visible {
  outline: 2px solid #fff;
  outline-offset: -2px;
}

/* The two sliders ring their RAIL, not themselves. Their hit areas are 20px
   and 28px tall so a finger has something to land on, but the visible control
   inside is a 3px line — so an outline on the element drew a big hollow box
   around empty space and read as a rendering fault rather than as focus.
   Ringing the rail says the same thing about the thing you can actually see. */
.film__scrub:focus-visible,
.film__volbar:focus-visible {
  outline: none;
}

.film__scrub:focus-visible .film__rail,
.film__volbar:focus-visible .film__volrail {
  outline: 2px solid #fff;
  outline-offset: 4px;
}

.film__start:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
}

/* --- fullscreen ---------------------------------------------------------- */

/* The box carries a locked aspect-ratio, which is the whole point of .ratio
   in the page and exactly wrong once the box IS the screen. */
.film:fullscreen {
  --film-bar-h: 48px;
  width: 100%;
  height: 100%;
  max-width: none;
  aspect-ratio: auto;
  border: 0;
  border-radius: 0;
  background: #000;
}

/* The same rule again, in its OWN block, because a browser throws away a whole
   rule for one selector it does not recognise — the reason :has() is isolated
   above. Safari before 16.4 has no Element.requestFullscreen, so
   toggleFullscreen() falls through to webkitRequestFullscreen, and that
   element matches :-webkit-full-screen and NOT :fullscreen. Without this the
   film sits at article size, still ratio-locked, in the middle of a black
   screen. */
.film:-webkit-full-screen {
  --film-bar-h: 48px;
  width: 100%;
  height: 100%;
  max-width: none;
  aspect-ratio: auto;
  border: 0;
  border-radius: 0;
  background: #000;
}


/* --- touch --------------------------------------------------------------- */

@media (pointer: coarse) {
  .film {
    --film-bar-h: 44px;
  }

  /* A wider strip to aim at, and a rail that does not need hovering to be
     visible — there is no hover here to swell it. */
  .film__scrub {
    height: 28px;
  }

  .film__rail {
    height: 5px;
  }

  .film__handle {
    transform: translateY(-50%) scale(1);
  }

  /* The OS owns volume on a phone, so an in-page slider is a control that
     duplicates the hardware buttons and steals a tap target. Mute stays. */
  .film__vol:has(:focus-visible) .film__volbar {
    width: 0;
    opacity: 0;
    margin-inline-end: 0;
  }
}

/* --- the article wrapper -------------------------------------------------- */

/* Same shape as .case__post, so a film and an X embed read as the same kind of
   evidence in the same column. 42rem matches .prose. */
.case__film {
  display: flex;
  flex-direction: column;
  gap: var(--gap-sm);
  max-width: 42rem;
  margin-inline: auto;
  margin-top: var(--gap-lg);
}

/* .case .prose centres its text, and this sits inside that. The count belongs
   over the left edge of the film, not floating above its middle. */
.case__film .eyebrow {
  text-align: left;
  font-variant-numeric: tabular-nums;
}

/* An explicit end-of-row, written by tools/testimonials.py where the TSV has a
   --- marker. flex-wrap fills greedily, so grouping shots in the data file is
   not enough on its own to keep them on a row of their own: whatever comes next
   climbs into the spare space on the right. A zero-height item forced to full
   width ends the line.

   No margin, and it must not eat a gap — hence the negative margin cancelling
   the row-gap it would otherwise add. */
.wall__break {
  flex: 0 0 100%;
  height: 0;
  margin-block: calc(var(--gap-md) * -1) 0;
}
