/* ============================================================
   TOKENS
   ============================================================

   Structure (scale, spacing, radii, container, material) follows the
   existing design system. Colour is the palette established earlier —
   warm greige ground, near-black ink, one green accent — mapped onto
   that system's token names.

   Contrast (WCAG 2.1, vs. the surface it sits on):
     ink        on bg   17.4 : 1  AAA
     ink-soft   on bg    6.5 : 1  AA   body copy
     ink-dim    on bg    3.3 : 1  AA   large text only (34px+ names)
     accent-ink on card  6.9 : 1  AA   small blue text
     accent     on bg    3.5 : 1  AA   non-text graphics
   ============================================================ */
:root {
  /* ---- type ----
     Inter leads, then the full system stack — so a machine without the
     webfont still lands on the platform's own UI face rather than
     Arial, and emoji resolve to colour fonts instead of tofu. */
  /* Instrument Sans: a grotesque with flat, cut terminals and tight
     apertures — it holds an edge at display size where Inter goes soft
     and neutral. Open-licensed, so it can be served from anywhere. */
  --ff-sans: "Instrument Sans", "Inter", ui-sans-serif, system-ui,
    -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
  /* Darker Grotesque carries every large size: narrow, tall, tightly
     spaced, with real personality at 50px. It is a display face, so it
     stops where the display sizes stop — at 13px those same narrow
     counters close up and it becomes hard to read. The UI face below
     takes over from there. */
  --ff-display: "Darker Grotesque", "Instrument Sans", ui-sans-serif,
    system-ui, "Helvetica Neue", Arial, sans-serif;
  --ff-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo,
    Consolas, monospace;
  /* Instrument Serif: the serif sibling of the sans already in use
     above, same foundry pairing rather than an unrelated font -- an
     editorial, single-weight face for the case study pages, where the
     brief called for something more delicate than the product-UI
     grotesques carry. Regular + italic only (the only two styles the
     face ships), so emphasis inside it is italic, never bold. */
  --ff-serif: "Instrument Serif", Georgia, "Times New Roman", serif;

  /* Two weights, and each one has a job:
       regular  every piece of running text
       medium   the project names, titles, labels — anything that has
                to hold its own against the mark
     A third would have to earn its place by doing something these two
     cannot. Light was carrying the project names and lost them to
     medium; semibold was carrying one hover state and lost it too. */
  --fw-regular: 400;
  --fw-medium: 500;
  --fw-display: 600;   /* Darker Grotesque only, where it reads as medium */

  /* The size ramp. Everything on the page is one of these — the only
     fluid value is the display size, which is the one thing that has
     to answer to the width of the window. */
  --fs-12: 0.75rem;
  --fs-13: 0.8125rem;
  --fs-15: 0.9375rem;
  --fs-18: 1.125rem;
  --fs-21: 1.3125rem;
  --fs-28: 1.75rem;
  --fs-34: 2.125rem;
  --fs-44: 2.75rem;   /* the largest fixed step: sheet headings */
  /* Darker Grotesque draws small for its point size — it is a narrow
     face with a modest cap height — so the display step runs larger
     than the reference's to land at the same optical size. */
  --fs-display: clamp(52px, 4.4vw, 80px);

  /* Four line heights, from flat to prose. Loose one-off numbers —
     1.1, 1.24 — were doing the same job as these with worse names. */
  --lh-flat: 1;
  --lh-tight: 1.15;
  --lh-body: 1.5;
  --lh-prose: 1.6;

  /* Tracking tightens as type grows and opens as it shrinks, which is
     how type behaves optically: --ls-display for the names, --ls-tight
     for titles, --ls-mono to loosen small monospace, --ls-label for
     the uppercase micro-labels. */
  --ls-display: -0.018em;   /* the face is already tight; -0.035 crashed */
  --ls-tight: -0.01em;
  --ls-mono: 0.01em;
  --ls-label: 0.09em;

  /* ---- space ---- */
  --sp-1: 4px;
  --sp-2: 8px;
  --sp-3: 12px;
  --sp-4: 16px;
  --sp-5: 24px;
  --sp-6: 32px;
  --sp-7: 48px;
  --sp-8: 64px;
  --sp-9: 96px;
  --sp-10: 128px;

  /* ---- shape ---- */
  /* Two radii, φ apart: the shell is generous, the media panel nested
     inside it is smaller so the inset reads as deliberate rather than
     as a rounding error. 26 / 1.618 ≈ 16. */
  --radius-card: 16px;
  --radius-feature: 26px;
  --radius-control: 10px;
  --radius-round: 999px;

  --measure: 38rem;
  --container-max: 1280px;
  --gutter: clamp(20px, 5vw, 64px);

  /* ---- golden ratio ----
     φ drives the island's proportions: the closed bar's height, the
     open panel's width, and the step between them are all powers of
     φ off a single 46px seed. Font sizes stay on the existing scale. */
  --phi: 1.618;
  /* ---- island ----
     A black shell with a yellow plate nested in it. Every measurement
     below is a step off the existing space scale, and the type is the
     card scale unchanged: plate values are .card-title, everything
     small is 13px — mono for anything numeric. */
  /* Concentric by construction: the frame is a full space step, and
     the shell's radius is the plate's plus that step, so the two
     curves stay parallel at every point.
        plate   60px  ->  radius 30 (a pill)
        frame   12px  ->  shell radius 42
        open    row radius 26 + frame -> shell radius 38
     The handle is 3px in a 12px band: 4.5px of shell above and below
     it, so it sits inside the frame instead of straddling an edge. */
  --island-plate-h: 60px;                 /* one row: value + label   */
  --island-frame: var(--sp-3);            /* shell showing round it   */
  --island-handle-h: 3px;
  /* White on the black shell needs more alpha than black on white to
     land at the same apparent weight — one value per theme, not one
     value for both. */
  --island-handle-alpha: 0.62;
  --island-h: 84px;                       /* plate + an even frame    */
  --island-w: 404px;
  --island-w-open: 384px;
  --island-space: 128px;                  /* island + clearance       */

  /* ---- mobile card ----
     It never reaches the edges: the margin is the larger of a fixed
     floor and a share of the screen, so the card breathes on a small
     phone and stops growing on a wide one. */
  --deck-card-margin: max(28px, 9vw);
  --deck-card-max: 380px;
  /* what the header occupies before the wheel may start: page padding
     + the nav pill + a little air. A declared value, not a measured
     one, so the resting position cannot depend on when the webfont
     lands or on whether a scrollbar is up at that instant. */
  --deck-safe-top: 86px;

  /* The shell inverts against the page — near-black on the light
     ground, near-white on the dark one — which is exactly what the
     site's chip-on tokens already mean. Solid, not frosted: the
     island is the one element that should never recede. */
  --island-shell: var(--chip-on-bg);
  --island-shell-border: rgba(250, 250, 250, 0.14);
  --island-shell-ink: var(--chip-on-text);
  --island-shell-ink-dim: rgba(250, 250, 250, 0.6);
  --island-hairline: rgba(250, 250, 250, 0.2);
  --island-hairline-hover: rgba(250, 250, 250, 0.45);
  --island-wash: rgba(250, 250, 250, 0.09);

  --island-yellow: #f5ec00;
  --island-ink: #101106;
  --island-plate-ring: rgba(16, 17, 6, 0.1);
  /* Ink levels on the plate, checked against #f5ec00:
        full   #101106            18.3 : 1
        dim    72% -> #57550a      7.1 : 1   AA at 11px
        faint  60% -> #6e6b0b      5.2 : 1   AA at 11px
     Anything lighter looked right and measured as a fail. */
  --island-ink-dim: rgba(16, 17, 6, 0.72);

  /* iOS-ish spring: overshoots a hair, then settles */
  --spring: cubic-bezier(0.34, 1.42, 0.5, 1);

  /* How far the preview card rides above the centre of the name list.
     The two are deliberately off-axis — the titles sitting lower than
     the card is what gives the pairing its diagonal. */
  --card-lift: 96px;

  /* ---- colour ----

     The dark theme is the one that works, so this is not a light theme
     designed separately — it is that one translated. What carries over
     is not the values but the RELATIONSHIPS between them, measured off
     the dark theme and matched here:

                        dark        light
       card / page      1.07 : 1    1.07 : 1
       panel / card     1.06 : 1    1.04 : 1
       primary / card  18.2  : 1   19.0  : 1
       body / card      7.8  : 1    7.5  : 1
       muted / card     5.7  : 1    5.2  : 1
       border / card    2.2  : 1    1.8  : 1

     Same steps, same separations, mirrored around the middle. Every
     grey is r = g = b; the only colours anywhere are the two accents. */
  --paper-0: #ffffff;
  --paper-50: #fafafa;
  --page-bg: #f2f2f2;

  --color-bg: var(--page-bg);
  --color-bg-subtle: #ebebeb;

  --card-bg: var(--paper-50);
  --panel-bg: var(--paper-0);
  --panel-bg-alt: var(--color-bg-subtle);

  --project-media-backplate: #f2f2f2;

  --color-text-primary: #0a0a0a;
  --color-text-body: #525252;
  --color-text-muted: #6a6a6a;
  --color-border: var(--edge);

  --ui-text: rgba(10, 10, 10, 0.9);
  --ui-text-dim: rgba(10, 10, 10, 0.45);

  /* ---- accents: two, and only two ---- */
  --accent: #1f49c4;
  --accent-ink: #1f49c4;
  --accent-wash: rgba(31, 73, 196, 0.09);

  --chip-on-bg: #0a0a0a;
  --chip-on-text: #ffffff;

  --badge-bg: #1f49c4;
  --badge-text: #ffffff;

  --chip-border: #bdbdbd;
  --chip-border-hover: #949494;

  --line: #0a0a0a;
  --bezel: #151515;

  /* ---- edges and elevation ----
     A hairline that draws the object, one soft shadow that says how far
     above the page it sits, and nothing else. Shadows are pure black at
     low alpha: a shadow with any hue in it tints what it falls on. */
  --edge: rgba(0, 0, 0, 0.12);

  --elev-2: 0 8px 24px -12px rgba(0, 0, 0, 0.1);
  --elev-3: 0 24px 60px -28px rgba(0, 0, 0, 0.22);

  --shadow-card: var(--elev-2);
  --shadow-phone: 0 10px 26px -14px rgba(0, 0, 0, 0.28);

  /* ---- material (the nav pill, the island shell) ---- */
  --material-bg: rgba(255, 255, 255, 0.72);
  --material-border: var(--edge);
  --material-blur: 22px;
  --material-saturate: 150%;
  --material-backdrop: blur(var(--material-blur)) saturate(var(--material-saturate));
  --material-shadow: 0 8px 26px -14px rgba(0, 0, 0, 0.12);

  --plate-bg: var(--material-bg);
  --plate-border: var(--material-border);
  --plate-shadow: var(--material-shadow);
  --plate-backdrop: var(--material-backdrop);
  --plate-chip: rgba(10, 10, 10, 0.07);

  --scrim-bg: rgba(0, 0, 0, 0.32);
  --sheet-bg: var(--paper-0);

  /* ---- cursor ---- */
  --cursor-blue: #1f49c4;
  --cursor-ring: #d8cf00;
  --cursor-dot: 11px;
  --cursor-ease: cubic-bezier(0.33, 1.18, 0.37, 1);

  --ease: cubic-bezier(0.22, 1, 0.36, 1);

  /* ---- cursor ----
     The same two accents: blue at rest, the plate's yellow for the
     tap ring. There is no third colour on this site. */
  --cursor-blue: #1f49c4;
  --cursor-ring: #d8cf00;
  --cursor-dot: 11px;
  --cursor-ease: cubic-bezier(0.33, 1.18, 0.37, 1);

  color-scheme: light;
}

/* ------------------------------------------------------------
   Dark — a translation, not an inversion.

     · Ground is a desaturated near-black, never #000; ink tops out
       below #fff. Both avoid halation at extreme contrast.
     · Elevation reverses: surfaces lighten as they come forward,
       because light-mode shadows carry no weight on a dark ground.
     · The blue is lifted and desaturated (#7d9dfa): the light accent
       reads 2.1:1 here, this one 7.9:1 — so one token safely covers
       graphics and small text alike.
     · The inverted pill flips: light chip, dark label.
     · Hairlines become light-on-dark alphas.
   ------------------------------------------------------------ */
[data-theme="dark"] {
  /* Elevation reverses in the dark: a card is read as raised because
     it is lighter than the page, not because of a shadow nobody can
     see. The old card sat almost on top of the page — 1.1:1 — so the
     cards were floating on nothing. */
  --paper-0: #171717;
  --paper-50: #101010;
  --page-bg: #050505;

  --color-bg: var(--page-bg);
  --color-bg-subtle: #1c1c1c;

  --card-bg: var(--paper-50);
  --panel-bg: var(--paper-0);
  --panel-bg-alt: var(--color-bg-subtle);

  /* Contrast, measured against the surface each one actually sits on
     (card #101010 in this theme, page #050505):

       primary  #fafafa  on card   18.2 : 1   AAA
       body     #a6a6a6  on card    7.8 : 1   AA at any size
       muted    #8c8c8c  on card    5.7 : 1   AA at any size

     All three are r = g = b, like the light ones. Muted carries the
     project names, so it has to clear AA at body size, not just at
     the display size the names happen to use. */
  --color-text-primary: #fafafa;
  --color-text-body: #a6a6a6;
  --color-text-muted: #8c8c8c;
  --color-border: var(--edge);

  --ui-text: rgba(250, 250, 250, 0.9);
  --ui-text-dim: rgba(250, 250, 250, 0.45);

  /* The blue lifts and desaturates for the dark ground: #1f49c4 would
     measure 2.1:1 here, #7d9dfa measures 7.9:1 — one token that covers
     graphics and small text alike, as the light one does. */
  --accent: #7d9dfa;
  --accent-ink: #7d9dfa;
  --accent-wash: rgba(125, 157, 250, 0.15);

  --chip-on-bg: #fafafa;
  --chip-on-text: #050505;

  --badge-bg: #7d9dfa;
  --badge-text: #0a1029;

  /* Screenshots still sit on a light plate here — product UI designed
     against white reads wrong on a dark one — but pure #fff inside a
     dark card is a glare source. A half step down keeps the intent
     and takes the punch out of it. */
  --project-media-backplate: #ededed;

  /* the tag and filter outlines: 2.0:1 against the card they sit on,
     up from 1.4:1 — enough to read as an edge without becoming a
     second piece of ink competing with the label inside it */
  --chip-border: #4a4a4a;
  --chip-border-hover: #626262;

  /* shell ink is dark here — every alpha struck from it flips too */
  /* Dark mode does not mean inverting the light design. The shell was
     near-white here, which put the brightest block on the page inside
     the darkest area and read as glare — and it broke this theme's own
     rule that surfaces lighten as they come forward, never to white.
     So the island is an elevated dark surface with a hairline edge,
     and the plate stays the only lit thing on it. */
  --island-shell: var(--color-bg-subtle);
  --island-shell-border: rgba(250, 250, 250, 0.1);
  --island-shell-ink: var(--color-text-primary);
  --island-shell-ink-dim: var(--color-text-muted);

  --island-yellow: #f0e600;
  --island-plate-ring: rgba(16, 17, 6, 0.16);
  --island-handle-alpha: 0.4;

  --island-hairline: rgba(250, 250, 250, 0.14);
  --island-hairline-hover: rgba(250, 250, 250, 0.34);
  --island-wash: rgba(250, 250, 250, 0.07);

  --line: #fafafa;
  --bezel: #050505;

  --cursor-blue: #7d9dfa;
  --cursor-ring: #f5ec00;

  --material-bg: rgba(32, 35, 40, 0.62);
  --material-border: rgba(255, 255, 255, 0.1);
  --material-shadow: 0 10px 28px -14px rgba(0, 0, 0, 0.6);
  --plate-chip: rgba(255, 255, 255, 0.12);

  --scrim-bg: rgba(0, 0, 0, 0.62);
  --sheet-bg: var(--page-bg);          /* the ground, near black */

  /* Same two parts, re-aimed. On a near-black ground a shadow has
     almost no room to be seen, so the hairline carries the separation
     and the shadow only deepens it. */
  --edge: rgba(255, 255, 255, 0.12);

  --elev-2: 0 10px 28px -14px rgba(0, 0, 0, 0.8);
  --elev-3: 0 28px 64px -30px rgba(0, 0, 0, 0.9);

  --shadow-card: var(--elev-2);
  --shadow-phone: 0 12px 30px -12px rgba(0, 0, 0, 0.65);

  color-scheme: dark;
}

/* ============================================================
   BASE
   ============================================================ */
* { box-sizing: border-box; }

html, body {
  margin: 0;
  padding: 0;
  background: var(--color-bg);
  color: var(--color-text-primary);
  font-family: var(--ff-sans);
  font-size: 16px;
  line-height: var(--lh-body);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
  -moz-tab-size: 4;
  tab-size: 4;
  -webkit-tap-highlight-color: transparent;
}

body { overflow-x: hidden; }

/* ---- loading grid: first-paint splash, ~2s, then fades out ----
   Fixed brand colors (yellow/black/blue) on purpose -- this is a splash,
   not themed page content, so it stays identical in light and dark.
   Three layered backgrounds on one element: vertical/horizontal
   hairlines, and a repeating dot -- the dot's own gradient is centered
   in ITS tile by default, which lands it in the middle of each cell;
   shifting just that layer's background-position by half a cell moves
   its center onto the tile's corner instead, which is where the lines
   actually cross. --grid-cell (column width) is calc(100vw/8), fixing
   the COLUMN COUNT rather than a px cell size, so the grid is the same
   density on desktop and mobile instead of a fixed cell size reading as
   huge squares on a phone or a tight mesh on an ultrawide. --grid-cell-h
   is set from the inline script right below this element (rounds the
   viewport height to a whole number of rows at ~that same cell size) --
   it defaults to --grid-cell here only for that first instant before
   the script runs, so a whole-number row count is what actually avoids
   a cut-off partial row at the bottom, not a fixed square cell. */
.loading-grid {
  --grid-cell: calc(100vw / 8);
  --grid-line-w: 3px;
  --grid-dot-r: 4px;
  --grid-dot-r-blur: 4.5px;
  position: fixed;
  inset: 0;
  z-index: 9999;
  /* Fixed brand yellow regardless of theme, same as always -- a themed
     version of this was tried and reverted, back to the original. */
  background-color: var(--island-yellow);
  /* Collapses to a point at the center rather than just fading in
     place -- "disappearing fast toward the middle" to put emphasis on
     where the caption narrative is about to appear, like an iris
     closing rather than a cross-dissolve. The expo-out curve (steep at
     first, a long smooth settle at the end) is what makes it read as a
     polished, deliberate close instead of a mechanical linear wipe --
     still quick, just not abrupt. Delayed 0.16s so the mark's own scale
     pop (above) plays first -- a beat of "attention" before the collapse
     follows it, instead of both firing at once. clip-path does the
     actual hiding; the opacity dip at the very end is just to avoid a
     hard 1px-circle flash right before removal. */
  clip-path: circle(150% at 50% 50%);
  opacity: 1;
  transition: clip-path 0.65s cubic-bezier(0.16, 1, 0.3, 1) 0.16s, opacity 0.15s linear 0.66s;
}
.loading-grid.is-hidden {
  clip-path: circle(0% at 50% 50%);
  opacity: 0;
  pointer-events: none;
}
/* The grid pattern (lines + dots) lives on its own layer, separate
   from .loading-grid's own solid background-color, specifically so it
   can fade out to a "pure"/clean screen once the percent readout hits
   100% (.is-revealing) -- leaving just the flat yellow behind the
   tagline/button, rather than the grid lines still running underneath
   them. Trying to fade a background-image's own opacity independent of
   background-color isn't possible on one element, hence the separate
   pseudo-element. */
.loading-grid::before {
  content: "";
  position: absolute;
  inset: 0;
  background-image:
    linear-gradient(to right, #000 var(--grid-line-w), transparent var(--grid-line-w)),
    linear-gradient(to bottom, #000 var(--grid-line-w), transparent var(--grid-line-w)),
    radial-gradient(circle, var(--accent) var(--grid-dot-r), transparent var(--grid-dot-r-blur));
  background-size: var(--grid-cell) var(--grid-cell-h, var(--grid-cell));
  background-position:
    -1px -1px,
    -1px -1px,
    calc(-1px - var(--grid-cell) / 2) calc(-1px - var(--grid-cell-h, var(--grid-cell)) / 2);
  opacity: 1;
  transition: opacity 0.4s var(--ease);
}
.loading-grid.is-revealing::before {
  opacity: 0;
}
@media (min-width: 901px) {
  .loading-grid { --grid-dot-r: 7px; --grid-dot-r-blur: 7.5px; }
}
/* Big and centered -- a hero-scale splash mark, not a preview of the
   small corner logo underneath. It collapses away with the rest of the
   grid toward the same center point (see .loading-grid's clip-path),
   so there's nothing to visually match against the header's own tiny
   logo -- they read as two different, deliberate beats (splash, then
   site) rather than one thing jumping into another. */
.loading-grid-mark {
  position: absolute;
  top: 50%;
  left: 50%;
  translate: -50% -50%;
  height: 18vh;
  width: auto;
  color: #000;
  opacity: 1;
  /* A small deliberate pop right as the close starts (see .is-hidden
     below and the delayed clip-path on .loading-grid) -- a call to
     attention before the collapse, instead of the circle just cutting
     into the letterform's edges with nothing leading into it, which is
     what read as a stray glitch/jump. */
  scale: 1;
  transition: scale 0.16s var(--ease), opacity 0.3s var(--ease);
}
.loading-grid.is-hidden .loading-grid-mark {
  scale: 1.12;
}
/* Fades out on its own once the percent readout hits 100%, BEFORE the
   tagline/button reveal takes its place (see the JS timing in
   index.html) -- they used to appear on top of each other, competing
   for attention in the same beat. */
.loading-grid-mark.is-fading {
  opacity: 0;
  scale: 0.94;
}

/* The reveal: a short tagline plus the one deliberate ask this splash
   makes (see .loading-grid-start below). Sits in the same centered
   spot the mark just vacated. */
.loading-grid-reveal {
  position: absolute;
  top: 50%;
  left: 50%;
  translate: -50% -50%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-5);
  opacity: 0;
  scale: 0.96;
  pointer-events: none;
  transition: opacity 0.5s var(--ease), scale 0.5s var(--spring);
}
.loading-grid-reveal.is-visible {
  opacity: 1;
  scale: 1;
  pointer-events: auto;
}
/* Real bug: bumping the gap a little still left the whole group
   centered as one tight knot, with the actual empty space pushed
   above and below IT rather than removed -- a taller gap between 3
   short lines barely moves the needle against a whole phone screen.
   Spanning a real slice of the viewport (top/bottom instead of a
   single centered point) and letting space-between spread the three
   pieces across it is what actually uses the height instead of just
   padding around a still-tiny cluster. */
@media (max-width: 640px) {
  /* Real bug, finally isolated: only `left` was set, no explicit
     width -- an absolutely-positioned flex container with no width
     shrinks-to-fit its content instead of actually spanning the space
     the vw-based max-widths below assumed it had. Every child then
     percentage-computed against that shrunken auto width, not the
     viewport, so nothing downstream (font-size, max-width, text-wrap)
     could have worked right no matter what value was tried. Giving the
     container itself a real width fixes the actual cause. */
  .loading-grid-reveal {
    top: 18vh;
    left: 50%;
    width: 92vw;
    translate: -50% 0;
    height: auto;
    justify-content: space-between;
    align-items: stretch;
    gap: var(--sp-6);
  }
  .loading-grid-reveal.is-visible { translate: -50% 0; }
  /* The stretch above is only meant for the text lines -- the button
     should stay its own natural pill width, centered, not balloon out
     to the full 92vw. */
  .loading-grid-start { align-self: center; }
}
.loading-grid-tagline {
  margin: 0;
  font-family: var(--ff-display);
  font-weight: var(--fw-display);
  font-size: var(--fs-28);
  letter-spacing: var(--ls-tight);
  color: #000;
  text-align: center;
  white-space: nowrap;
}
.loading-grid-tagline span { color: rgba(0, 0, 0, 0.45); margin: 0 0.15em; }
@media (max-width: 640px) {
  /* Not shown on mobile -- the question alone carries the ask at this
     size, and the tagline read as redundant clutter underneath it. */
  .loading-grid-tagline { display: none; }
}

/* The question sits between the tagline and the CTA -- a smaller,
   plainer voice (sans, not display) so it reads as a prompt rather
   than a second headline competing with the tagline above it. */
/* Bigger than the tagline above it now, on purpose -- this is the
   actual point being made ("you're here for the work"), the tagline
   is just the lead-in rhythm before it. Switched to the display
   family/weight to carry that size instead of just scaling up the
   sans, which read as thin at this size. */
.loading-grid-question {
  margin: 0;
  max-width: min(90vw, 60ch);
  font-family: var(--ff-display);
  font-weight: var(--fw-display);
  font-size: var(--fs-44);
  line-height: 1.2;
  letter-spacing: var(--ls-tight);
  color: #000;
  text-align: center;
  /* Wide enough now that this sentence fits on one line on most
     desktop widths -- text-wrap:balance still keeps it from breaking
     unevenly on the narrower viewports where it does wrap. */
  text-wrap: balance;
}
@media (max-width: 640px) {
  /* Sized off the viewport itself (clamped) instead of a fixed step on
     the scale, so this actually fills the width edge-to-edge (minus a
     safe side margin) rather than reading as one more modestly-sized
     line of text floating in the middle of the plate. */
  /* text-wrap:balance (set above for desktop) deliberately keeps every
     line the same short width instead of filling the container edge to
     edge -- exactly why this kept reading as a narrow, cramped column
     no matter how wide max-width was set. Plain wrapping actually uses
     the full line width. */
  /* fs-34 at this container width only ever fits ~2 words per line no
     matter what text-wrap or max-width say -- the font itself was too
     big for the space, forcing a tall narrow column of short lines.
     Dropping one step to fs-28 is what actually lets lines run longer
     and use the real width. */
  .loading-grid-question { font-size: var(--fs-28); font-weight: 700; max-width: 100%; text-wrap: normal; }
}

/* A real 0->100% readout, not just a timer -- mono (same face the
   island's own stat readouts use) and its heaviest weight, since "un
   poco más bold" is exactly what fw-medium buys over the sans's
   regular. Anchored to the grid's own lines (--grid-cell/-h, the same
   custom properties the grid pattern itself uses, inherited down from
   .loading-grid) rather than the page's unrelated gutter/sp-5 tokens --
   one cell in from the corner, so it starts right on an intersection
   instead of floating at an offset the grid never actually lines up
   with. */
.loading-grid-percent {
  position: absolute;
  bottom: calc(var(--grid-cell-h, var(--grid-cell)) - 1px);
  left: calc(var(--grid-cell) - 1px);
  /* line-height:1 (not the default ~1.5) so the box hugs the glyphs'
     actual ink -- with the default leading, the anchored bottom-left
     corner was several px of empty space below the visible number,
     which read as "floating in the middle of the cell" rather than
     pinned to the intersection it's actually anchored to. */
  line-height: var(--lh-flat);
  font-family: var(--ff-mono);
  font-weight: var(--fw-medium);
  font-size: var(--fs-34);
  font-variant-numeric: tabular-nums;
  color: #000;
  opacity: 1;
  transition: opacity 0.4s var(--ease);
}
/* Once it reaches 100%, its job is done -- fades out along with the
   mark/grid pattern rather than sitting there as a stale "100%"
   through the whole reveal. */
.loading-grid-percent.is-done {
  opacity: 0;
}
@media (max-width: 900px) {
  .loading-grid-percent { font-size: var(--fs-28); }
}

/* The one deliberate ask this splash makes: a real click is the only
   way any browser ever unlocks audio, so rather than leave that to
   chance, this button IS the first gesture. Solid black in both
   themes -- unlike the rest of the splash (now theme-matched), this
   one stays a fixed, confident color on purpose so it reads as a clear
   call to action rather than a subtle system-style control. Visibility
   itself is driven by the parent .loading-grid-reveal fading in, once
   the mark has finished fading out (see index.html's timing). */
.loading-grid-start {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  padding: var(--sp-3) var(--sp-6);
  border: none;
  border-radius: var(--radius-round);
  background: #000;
  color: #fff;
  font-family: var(--ff-mono);
  font-weight: var(--fw-medium);
  font-size: var(--fs-15);
  letter-spacing: var(--ls-tight);
  cursor: pointer;
  transition: background 0.3s var(--ease), scale 0.15s var(--ease);
}
@media (hover: hover) {
.loading-grid-start:hover { background: #1a1a1a; }
}
.loading-grid-start:active { scale: 0.97; }
/* JS calls .focus() on this button the instant it appears (needed so
   a keyboard user can act on it right away) -- but that leaves the
   browser's default square focus ring sitting on top of a pill-shaped
   button until the next click elsewhere clears it. A visible focus
   state still matters for keyboard users, so it's redrawn to follow
   the button's own shape instead of just being removed. */
.loading-grid-start:focus { outline: none; }
.loading-grid-start:focus-visible {
  outline: 2px solid #fff;
  outline-offset: 2px;
  box-shadow: 0 0 0 4px rgba(0, 0, 0, 0.35);
}

/* Visually hidden, still announced. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* Inverted selection, drawn from our own ink — same idea, our palette. */
::selection {
  background: var(--color-text-primary);
  color: var(--paper-0);
}

/* Keyboard focus only — clears 3:1 on both grounds. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: var(--radius-control);
}

.ui {
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  /* the island is fixed; reserve its lane so it never sits on content */
  padding: var(--sp-5) var(--gutter) var(--island-space);
}

/* ============================================================
   HEADER
   ============================================================ */
.header {
  /* fixed, not sticky: .ui (which it used to live inside, layout-wise)
     only starts after the 100svh hero, so "sticky" had nowhere to stick
     to during the hero itself — logo + nav need to be visible from the
     very first frame, not just once scrolled past the intro. Detached
     from .ui's own padding as a result, so it repeats the same gutter
     itself; see .ui's padding-top override below for the compensating
     clearance this creates for the content underneath it. */
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  padding: var(--sp-5) var(--gutter) 0;
  background: transparent;
  z-index: 60;          /* the wheel passes underneath it */
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  flex: none;
  /* Held back and faded out until the loading grid starts dissolving
     (body.loading-revealed, toggled by the inline script alongside it)
     -- otherwise it's just sitting there instantly once the grid clears,
     with no arrival of its own to match the island's. translate (not
     transform, which .site-logo/.island already use for their own
     purposes) composes independently, so it can't clash. */
  opacity: 1;
  translate: 0 0;
  transition: opacity 0.6s var(--ease), translate 0.6s var(--ease);
}
body:not(.loading-revealed) .header {
  opacity: 0;
  translate: 0 -14px;
}

/* The mark is drawn in currentColor, so it is the page's ink: near
   black on the light ground, near white on the dark one — the same
   two values every other piece of type uses. Its height is a step on
   the type scale, not a stray pixel count. */
.site-logo {
  display: block;
  color: var(--color-text-primary);
  text-decoration: none;
  transition: opacity 0.4s var(--ease);
}
@media (hover: hover) {
.site-logo:hover { opacity: 0.65; }
}

.site-logo svg {
  display: block;
  /* the mark is set to a step on the type ramp, so it scales with the
     rest of the page rather than to a number of its own */
  height: var(--fs-34);
  width: auto;
}

.nav {
  display: flex;
  align-items: center;
  gap: var(--sp-1);
  padding: var(--sp-1) var(--sp-2);
  border-radius: var(--radius-round);
  background: var(--plate-bg);
  border: 1px solid var(--plate-border);
  box-shadow: var(--plate-shadow);
  -webkit-backdrop-filter: var(--plate-backdrop);
  backdrop-filter: var(--plate-backdrop);
}

.theme-toggle {
  width: 28px;
  height: 28px;
  border: 0;
  border-radius: 50%;
  background: transparent;
  color: var(--ui-text);
  cursor: pointer;
  display: grid;
  place-items: center;
  padding: 0;
  opacity: 0.7;
  transition: background 0.45s var(--ease), opacity 0.45s var(--ease);
}
/* hover:hover only -- on a touch device with no real hover, tapping to
   toggle the theme otherwise left this background stuck lit until the
   next tap elsewhere, same sticky-hover trap the island's own hover
   style already guards against. */
@media (hover: hover) {
  .theme-toggle:hover { background: var(--plate-chip); opacity: 1; }
}

.nav a {
  font-size: var(--fs-13);
  font-weight: var(--fw-regular);
  letter-spacing: var(--ls-mono);
  color: var(--ui-text);
  text-decoration: none;
  padding: var(--sp-2) var(--sp-4);
  border-radius: var(--radius-round);
  opacity: 0.85;
  transition: background 0.5s var(--ease), opacity 0.5s var(--ease);
}
@media (hover: hover) {
.nav a:hover { background: var(--plate-chip); opacity: 1; }
}

/* The one in-page section reads as "already here" -- same chip the
   hover state uses, permanently, plus a touch more weight, iOS
   segmented-control style. Without this it looked identical to a link
   you hadn't visited yet, which is exactly what made tapping it feel
   tempting while scrolled into that section already. */
.nav a.is-current {
  background: var(--plate-chip);
  opacity: 1;
  font-weight: var(--fw-medium);
}

/* ============================================================
   PAGE — header/footer full-bleed, content in a centred column
   ============================================================ */
.page {
  width: 100%;
  max-width: var(--container-max);
  margin: 0 auto;
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

/* ============================================================
   WORK — name list + floating card + connector
   ============================================================ */
.work { position: relative; flex: none; }

.project-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: clamp(12px, 1.25vh, 18px);
  width: max-content;
  max-width: 100%;
  position: relative;
  z-index: 3;
}

/* ---- entrance: opacity, with a whisper of upward drift ----
   Delays are staggered per item from JS. The rest position is the
   default, so nothing here interferes with hover once revealed. */
.project-item {
  position: relative;
  transition: opacity 1.1s var(--ease), transform 1.2s var(--ease);
}
.project-list:not(.is-revealed) .project-item {
  opacity: 0;
  transform: translateY(10px);
}

.project-link {
  display: inline-block;
  position: relative;
  text-decoration: none;
  color: var(--color-text-muted);
  /* Medium, not light: the mark beside them is a heavy geometric
     letterform, and at 300 the names read as a different voice from
     the logo rather than the same one. */
  font-family: var(--ff-display);
  font-size: var(--fs-display);
  line-height: var(--lh-flat);
  font-weight: var(--fw-display);
  letter-spacing: var(--ls-display);
  transition: color 0.6s var(--ease), opacity 0.6s var(--ease);
  /* Optical trim, the way the reference does it: a line box is taller
     than the letters inside it, so a stack of names is spaced by
     invisible leading rather than by what you can see. Pulling the box
     in by the difference makes the gaps between names the gaps you
     actually set. */
  margin-top: -0.08em;
  margin-bottom: -0.08em;
}

/* while one name is hovered the rest recede — opacity does the work */
.project-list.is-live .project-item:not(.is-on) .project-link { opacity: 0.4; }

.project-item.is-on .project-link {
  /* the hovered name changes colour and nothing else: changing weight
     or style re-cuts the word and shifts everything after it */
  color: var(--color-text-primary);
  opacity: 1;
}

/* ============================================================
   CURSOR

   A dot that trails the pointer. Over a project name it stretches
   into a pill carrying that project's line — which is why the label
   never sits in the same place twice. Over anything tappable it
   opens into a ring.
   ============================================================ */
.cursor {
  position: fixed;
  top: 0;
  left: 0;
  /* above the loading grid (9999) as well as the sheet (200): the page
     hides the native cursor everywhere once cursor-on is active, so if
     this were painted under either there'd be no visible pointer at
     all over them -- real bug hit on the loading grid specifically,
     where the custom dot was invisible under it the whole time. */
  z-index: 10000;
  pointer-events: none;
  opacity: 0;
  transition: opacity 0.35s var(--ease);
  will-change: transform;
}
.cursor.is-awake { opacity: 1; }

.cursor-pill {
  position: absolute;
  /* composed so the label anchor and the press scale don't fight */
  --pill-x: -50%;
  --pill-s: 1;
  transform: translate(var(--pill-x), -50%) scale(var(--pill-s));
  display: flex;
  align-items: center;
  justify-content: center;
  width: var(--cursor-dot);
  height: var(--cursor-dot);
  border-radius: var(--radius-round);
  background: var(--cursor-blue);
  border: 1.5px solid transparent;
  overflow: hidden;
  transition:
    width 0.5s var(--cursor-ease),
    height 0.5s var(--cursor-ease),
    transform 0.28s var(--cursor-ease),
    background-color 0.4s var(--ease),
    border-color 0.4s var(--ease);
}

.cursor-text {
  font-family: var(--ff-mono);
  font-size: var(--fs-13);
  line-height: var(--lh-flat);
  color: #fff;
  white-space: nowrap;
  opacity: 0;
  transition: opacity 0.28s var(--ease);
}

/* label state — the pill carries the project's line, opening to the
   right of the dot rather than straddling it */
.cursor.is-label .cursor-pill {
  height: 28px;
  --pill-x: 10px;
  justify-content: flex-start;
  padding-left: 14px;
}
.cursor.is-label .cursor-text { opacity: 1; transition-delay: 0.08s; }

/* tap state — an open ring over anything clickable */
.cursor.is-tap .cursor-pill {
  width: 30px;
  height: 30px;
  background: transparent;
  border-color: var(--cursor-ring);
}

/* press — the ring closes in on itself */
.cursor.is-press .cursor-pill { transform: translate(-50%, -50%) scale(0.82); }
.cursor.is-tap.is-press .cursor-pill { background: var(--cursor-ring); }

/* the native pointer steps aside only where a real mouse exists */
@media (hover: hover) and (pointer: fine) {
  body.cursor-on,
  body.cursor-on * { cursor: none; }

  /* the island is the one thing you grab, so it keeps a real cursor
     and the trailing dot steps out of the way while you are on it */
  body.cursor-on .island,
  body.cursor-on .island * { cursor: grab; }
  body.cursor-on .island.is-dragging,
  body.cursor-on .island.is-dragging * { cursor: grabbing; }
  .cursor.is-off { opacity: 0; }
}
@media (hover: none), (pointer: coarse) {
  .cursor { display: none; }
}

/* ---- connector ---- */
.connector {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  z-index: 2;
  opacity: 0;
  transition: opacity 0.5s var(--ease);
}
.connector.is-on { opacity: 1; }

/* The line and its endpoints fade together, as one mark. Nothing
   draws itself on, nothing snaps into place. */
.connector-line {
  stroke: var(--line);
  stroke-width: 1.15;
  stroke-linecap: round;
  opacity: 0.7;
}
.connector-dot {
  fill: var(--line);
  opacity: 0.85;
}

/* ---- preview card ---- */
.preview-card {
  position: absolute;
  top: 50%;
  right: 0;
  z-index: 1;
  background: var(--card-bg);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-feature);
  box-shadow: var(--shadow-card);
  /* the media panel sits on a hairline inset; the copy block carries
     its own, much wider margins — as in the reference cards */
  padding: var(--sp-2);
  opacity: 0;
  visibility: hidden;
  transform: translateY(calc(-50% - var(--card-lift) + 10px));
  transition:
    opacity 0.55s var(--ease),
    transform 0.7s var(--ease),
    visibility 0.55s;
}

.preview-card.is-on {
  opacity: 1;
  visibility: visible;
  transform: translateY(calc(-50% - var(--card-lift)));
}

/* layout A — horizontal: device panel left, copy right */
.preview-card.layout-horizontal {
  width: min(680px, 54vw);
  display: grid;
  grid-template-columns: 45% 1fr;
  gap: var(--sp-5);
  min-height: 372px;
}

/* layout B — vertical: image on top, copy below */
.preview-card.layout-vertical {
  width: min(352px, 29vw);
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
}

.card-panel {
  background: var(--panel-bg);
  border-radius: var(--radius-card);
  display: grid;
  place-items: center;
  overflow: hidden;
  padding: var(--sp-5) var(--sp-4);
  /* a well, not a sticker: the hairline is drawn inside so it survives
     the overflow clip that keeps the mockup's corners rounded */
  box-shadow: inset 0 0 0 1px var(--edge);
}
.card-panel.is-tinted { background: var(--panel-bg-alt); }

.layout-vertical .card-panel { height: 216px; }

/* ---- device mockups ---- */
.phone {
  position: relative;
  width: 158px;
  max-width: 100%;
  aspect-ratio: 9 / 19.2;
  background: var(--bezel);
  border-radius: 24px;
  padding: 4px;
  box-shadow: var(--shadow-phone);
}
.phone::after {
  content: "";
  position: absolute;
  top: 9px;
  left: 50%;
  transform: translateX(-50%);
  width: 32%;
  height: 9px;
  background: var(--bezel);
  border-radius: var(--radius-round);
  z-index: 2;
}
.phone-screen {
  width: 100%;
  height: 100%;
  border-radius: 21px;
  overflow: hidden;
  background: var(--panel-bg-alt);
}
.phone-screen img,
.phone-screen video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.layout-vertical .phone { width: 108px; border-radius: 18px; }
.layout-vertical .phone-screen { border-radius: 15px; }
.layout-vertical .phone::after { height: 7px; top: 7px; }

/* two-device shot (Microsoft) */
.duo {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--sp-3);
  width: 100%;
}
.duo .slab {
  background: var(--bezel);
  border-radius: 10px;
  padding: 3px;
  box-shadow: var(--shadow-phone);
  overflow: hidden;
}
.duo .slab.wide { width: 58%; aspect-ratio: 16 / 10; }
.duo .slab.tall { width: 26%; aspect-ratio: 3 / 4.4; }
/* No second (mobile) slab for this project -- the desktop capture
   fills the whole space alone instead of sitting at its normal 58%
   beside an empty gap. */
.duo .slab.solo { width: 84%; aspect-ratio: 16 / 10; }
.duo .slab img,
.duo .slab video {
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 7px;
  display: block;
}

/* ---- copy ---- */
.card-copy {
  display: flex;
  flex-direction: column;
  padding: var(--sp-5) var(--sp-4) var(--sp-4) var(--sp-3);
  max-width: var(--measure);
}
.layout-vertical .card-copy { padding: var(--sp-3) var(--sp-4) var(--sp-4); }

/* One or several client-logo marks in a row -- real app icons (full-
   bleed, own colored background, rounded corners baked into the image
   itself), not a single generic app-icon placeholder any more.
   object-fit:cover so each one fills its box edge to edge with no
   inset padding, plus a thin border since a colorful square can
   otherwise blend into a background of a similar value -- chip-border
   is already themed, so light/dark each get their own discreet line
   with no new color. Multiple marks (see PROJECTS.icons) sit side by
   side in this row rather than each carrying their own margin. */
.card-icon-row {
  display: flex;
  gap: var(--sp-2);
  margin-bottom: var(--sp-5);
}
.card-icon {
  width: 52px;
  height: 52px;
  /* Half the box is a circle: --radius-feature (26) on 52px was
     exactly that. --radius-card keeps the same ratio the mobile icon
     has (10 on 36), which is the squircle an app mark wants. */
  border-radius: var(--radius-card);
  border: 1px solid var(--chip-border);
  display: grid;
  place-items: center;
  overflow: hidden;
  flex: none;
}
.card-icon img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.card-title {
  margin: 0 0 var(--sp-2);
  font-size: var(--fs-21);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-tight);
}

.card-flow {
  margin: 0 0 var(--sp-3);
  font-family: var(--ff-display);
  font-size: var(--fs-28);
  font-weight: var(--fw-display);
  /* No line-height set before this fell back to the browser's tight
     display-font default -- fine for the short arrow-style text this
     was written for, but the longer sentence-style taglines that
     replaced it wrap to 2 lines and read as cramped/touching without
     real leading. */
  line-height: 1.3;
  color: var(--color-text-primary);
}

.card-desc {
  margin: 0;
  font-size: var(--fs-15);
  line-height: var(--lh-prose);
  color: var(--color-text-body);
}

/* The clock and the credit live in the island now — there is no
   separate footer to compete with it. */

/* ============================================================
   ISLAND

   Pinned to the bottom, closed by default. Every dimension is a
   power of φ off one 46px seed, so the closed bar, the open panel
   and the gap between rows all belong to the same series:

     46            bar height          seed
     46/φ³ ≈ 11    bar inset
     46/φ² ≈ 18    panel inset
     46·φ⁴ ≈ 316   panel width

   The open/close is a single spring on the shell — width, height and
   radius travel together while the contents cross-fade, which is what
   makes iOS's version read as one object changing shape rather than
   two views swapping.
   ============================================================ */

/* Small, discreet, always-on status line at the very bottom edge --
   sits in the clearance the island already keeps below itself
   (bottom: var(--sp-5) on .island), so it never competes with it even
   though the island's z-index is higher. Text only, no interaction. */
.site-footbar {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 20;
  display: flex;
  justify-content: space-between;
  align-items: center;
  padding: var(--sp-1) var(--sp-4);
  font-family: var(--ff-mono);
  font-size: 10px;
  letter-spacing: var(--ls-label);
  color: var(--color-text-muted);
  pointer-events: none;
}

.island {
  position: fixed;
  left: 50%;
  bottom: var(--sp-5);
  z-index: 90;
  transform: translate(-50%, 0);
  display: flex;
  flex-direction: column;
  overflow: hidden;

  width: var(--island-w);
  max-width: calc(100vw - var(--sp-6));
  height: var(--island-h);
  padding: var(--island-frame);
  border-radius: calc(var(--island-h) / 2);

  background: var(--island-shell);
  border: 1px solid var(--island-shell-border);
  color: var(--island-shell-ink);
  box-shadow: var(--shadow-card);

  /* the spring: a touch of overshoot on the way out, settled on the way
     in. Slowed slightly past the site's usual 0.5-0.6s -- opening this
     is a deliberate action, not ambient motion, so it earns a beat
     longer to read as considered rather than snapping into place. */
  transition:
    width 0.75s var(--spring),
    height 0.75s var(--spring),
    border-radius 0.75s var(--spring),
    translate 0.6s var(--ease),
    transform 0.5s var(--ease),
    box-shadow 0.5s var(--ease),
    opacity 0.6s var(--ease);
}

/* Held back until the loading grid starts dissolving (body.loading-
   revealed, toggled by the inline script alongside it), same as
   .header -- translate is already load-bearing here for hover/drag, so
   the entrance offset rides on top of whatever translateX(-50%) is
   doing via the separate translate property instead of fighting it. */
body:not(.loading-revealed) .island {
  opacity: 0;
  /* Y only -- X centering is transform:translate(-50%,0) above, which
     is a different property (transform, not translate) but the same
     "percent of own box" math, so a -50% here would double up and
     shove it off-screen. */
  translate: 0 14px;
}

/* it lifts a couple of pixels under the pointer — enough to read as
   an object sitting on the page rather than printed onto it */
@media (hover: hover) {
  .island:hover { transform: translate(-50%, -2px); }
  .island.is-dragging:hover { transform: translate(-50%, 0); }
}

/* open, the shell relaxes to the card radius — the same one the
   project cards use, so the two read as one family */
.island[data-open="true"] {
  width: var(--island-w-open);
  height: var(--island-open-h, 460px);
  border-radius: calc(var(--radius-feature) + var(--island-frame));
  box-shadow: var(--shadow-card);
}

/* Hero mode: a small chip, not the full-size bar -- reusing the exact
   same width/height/border-radius transition already defined on the
   base .island rule (the same one that already animates the open/close
   morph above) means switching data-mode to "normal" grows the chip
   into the full stats bar with that same spring, instead of a hard cut
   between two differently-sized elements.
   border-radius is a fixed 20px (half of this chip's own 40px height),
   the same pattern the base rule uses for its own height -- NOT
   --radius-round (a large clamped value). Both radii being exactly
   half of their own height means the radius/height ratio stays at
   0.5 at every point along the transition (same timing function, same
   duration, so both interpolate through identical progress at every
   frame) -- a full pill throughout. Mixing a clamped value with a
   fixed one made the radius and height drift out of sync mid-morph,
   reading as square-ish for a moment before snapping back round. */
.island[data-mode="hero"] {
  width: 140px;
  height: 32px;
  padding: 0;
  border-radius: 16px;
}

/* the gesture owns vertical movement here, the page does not */
.island-grabber,
.island-bar { touch-action: none; }

/* The handle: pure affordance — it says the thing is grabbable before
   anyone touches it. It rides on the plate rather than above it, so
   the shell's frame stays the same thickness on all four sides, and
   being struck from the plate's ink it reads identically in both
   themes instead of inverting with the shell. */
.island-grabber {
  position: absolute;
  top: calc((var(--island-frame) - var(--island-handle-h)) / 2);
  left: 50%;
  width: 28px;
  height: var(--island-handle-h);
  margin-left: -14px;
  border-radius: var(--radius-round);
  background: var(--island-shell-ink);
  opacity: var(--island-handle-alpha);
  transition:
    opacity 0.4s var(--ease),
    width 0.5s var(--spring),
    margin-left 0.5s var(--spring);
}
@media (hover: hover) {
.island:hover .island-grabber {
  width: 34px;
  margin-left: -17px;
  opacity: calc(var(--island-handle-alpha) + 0.2);
}
}
.island[data-open="true"] .island-grabber {
  opacity: calc(var(--island-handle-alpha) + 0.12);
}
.island.is-dragging .island-grabber { opacity: 0.75; }
/* The grabber promises "you can drag this" -- true once it's the full
   stats bar, not true for the hero chip (a tap target, not a drag
   handle), so it stays hidden until the morph into normal mode. */
.island[data-mode="hero"] .island-grabber { display: none; }

/* while a finger is on it, the shell should not fight the gesture */
.island.is-dragging { transition: none; }
.island.is-dragging .island-bar { transform: scale(0.994); }

/* ---- closed: the plate ---- */
.island-bar {
  flex: none;
  width: 100%;
  height: var(--island-plate-h);
  position: relative;
  display: flex;
  align-items: center;
  padding: var(--sp-3) var(--sp-5);
  border: 0;
  border-radius: var(--radius-round);
  background: var(--island-yellow);
  color: var(--island-ink);
  box-shadow: inset 0 0 0 1px var(--island-plate-ring);
  font: inherit;
  cursor: pointer;
  text-align: left;
}

/* The plate is a pill in both states. Animating its radius away from
   999px is what made the corners snap square and then round again
   halfway through the open — a pill has no radius to interpolate. */

.island-stats {
  width: 100%;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  align-items: start;
}

.island-stat {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  min-width: 0;
}

/* Readings, not prose: mono, tight, the plate's full ink. The labels
   under them drop two steps in size and half the ink, so the row has
   one clear voice and one quiet one. */
.island-readout {
  /* the glyphs are swapped one at a time while it resolves, so the
     box must not resize as they change */
  display: inline-block;
  white-space: pre;
}

.island-stat-value {
  font-family: var(--ff-mono);
  font-size: var(--fs-15);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-display);
  font-variant-numeric: tabular-nums;
  line-height: var(--lh-flat);
  white-space: nowrap;
}

/* the site's micro-label: mono, uppercase, wide tracking */
.island-stat-label {
  font-family: var(--ff-mono);
  font-size: var(--fs-12);
  line-height: var(--lh-flat);
  letter-spacing: var(--ls-mono);
  color: var(--island-ink-dim);
  white-space: nowrap;
}

/* availability reads as a mark, not a colour — there is no second hue */
.island-dot {
  display: inline-block;
  width: 3.5px;
  height: 3.5px;
  margin-left: 3px;
  border-radius: 50%;
  background: currentColor;
  vertical-align: top;
}

/* ---- open: the contact card ---- */
.island-panel {
  flex: 1;
  min-height: 0;
  padding: var(--sp-2) 0 0;
  opacity: 0;
  transform: translateY(6px);
  pointer-events: none;
  transition: opacity 0.34s var(--ease), transform 0.5s var(--spring);
}
.island[data-open="true"] .island-panel {
  opacity: 1;
  transform: none;
  pointer-events: auto;
  transition-delay: 0.1s;
}

.island-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
}

/* Staggered: each row is a beat behind the one above it on the way
   in, and they leave together so closing stays crisp. */
.island-list li {
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 0.3s var(--ease), transform 0.45s var(--spring);
}
.island[data-open="true"] .island-list li { opacity: 1; transform: none; }
.island[data-open="true"] .island-list li:nth-child(1) { transition-delay: 0.06s; }
.island[data-open="true"] .island-list li:nth-child(2) { transition-delay: 0.11s; }
.island[data-open="true"] .island-list li:nth-child(3) { transition-delay: 0.16s; }
.island.is-dragging .island-list li {
  opacity: 1;
  transform: none;
  transition: none;
}

/* one row = one card: hairline, card radius, title + desc */
.island-row {
  display: block;
  width: 100%;
  padding: var(--sp-3) var(--sp-4);
  border: 1px solid var(--island-hairline);
  border-radius: var(--radius-feature);
  background: none;
  font: inherit;
  color: var(--island-shell-ink);
  -webkit-tap-highlight-color: transparent;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
  transition: background 0.5s var(--ease), border-color 0.5s var(--ease);
}
@media (hover: hover) {
.island-row:hover {
  background: var(--island-wash);
  border-color: var(--island-hairline-hover);
}
}

/* .card-title at the deck's size */
.island-row-title {
  display: flex;
  align-items: center;
  gap: var(--sp-2);
  font-size: var(--fs-15);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-tight);
  line-height: var(--lh-tight);
}
.island-row-title .arrow {
  font-weight: var(--fw-regular);
  opacity: 0.55;
  transition: transform 0.5s var(--spring), opacity 0.4s var(--ease);
}
@media (hover: hover) {
.island-row:hover .arrow { opacity: 1; transform: translateX(3px); }
}

/* .card-desc, on the shell's dim ink */
.island-row-sub {
  display: block;
  margin-top: var(--sp-1);
  font-size: var(--fs-13);
  line-height: var(--lh-body);
  color: var(--island-shell-ink-dim);
}

/* iOS-ish press: the whole shell gives a little, nothing flashes */
.island-bar:active { transform: scale(0.994); }
.island-row:active { background: var(--island-wash); transform: scale(0.992); }
.island-bar,
.island-row { transform-origin: center; }
.island-bar { transition: transform 0.3s var(--ease); }
.island-row { transition: background 0.5s var(--ease), border-color 0.5s var(--ease), transform 0.3s var(--ease); }

/* The ring has to contrast with the surface under it: the plate's own
   ink on the yellow, the shell's ink on the shell. */
.island-bar:focus-visible {
  outline: 2px solid var(--island-ink);
  outline-offset: -4px;
}
.island-row:focus-visible {
  outline: 2px solid var(--island-shell-ink);
  outline-offset: 2px;
}

/* Closed, the panel is inert — but belt and braces for older engines. */
.island-panel[inert] { visibility: hidden; }
.island[data-open="true"] .island-panel { visibility: visible; }

.island svg { display: block; }

/* ============================================================
   PROJECT SHEET

   One dialog for every project: the chips above it swap which one is
   open without the panel ever closing, and the expand control grows it
   to fill the screen. Its surface is a single flat value per theme —
   white on the light one, the page's near-black on the dark one — so
   the content that goes in later is what carries the difference between
   projects, not the panel behind it.
   ============================================================ */
.sheet-root {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-5);
  /* φ off the base step: 24 · 1.618 ≈ 39. The row needs more air above
     it than between it and the panel, or it reads as stuck to the top
     of the screen rather than as the panel's own header. */
  padding: calc(var(--sp-5) * var(--phi)) var(--sp-5) 0;
}
.sheet-root[hidden] { display: none; }

.sheet-scrim {
  position: absolute;
  inset: 0;
  background: var(--scrim-bg);
  /* enough to push the page back, little enough that it is still the
     page you are looking through */
  -webkit-backdrop-filter: blur(6px) saturate(112%);
  backdrop-filter: blur(6px) saturate(112%);
  opacity: 0;
  transition: opacity 0.55s var(--ease);
}
.sheet-root.is-on .sheet-scrim { opacity: 1; }

/* ---- the chips ---- */
.sheet-chips {
  position: relative;
  flex: none;
  width: min(1160px, 100%);
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-start;
  gap: var(--sp-2);
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity 0.45s var(--ease), transform 0.6s var(--spring);
}
.sheet-root.is-on .sheet-chips {
  opacity: 1;
  transform: none;
  transition-delay: 0.06s;
}
/* Expanded, the panel owns the screen and the chips step aside. They
   have to leave the flow as well as fade: a fading flex item still
   reserves its height, which is what was holding the panel a chip's
   worth of pixels short of the top edge. */
.sheet-root.is-full .sheet-chips {
  position: absolute;
  top: calc(var(--sp-5) * var(--phi));
  left: var(--sp-5);
  opacity: 0;
  transform: translateY(-10px);
  pointer-events: none;
  transition-delay: 0s;
}

.sheet-chip {
  font: inherit;
  font-size: var(--fs-13);
  line-height: var(--lh-flat);
  padding: var(--sp-3) var(--sp-4);
  border-radius: var(--radius-round);
  border: 1px solid var(--chip-border);
  background: var(--card-bg);
  color: var(--ui-text);
  cursor: pointer;
  white-space: nowrap;
  transition:
    background 0.5s var(--ease),
    color 0.5s var(--ease),
    border-color 0.5s var(--ease);
}
@media (hover: hover) {
.sheet-chip:hover { border-color: var(--chip-border-hover); }
}
.sheet-chip.is-on {
  background: var(--chip-on-bg);
  border-color: var(--chip-on-bg);
  color: var(--chip-on-text);
}

/* ---- the panel ---- */
.sheet {
  position: relative;
  flex: 1;
  width: min(1160px, 100%);
  min-height: 0;
  margin-bottom: var(--sp-6);
  border-radius: var(--radius-card);
  border: 1px solid var(--edge);
  background: var(--sheet-bg);
  box-shadow: var(--elev-3);
  overflow: hidden;

  opacity: 0;
  transform: translateY(14px) scale(0.985);
  transition:
    opacity 0.5s var(--ease),
    transform 0.7s var(--spring),
    width 0.6s var(--spring),
    max-width 0.6s var(--spring),
    margin 0.6s var(--spring),
    border-radius 0.6s var(--spring);
}
.sheet-root.is-on .sheet { opacity: 1; transform: none; }

/* Expanded: the panel (the modal chrome itself -- background, border,
   shadow) takes the full viewport edge to edge in both directions, not
   just height. The actual content doesn't reflow or get any wider,
   though: case pages already center their own content in
   .case-container's own max-width/auto-margins, so letting .sheet
   itself go full width just removes the visible modal frame around
   that already-centered content instead of moving it. */
.sheet-root.is-full { padding: 0; gap: 0; }
.sheet-root.is-full .sheet {
  width: 100%;
  margin-bottom: 0;
  border-radius: 0;
  border-color: transparent;
}

.sheet-tools {
  position: absolute;
  top: var(--sp-5);
  right: var(--sp-5);
  z-index: 2;
  display: flex;
  gap: var(--sp-1);
}

.sheet-tool {
  width: 32px;
  height: 32px;
  display: grid;
  place-items: center;
  padding: 0;
  border: 0;
  border-radius: var(--radius-control);
  background: none;
  color: var(--color-text-body);
  cursor: pointer;
  transition: background 0.4s var(--ease), color 0.4s var(--ease);
}
@media (hover: hover) {
.sheet-tool:hover { background: var(--plate-chip); color: var(--color-text-primary); }
}
.sheet-tool svg { display: block; }

/* one glyph, two states: the arrows turn around instead of swapping */
.sheet-tool .ico-in { display: none; }
.sheet-root.is-full .sheet-tool .ico-out { display: none; }
.sheet-root.is-full .sheet-tool .ico-in { display: block; }

.sheet-body {
  height: 100%;
  overflow-y: auto;
  overscroll-behavior: contain;
  -webkit-overflow-scrolling: touch;
  padding: var(--sp-8);
}

.sheet-heading {
  margin: 0;
  font-family: var(--ff-display);
  font-size: var(--fs-44);
  font-weight: var(--fw-display);
  letter-spacing: var(--ls-tight);
  line-height: var(--lh-tight);
}

.sheet-subtitle {
  margin: var(--sp-3) 0 0;
  font-family: var(--ff-display);
  font-size: var(--fs-21);
  font-weight: var(--fw-display);
  color: var(--color-text-body);
}

/* ============================================================
   MOBILE DECK — hidden on desktop
   ============================================================ */
.deck { display: none; }

/* ============================================================
   BREAKPOINTS
   ============================================================ */
@media (max-width: 1180px) {
  .preview-card.layout-horizontal { width: 56vw; min-height: 340px; }
  .preview-card.layout-vertical { width: 34vw; }
  .card-icon { width: 44px; height: 44px; margin-bottom: var(--sp-4); }
  .phone { width: 132px; }
}

/* ---- MOBILE: the name list is replaced by a snap carousel ---- */
@media (max-width: 900px) {
  .ui { padding: var(--sp-5) var(--sp-5) var(--island-space); }

  .site-logo svg { height: var(--fs-28); }
  .header { align-items: center; }

  .nav { padding: var(--sp-1); gap: 0; }
  .nav a { padding: var(--sp-2) var(--sp-3); }

  .page { justify-content: flex-start; min-height: 0; }

  /* the carousel is the navigation on mobile */
  .work { display: none; }

  /* Too small a readout to earn its place on a narrow screen -- desktop
     keeps it since there's room to spare there. */
  .site-footbar { display: none; }

  /* .deck-slide/.deck-card are position:fixed (needed for the orbit
     gesture to track the viewport, not page scroll), so unlike
     desktop's .preview-card -- a normal-flow element .ui's own
     margin-top already keeps off screen -- they paint over the hero
     from scroll 0 with nothing to stop them. hero-done (added on body
     once the intro hands off, see finishHeroIntro/syncIntroWithScroll)
     gates them the same way .ui's layout already gates desktop. */
  body:not(.hero-done) .deck-slide,
  body:not(.hero-done) .deck-card {
    visibility: hidden !important;
    pointer-events: none !important;
  }

  /* revealed as one unit, so the per-card turn animations below are
     never fighting a transition on the same element */
  /* the fade runs long, so it is still going while the card rises —
     the two read as one arrival rather than two events */
  .deck {
    display: flex;
    flex-direction: column;
    position: relative;
    flex: 1 1 auto;
    margin-top: var(--sp-5);
    /* The cards are fixed to the viewport, so the stage only has to be
       a hit area — giving it a tall floor was pushing the page past the
       viewport and flashing a scrollbar on load. */
    min-height: 200px;
    transition: opacity 1.4s var(--ease);
  }
  .deck:not(.is-revealed) { opacity: 0; }

  /* One card occupies the stage; the rest wait, stacked underneath. */
  .deck-stage {
    position: relative;
    flex: 1 1 auto;
    min-height: 200px;
    /* header + margins + the island's reserved lane */
    max-height: calc(var(--vh, 1vh) * 100 - 96px - var(--island-space));
    padding-right: var(--sp-6);
    /* the wheel owns vertical movement inside the stage */
    touch-action: none;
    /* the cards are fixed to the viewport and travel behind the header
       and the island, so nothing may clip them */
    overflow: visible;
  }

  /* The slide is just a frame; the card inside it is placed by the
     orbit, in stage coordinates. */
  .deck-slide {
    position: fixed;
    inset: 0;
    visibility: hidden;
    pointer-events: none;
    z-index: 3;
  }

  .deck-card {
    position: fixed;
    /* Both axes are centred by CSS: the resting point is the middle of
       the room between the header and the island's lane, and svh keeps
       it still while mobile browser chrome slides away. The script only
       adds the orbit's offset from here. */
    top: calc((var(--deck-safe-top) + 100svh - var(--island-space)) / 2);
    /* Horizontal centring is the browser's job — left:50% plus the
       -50% in the transform. The script only ever adds the orbit's
       offset from that point, so a card at rest is centred exactly,
       without any viewport arithmetic that could be off by a
       scrollbar's width. */
    left: 50%;
    width: min(calc(100vw - var(--deck-card-margin) * 2), var(--deck-card-max));
    pointer-events: auto;
    transform-origin: 50% 50%;
    backface-visibility: hidden;
    background: var(--card-bg);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-feature);
    box-shadow: var(--shadow-card);
    padding: var(--sp-3);
    display: flex;
    flex-direction: column;
    gap: var(--sp-4);
    text-decoration: none;
    color: inherit;
  }

  /* Every mobile card reads the same: themed backplate (dark in dark
     mode, like desktop's), device, copy. */
  .deck-card .card-panel {
    background: var(--panel-bg);
    height: 38vh;
    max-height: 270px;
  }
  .deck-card .phone { width: 40%; max-width: 128px; }
  .deck-card .card-copy { padding: 0 var(--sp-2) var(--sp-2); }

  /* Logo on top, title below -- stacked, not side-by-side, matching
     how desktop's own .card-head already reads (no flex row there at
     all, just normal block stacking in DOM order: icon, then title). */
  .deck-card .card-head {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--sp-3);
    margin-bottom: var(--sp-3);
  }
  .deck-card .card-icon-row { margin-bottom: 0; }
  .deck-card .card-icon {
    width: 36px;
    height: 36px;
    border-radius: var(--radius-control);
  }
  .deck-card .card-title { margin: 0; font-size: var(--fs-21); }
  .deck-card .card-flow { font-size: var(--fs-18); }

  /* dot pagination, right rail. Fixed, not absolute-inside-.deck: the
     cards are position:fixed (they have to be, for the orbit/drag to
     track the viewport, not page scroll) and stayed put through a
     scroll, but the dots were absolute inside .deck's own (relative,
     normal-flow) box -- as the deck section scrolled up into view, the
     dots rode along with it and visibly rose in from below while the
     cards just sat there. Fixed positioning anchors them to the
     viewport the same way, so they don't move on scroll at all. */
  .deck-dots {
    position: fixed;
    right: var(--sp-2);
    top: 50%;
    transform: translateY(-50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--sp-3);
    z-index: 5;
    /* Hidden through the deck's own first entrance glide (see
       ensureAnimation/animate in script.js, which adds this once that
       settle actually lands) -- appearing only once the sweep is done,
       quick and quiet, reads as smart/deliberate instead of one more
       thing sliding in during an already-busy entrance. */
    opacity: 0;
    transition: opacity 0.25s var(--ease);
  }
  .deck-dots.is-visible { opacity: 1; }

  .deck-dot {
    width: 5px;
    height: 5px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: currentColor;
    color: var(--color-text-primary);
    /* an inactive dot is still a control you have to be able to find */
    opacity: 0.35;
    cursor: pointer;
    transition: opacity 0.6s var(--ease), box-shadow 0.6s var(--ease);
  }

  .deck-dot.is-on {
    opacity: 1;
    box-shadow: 0 0 0 4px transparent, 0 0 0 4.5px currentColor;
  }

  /* On a phone the sheet is the screen: no chips (the deck behind is
     already the way you move between projects), no inset, no radius. */
  .sheet-root { padding: 0; gap: 0; }
  .sheet-chips { display: none; }
  .sheet {
    width: 100%;
    margin-bottom: 0;
    border-radius: 0;
    border: 0;
  }
  .sheet-body { padding: var(--sp-7) var(--sp-5) var(--sp-6); }

  /* the sheet already fills the screen here, so there is nothing to
     expand into — only the close button earns its place, bigger too:
     32px/16px-icon read as too small to find against a full-screen
     sheet's own busy content. */
  #sheetExpand { display: none; }
  #sheetClose {
    width: 44px;
    height: 44px;
  }
  #sheetClose svg {
    width: 22px;
    height: 22px;
  }
  .sheet-heading { font-size: var(--fs-34); }
  .sheet-tools { top: var(--sp-3); right: var(--sp-3); }

  /* the island narrows to the gutter, and also sits shorter/tighter --
     the desktop plate height carried over unshrunk read as oversized
     and too dominant against a much narrower screen. --island-h is a
     literal (not a calc of the other two), so it's restated here too:
     sp-7 + sp-2*2 = 64px = sp-8, keeping the same "plate + even frame"
     relationship the desktop values have at their own scale. */
  :root {
    --island-plate-h: var(--sp-7);
    --island-frame: var(--sp-2);
    --island-h: var(--sp-8);
  }
  .island { width: min(var(--island-w), calc(100vw - var(--sp-5) * 2)); }
  .island[data-open="true"] { width: min(var(--island-w-open), calc(100vw - var(--sp-5) * 2)); }
  .island-bar { padding: var(--sp-1) var(--sp-4); }
  .island-stat-value { font-size: var(--fs-13); }
  .island-row { padding: var(--sp-4); }

  /* The hero-mode marquee shrinks again on top of the shared 140x32 --
     it's a passive cue, not a control, so on a screen this narrow it
     should read as even more discreet/out-of-the-way than the normal
     stats bar it morphs into. Radius stays exactly height/2. */
  .island[data-mode="hero"] {
    width: 108px;
    height: 26px;
    border-radius: 13px;
  }
  .island-hero-group {
    padding: 0 var(--sp-2);
  }
}

@media (max-width: 420px) {
  .deck-card .card-panel { height: 34vh; }
}

@media (prefers-reduced-motion: reduce) {
  * { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
  .deck-slide { transition: none !important; }
}

/* ============================================================
   HERO — fixed intro media + the scroll reveal into .ui
   ============================================================

   .hero never moves or scrolls: it is position:fixed, full-viewport,
   and stays exactly where it is for the life of the page. What reads
   as "the projects rise up over the intro" is entirely .ui sliding
   over it in normal flow (margin-top: 100svh gives back the space the
   hero used to occupy), with .page-fade-solid/.page-fade-gradient
   (further down this file, inside .ui) turning that hand-off from a
   hard cut into a soft blend right at the seam.

   Currently empty — drop the intro asset in as
   <img class="hero-media" src="..."> (or a <video>) inside #hero in
   index.html; .hero-media already covers it full-bleed, cropped.
   ============================================================ */
.hero {
  position: fixed;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  background: var(--color-bg);
}

.hero-media {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.hero-canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}

/* Fisheye-style lens falloff: blurred right at the left/right/bottom
   edges, sharp everywhere else -- a thin sliver, not a broad band, like
   the very edge of the frame is out of focus rather than a third of the
   shot. backdrop-filter blurs whatever is painted behind this layer
   (the canvas) within the masked region; mask-image is two gradients
   layered together (default mask-composite is additive) so the blurred
   sliver traces three edges but leaves the top and the rest of the
   frame untouched. Guarded behind @supports so a browser without
   mask-image support gets no effect at all rather than a
   blurred-everywhere fallback (backdrop-filter alone, unmasked, would
   blur the whole hero). */
@supports (mask-image: linear-gradient(#000, #000)) or (-webkit-mask-image: linear-gradient(#000, #000)) {
  .hero-edge-blur {
    position: absolute;
    inset: 0;
    pointer-events: none;
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    -webkit-mask-image:
      linear-gradient(to bottom, transparent 94%, #000 100%),
      linear-gradient(to right, #000 0%, transparent 5%, transparent 95%, #000 100%);
    mask-image:
      linear-gradient(to bottom, transparent 94%, #000 100%),
      linear-gradient(to right, #000 0%, transparent 5%, transparent 95%, #000 100%);
  }
}

/* Caption sequence -- four short beats crossfading in place over the
   hero, timed off the real-time clock in script.js (updateCaptions).
   Stacked with position:absolute at the same spot so one line doesn't
   shift layout as the next one arrives; opacity + a small upward drift
   is the only thing that moves. No background chip -- see
   .hero-caption-line below for the inverted-blend treatment. */
.hero-caption {
  position: absolute;
  inset: 0;
  /* No z-index: that alone (with position) creates a new stacking
     context, which boxes in mix-blend-mode on the lines inside it --
     they'd only ever see their own transparent siblings to blend
     against instead of the canvas behind the whole thing, rendering as
     flat, unblended white. Coming after the canvas in source order
     already paints this on top without needing one. */
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 var(--gutter);
  pointer-events: none;
  text-align: center;
}

/* A soft veil behind the narration: the trail canvas is busiest right
   where the words sit, so the page's own ground colour is laid over that
   band, fading to nothing at its edges. The words, the animation and
   their blend are untouched -- only the contrast underneath improves.
   Painted before the lines (source order), only while a line is up. */
.hero-caption::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(
    ellipse 78% 15% at 50% 50%,
    color-mix(in srgb, var(--color-bg) 62%, transparent) 0%,
    color-mix(in srgb, var(--color-bg) 42%, transparent) 55%,
    transparent 100%
  );
  opacity: 0;
  transition: opacity 0.6s var(--ease);
}
.hero-caption:has(.hero-caption-line.is-visible)::before { opacity: 1; }

.hero-caption-line {
  position: absolute;
  margin: 0;
  max-width: 34ch;
  /* Respects the \n in mobile's data-text-mobile as a real line break
     (see updateCaptions) -- harmless on desktop's own data-text, which
     has none. */
  white-space: pre-line;
  /* Experiment: no pill/background at all -- the text sits directly on
     the motion-trail canvas. White + mix-blend-mode:difference inverts
     against whatever's directly behind it pixel-for-pixel, so it reads
     as dark against the canvas's light blocks and light against its
     dark ones instead of needing a solid chip for contrast. Display
     face + its own heavier weight (not the sans at regular/light) so
     it still reads as a strong, single voice at this size. */
  font-family: var(--ff-display);
  font-weight: var(--fw-display);
  /* Fluid, not stepped: 28px at a 900px-wide viewport up to 44px at
     1920px, clamped at both ends by the existing tokens rather than new
     endpoints -- scales continuously with the window instead of
     snapping at the mobile breakpoint. Slope/intercept solved from
     those two points: (44-28)/(1920-900) = 1.57vw per 100vw, offset so
     the line passes through 28px at 900px. */
  font-size: clamp(var(--fs-28), calc(0.87rem + 1.57vw), var(--fs-44));
  line-height: var(--lh-tight);
  letter-spacing: var(--ls-tight);
  color: #fff;
  mix-blend-mode: difference;
  opacity: 0;
  translate: 0 14px;
  transition: opacity 0.6s var(--ease), translate 0.6s var(--ease);
}

.hero-caption-line.is-visible {
  opacity: 1;
  translate: 0 0;
}

/* A blinking caret while the line is still typing (see updateCaptions) --
   gone the instant the full line has revealed, so it doesn't linger as
   a stray mark once it's just sitting there being read. */
.hero-caption-line.is-typing::after {
  content: "";
  display: inline-block;
  width: 0.5ch;
  height: 1em;
  margin-left: 0.05em;
  background: currentColor;
  vertical-align: -0.15em;
  animation: captionCaret 0.8s steps(1) infinite;
}

@keyframes captionCaret {
  0%, 49% { opacity: 1; }
  50%, 100% { opacity: 0; }
}

/* Underlines a single word within a caption line (see data-emphasis /
   CAPTION_EMPHASIS in script.js) -- currentColor, no new color, just a
   heavier line than the browser default so it actually reads against
   the line's own accent background. */
.hero-caption-emphasis {
  text-decoration: underline;
  text-decoration-thickness: 2px;
  text-underline-offset: 3px;
}

/* The closing line stays at the same size as the rest of the
   narration -- no separate chip, no separate step. */
.hero-caption-line--big {
  font-size: var(--fs-44);
  transition-duration: 0.25s;
}


.ui {
  position: relative;
  z-index: 10;
  margin-top: 100svh;
  /* header is fixed/detached now (see .header) and no longer reserves
     its own space in flow — add back the clearance so .page's content
     doesn't start underneath it. --deck-safe-top already exists for
     exactly this "header's height" purpose (see its own definition). */
  padding-top: var(--deck-safe-top);
}

/* .page-fade-solid spans .ui's ENTIRE box (inset: 0, not a fixed
   height) so the fixed hero underneath can never bleed through, no
   matter how far down .ui's own content runs -- covering is
   structural, not scroll-timed. .page-fade-gradient sits on top of it
   but only across .ui's own top edge, softening just the seam where
   the fixed hero hands off to .ui's solid ground into a blend instead
   of a hard line. Both are z-index: 0 and come first in .ui's markup,
   so header/.page (z-index: auto, later in DOM) paint above them with
   no extra stacking rules needed. */
.page-fade-solid {
  position: absolute;
  inset: 0;
  z-index: 0;
  background: var(--color-bg);
  pointer-events: none;
}

.page-fade-gradient {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 18svh;
  z-index: 0;
  background: linear-gradient(to bottom, transparent, var(--color-bg));
  pointer-events: none;
}

/* The island is on screen from the very first frame now -- it just
   shows different content depending on data-mode. "hero" mode (the
   scrolling marquee, standing in for the old static "scroll to view
   work" cue) morphs in place into the normal stats bar once the intro
   hands off (see finishHeroIntro in script.js), Dynamic-Island style:
   same element, same position, only the content inside changes. */
.island[data-mode="hero"] .island-bar,
.island[data-mode="hero"] .island-panel {
  display: none;
}
.island:not([data-mode="hero"]) .island-hero-cue {
  display: none;
}

.island-hero-cue {
  width: 100%;
  height: 100%;
  border: none;
  background: none;
  padding: 0;
  cursor: pointer;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 14%, #000 86%, transparent);
  mask-image: linear-gradient(90deg, transparent, #000 14%, #000 86%, transparent);
}

.island-hero-track {
  display: flex;
  width: max-content;
  height: 100%;
  align-items: center;
  animation: islandMarquee 9s linear infinite;
}

.island-hero-group {
  flex: none;
  padding: 0 var(--sp-3);
  font-family: var(--ff-mono);
  font-size: var(--fs-12);
  letter-spacing: var(--ls-label);
  text-transform: uppercase;
  /* --island-shell-ink, not --color-text-primary: the shell itself is a
     near-black chip even in light theme (see --island-shell), so this
     needs the color already designed to sit on THAT background, not
     the page's own text color. */
  color: var(--island-shell-ink);
}

@keyframes islandMarquee {
  from { transform: translateX(0); }
  to { transform: translateX(-50%); }
}

@media (prefers-reduced-motion: reduce) {
  .island-hero-track { animation-duration: 24s; }
}

/* Fallback card shown when a mailto: link opens no mail app. */
.mail-fallback {
  position: fixed;
  left: 50%;
  bottom: var(--sp-6);
  transform: translateX(-50%);
  z-index: 1000;
  width: min(92vw, 360px);
  padding: var(--sp-4);
  border-radius: var(--radius-feature);
  background: var(--island-shell);
  color: var(--island-shell-ink);
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.35);
  font-family: inherit;
}
.mail-fallback-title { margin: 0; font-weight: 600; }
.mail-fallback-addr { margin: var(--sp-1) 0 var(--sp-3); color: var(--island-shell-ink-dim); overflow-wrap: anywhere; }
.mail-fallback-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.mail-fallback-actions button,
.mail-fallback-actions a {
  padding: var(--sp-2) var(--sp-3);
  border: 1px solid var(--island-shell-border);
  border-radius: 999px;
  background: none;
  color: inherit;
  font: inherit;
  text-decoration: none;
  cursor: pointer;
}
@media (hover: hover) {
.mail-fallback-actions button:hover,
.mail-fallback-actions a:hover { background: var(--island-wash); }
}

/* Contact modal opened from the case-page CTA. */
.contact-modal {
  position: fixed;
  inset: 0;
  z-index: 1000;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--sp-4);
  background: rgba(0, 0, 0, 0.55);
}
.contact-modal-card {
  display: flex;
  flex-direction: column;
  gap: var(--sp-3);
  width: min(100%, 380px);
  padding: var(--sp-5);
  border-radius: var(--radius-feature);
  background: var(--island-shell);
  color: var(--island-shell-ink);
}
.contact-modal-title { margin: 0 0 var(--sp-1); font-weight: 600; }

/* Shimmer over a card icon / device screen until its media has loaded
   (see skel() in script.js). Same tokens as the case pages' skeleton. */
.skel { position: relative; }
.skel::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background:
    linear-gradient(100deg, transparent 35%, var(--plate-chip) 50%, transparent 65%),
    var(--panel-bg-alt);
  background-size: 200% 100%, 100% 100%;
  animation: caseMediaShimmer 1.6s ease-in-out infinite;
}
@keyframes caseMediaShimmer {
  0% { background-position: 200% 0, 0 0; }
  100% { background-position: -200% 0, 0 0; }
}
@media (prefers-reduced-motion: reduce) {
  .skel::before { animation: none; }
}

/* Phones: the card's secondary line just repeats the title's idea. */
@media (max-width: 900px) {
  .deck-card .card-flow { display: none; }
}

/* No 300ms double-tap-zoom wait on tappable things: a single tap acts. */
a, button, [role="button"], summary, label, input, select, textarea {
  touch-action: manipulation;
}

/* Language switch: same pill entry as the nav links, set in the mono face
   so it reads as a code (EN / FR), not a word. */
.nav .lang-switch {
  font-family: var(--ff-mono);
  font-size: var(--fs-12);
  letter-spacing: 0.06em;
}

/* Service tag on the mobile deck cards' image panel. */
.card-tag { display: none; }
@media (max-width: 900px) {
  .deck-card .card-panel { position: relative; }
  .deck-card .card-tag {
    display: inline-block;
    position: absolute;
    top: var(--sp-3);
    left: var(--sp-3);
    padding: 5px 11px;
    border: 1px solid var(--chip-border);
    border-radius: 999px;
    font-family: var(--ff-mono);
    font-size: var(--fs-12);
    letter-spacing: 0.04em;
    line-height: 1;
    color: var(--color-text-muted);
    pointer-events: none;
    z-index: 3;
    /* opaque, so it never fights with the device behind it */
    background: #fff;
  }
  :root[data-theme="dark"] .deck-card .card-tag { background: #000; }
}

/* Language switch on the yellow splash (top right): the same discreet
   EN/FR control as the menu, in the splash's own ink. Sits above the
   splash, is removed with it. */
.loading-grid-lang {
  position: fixed;
  top: max(var(--sp-4), env(safe-area-inset-top));
  right: var(--sp-4);
  z-index: 10000;
  padding: 8px 12px;
  border: 1px solid rgba(16, 17, 6, 0.4);
  border-radius: 999px;
  font-family: var(--ff-mono);
  font-size: var(--fs-12);
  letter-spacing: 0.06em;
  line-height: 1;
  color: #101106;
  text-decoration: none;
  transition: opacity 0.4s var(--ease), background 0.3s var(--ease);
}
@media (hover: hover) {
  .loading-grid-lang:hover { background: rgba(16, 17, 6, 0.08); }
}
