@import "tailwindcss";

/* ================================================================== */
/*  Denis Stritar — Design System Foundations                          */
/*  Light / dark theming driven by [data-theme] on <html>.             */
/*  This file is the single source                                     */
/*  of truth for tokens; the Figma "Portfolio" library mirrors it.     */
/*  See docs/tokens.md for the human-readable reference.                */
/* ================================================================== */

/* ------------------------------------------------------------------ */
/*  1. Per-theme runtime palettes (ported verbatim from the prototype).*/
/*     [data-theme] re-points these CSS variables; every Tailwind      */
/*     utility below resolves through them, so a single attribute flip */
/*     re-themes the whole surface. Default = light.                   */
/* ------------------------------------------------------------------ */
:root,
:root[data-theme="light"] {
  --text: #2f2f2f;
  --bg-base: #edeceb;
  --btn-bg: rgba(47, 47, 47, 0.85);
  --btn-bg-hover: rgba(47, 47, 47, 1);
  --btn-text: #f7f5f4;
  --glass: rgba(255, 255, 255, 0.3);
  --glass-hover: rgba(255, 255, 255, 0.42);
  /* Rim direction follows the Figma GLASS "Light" at —45° — dim on the unlit
     top-left, lit on the bottom-right — but the measured 80% intensity reads
     as a solid white outline in situ, so it runs far quieter. Deliberate
     departure from node 68:528; see docs/tokens.md. */
  --glass-rim-dim: rgba(255, 255, 255, 0.26);
  --glass-rim-lit: rgba(255, 255, 255, 0.36);
  --glass-bevel-shadow: rgba(0, 0, 0, 0.045);
  /* Retuned when the fill went 0.40 → 0.30: the thinner fill leaves more
     headroom to white, so 0.55 overshot the measured +4/255 peak. */
  --glass-bevel-light: rgba(255, 255, 255, 0.44);
  /* Empty-tile fill for the bottom rail — Figma node 79:253, measured as a flat
     #f7f6f7 (the same unbound raw hex the header's hover chip uses), and now
     used literally. This was rgba(255,255,255,.5), which resolved to #f6f6f5
     over --bg-base — within 2/255 of the comp, but a wash rather than a card.
     Opaque closes that gap exactly and gives the tiles their own surface.
     The trade is real and intended: the rail floats over the WebGL model, so
     three opaque rectangles now occlude it. --surface-hover stays translucent
     for precisely the reason dropped here — don't "fix" the inconsistency.
     Once a tile carries an image or video this is only the well behind it. */
  --tile-media: #f7f6f7;
  /* The tile's hover scrim — Figma node 88:307, state2, used at its literal
     rgba(255,255,255,.65). Sits over the media, not over the page, so unlike
     --surface-hover it can afford to be this heavy: the thing it obscures is
     the tile's own image, and obscuring it is the point. */
  --tile-scrim: rgba(255, 255, 255, 0.65);
  /* Halo behind the label, the comp's `0 0 12px white`. It does the work the
     scrim alone cannot: 0.65 white over an image is not a uniform ground, and
     a dark glyph landing on a light patch of photo needs its own separation. */
  --tile-scrim-glow: rgba(255, 255, 255, 1);
  --hairline: rgba(47, 47, 47, 0.12);
  /* Hover fill for transparent icon chrome — Figma component 81:277,
     state=hover. Measured there as a flat #f7f6f7, which is an unbound raw
     hex; it enters the system here so the two stop drifting. Kept
     *translucent* rather than the literal hex: the header floats over the
     WebGL model, and an opaque chip would punch a rectangle out of it on
     hover. 0.5 white over --bg-base resolves to #f6f6f5 — within 2/255 of
     the comp — and stays honest over the model. */
  --surface-hover: rgba(255, 255, 255, 0.5);
  /* Reading surface for the case-study panel (§4c). The same hex as
     --tile-media on purpose: the panel is a card surface at page scale, and
     the site has exactly one card colour. A separate token anyway, because
     the two can legitimately diverge — the well behind tile media and a
     full page of prose have different jobs. Opaque like --tile-media, and
     for the same traded reason: it occludes the WebGL model behind it.
     No --panel-fg partner: --text reads on this surface in both themes. */
  --panel: #f7f6f7;
}

:root[data-theme="dark"] {
  --text: #f7f5f4;
  --bg-base: #292623;
  --btn-bg: rgba(247, 245, 244, 0.85);
  --btn-bg-hover: rgba(247, 245, 244, 1);
  --btn-text: #2f2f2f;
  --glass: rgba(41, 38, 35, 0.3);
  --glass-hover: rgba(41, 38, 35, 0.42);
  /* Dark flips the rim to black — any white here glows. The alphas read
     inverted against the names on purpose: `dim` is the top-left, away from
     the —45° light, so on a dark edge it is the stronger one. */
  --glass-rim-dim: rgba(0, 0, 0, 0.4);
  --glass-rim-lit: rgba(0, 0, 0, 0.24);
  --glass-bevel-shadow: rgba(0, 0, 0, 0.24);
  --glass-bevel-light: rgba(255, 255, 255, 0.09);
  /* No dark comp exists for the rail, so this is matched on *delta*. What sits
     behind the tiles is the bottom of the gradient backdrop (~#3d3a37), not
     flat --bg-base: #5b5855 lifts +30/255 off that. The light tile lifts +33
     off its own ground (#f7f6f7 over ~#d6d5db). Same step in both themes.
     Holds the palette's warm ramp too — R/G/B step +3/+3, the same as
     --bg-base (#292623) and the gradient's bottom stop. */
  --tile-media: #5b5855;
  /* No dark comp for the hover state either. The alpha carries over from light
     unchanged — it is what makes the media legible-but-recessive, and that
     reading is the same in both themes — over the palette's warm dark base,
     the one --glass already uses. What flips is the halo: light text on a dark
     scrim needs a dark glow to separate, so the sign inverts with the fill. */
  --tile-scrim: rgba(41, 38, 35, 0.65);
  --tile-scrim-glow: rgba(0, 0, 0, 0.55);
  --hairline: rgba(247, 245, 244, 0.14);
  /* Dark has no comp to measure, so it is matched on *delta* instead. What is
     actually behind the header is the gradient backdrop, not flat --bg-base:
     ~#282523 up here, so 0.09 lands on ~#3b3835 — about +19/255. The light
     chip over its own gradient (~#d6d5db) lands +20. The two lifts read the
     same, which is the point. Note --glass-hover cannot stand in here: at
     0.42 over a dark ground it *is* the background, i.e. invisible. */
  --surface-hover: rgba(247, 245, 244, 0.09);
  /* Not --tile-media's #5b5855 — that is matched on delta against the
     gradient for a small media well, and a full reading surface that light
     would glow. This is --bg-base (#292623) lifted +10/255 on the palette's
     warm R−G=3/G−B=3 ramp: one card step off the page, dark enough to read
     against for a long stretch of prose. */
  --panel: #33302d;
}

/* ------------------------------------------------------------------ */
/*  2. Static design tokens → Tailwind utilities.                      */
/*     `inline` makes color utilities resolve through the runtime      */
/*     variables above (so bg-base, text-fg, … follow the mood).       */
/* ------------------------------------------------------------------ */
@theme inline {
  /* Semantic colors (mood-aware) */
  --color-fg: var(--text);
  --color-base: var(--bg-base);
  --color-btn: var(--btn-bg);
  --color-btn-hover: var(--btn-bg-hover);
  --color-btn-fg: var(--btn-text);
  --color-glass: var(--glass);
  --color-glass-hover: var(--glass-hover);
  --color-tile-media: var(--tile-media);
  --color-tile-scrim: var(--tile-scrim);
  --color-panel: var(--panel);
  --color-hairline: var(--hairline);
  --color-surface-hover: var(--surface-hover);

  /* Typography */
  --font-heading: var(--font-outfit), ui-sans-serif, system-ui, sans-serif;
  --font-body: var(--font-outfit), ui-sans-serif, system-ui, sans-serif;
  --font-sans: var(--font-outfit), ui-sans-serif, system-ui, sans-serif;
  --font-mono: var(--font-roboto-mono), ui-monospace, monospace;
  /* The reading face, distinct from --font-body: Outfit is a display type and
     the case-study column is read end to end. Used by .article-prose (§4d)
     and nowhere else — the site chrome stays on one face. */
  --font-reading: var(--font-inter), ui-sans-serif, system-ui, sans-serif;

  /* Radii */
  --radius-xs: 4px;
  --radius-sm: 6px;
  --radius-md: 8px;
  --radius-lg: 12px;
  --radius-xl: 16px;

  /* Layout */
  --container-max: 1440px;

  /* Motion. The site's one curve, named after what §4a already calls it —
     easeOutQuint. It was written out literally in each rule that needed it,
     which was fine while every use was in this file; the tile hover states are
     the first that live in a component, and `ease-[cubic-bezier(…)]` spelled
     out in JSX is exactly the kind of unbound literal AGENTS.md keeps out of
     components. The rules below still declare it inline: they are shorthands,
     and a var() inside `animation:` is more fragile than it is worth. */
  --ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
}

/* ------------------------------------------------------------------ */
/*  2b. Fluid type scale.                                              */
/*                                                                     */
/*  A *plain* @theme, deliberately not the `inline` block above.       */
/*  `inline` freezes the value into the utility at build time, which   */
/*  means the utility emits no var() — and emission of the :root       */
/*  declaration is then contingent on some other var() reference       */
/*  surviving elsewhere in the sheet. Plain @theme makes `.text-body`  */
/*  emit `font-size: var(--text-body)` and guarantees the variable     */
/*  reaches :root, which @layer base below depends on. Multiple @theme */
/*  blocks are merged; the flags are per-block.                        */
/*                                                                     */
/*  Every size interpolates between the viewport bounds 390px and      */
/*  1440px — the grid-page mobile boundary and --container-max, so the */
/*  type stops growing exactly where the container does. Outside that  */
/*  range clamp() pins to the endpoints.                               */
/*                                                                     */
/*  The slope term is `rem + vw`, never bare vw: a bare-vw size ignores */
/*  the reader's browser font-size setting and fails WCAG 1.4.4. The   */
/*  rem part keeps it responsive to both.                              */
/*                                                                     */
/*  Endpoints are not invented — they are the values the old stepped   */
/*  scale already used, so 390px and 1440px render identically to the  */
/*  previous mobile and desktop steps. Only the middle is new.         */
/*                                                                     */
/*  Weight is deliberately NOT paired into these tokens. The same size */
/*  legitimately appears at two weights (base h1 is 700, the hero      */
/*  tagline is 400), so a --text-display--font-weight would contradict */
/*  one of them. Weight stays orthogonal, via font-normal / font-bold. */
/* ------------------------------------------------------------------ */
@theme {
  /* Clears Tailwind's default text-xs…text-9xl ramp, so every size on
     the site has to come from the scale below and nothing can quietly
     bypass it. Must be the FIRST declaration here: clearing runs in
     declaration order and would eat anything above it.

     Narrower than it looks — the match is `startsWith("--text")` against
     an allowlist, so --text-shadow-*, --text-indent, --text-decoration-*
     and --text-underline-offset are all protected, and --color-* is
     untouched (text-fg and friends keep working). Note the palette's own
     `--text` at the top of this file survives only because it lives in a
     plain :root rule rather than in a @theme block — never move it in. */
  --text-*: initial;

  /*                          390px → 1440px */
  --text-caption: clamp(0.6875rem, 0.6643rem + 0.0952vw, 0.75rem); /* 11 → 12 */
  --text-caption--line-height: 1.4;

  --text-label: clamp(0.8125rem, 0.7893rem + 0.0952vw, 0.875rem); /* 13 → 14 */
  --text-label--line-height: 1.2;

  --text-body: clamp(0.875rem, 0.8286rem + 0.1905vw, 1rem); /* 14 → 16 */
  --text-body--line-height: 1.55;

  --text-lead: clamp(1rem, 0.9536rem + 0.1905vw, 1.125rem); /* 16 → 18 */
  --text-lead--line-height: 1.5;

  --text-heading: clamp(1.125rem, 1.0786rem + 0.1905vw, 1.25rem); /* 18 → 20 */
  --text-heading--line-height: 1.3;

  --text-title: clamp(1.25rem, 1.1571rem + 0.381vw, 1.5rem); /* 20 → 24 */
  --text-title--line-height: 1.25;

  /* 1.25 is the comp's 30/24 exactly, and being unitless it holds at
     every step of the clamp — 40px at the 32px end. */
  --text-display: clamp(1.5rem, 1.3143rem + 0.7619vw, 2rem); /* 24 → 32 */
  --text-display--line-height: 1.25;

  --text-display-lg: clamp(2rem, 1.6286rem + 1.5238vw, 3rem); /* 32 → 48 */
  --text-display-lg--line-height: 1.1;

  /* Eyebrow tracking. Tailwind's widest is 0.1em, nowhere near enough for
     a mono uppercase label; this replaces the one arbitrary tracking
     value left in the codebase. */
  --tracking-eyebrow: 0.3em;

  /* The tile label's halo — Figma node 88:307, state2, `0 0 12px white`. Safe
     in this block despite the `--text-*: initial` above it: the clear matches
     an allowlist of size keys, and --text-shadow-* is not on it. The colour
     stays a var() so it flips with the theme; only the geometry is frozen. */
  --text-shadow-tile-label: 0 0 12px var(--tile-scrim-glow);
}

/* ------------------------------------------------------------------ */
/*  3. Gradient backdrops per theme. Both layers are always mounted;   */
/*     CSS fades opacity from [data-theme] (see GradientBackdrop).      */
/* ------------------------------------------------------------------ */
.bg-stack {
  position: fixed;
  inset: 0;
  z-index: -2;
  pointer-events: none;
}
.bg-layer {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 600ms ease;
  will-change: opacity;
}
.bg-layer[data-mood="light"] {
  background: radial-gradient(50% 50% at 50% 50%, #ffffff 0%, #d6d5db 100%);
}
.bg-layer[data-mood="dark"] {
  background: linear-gradient(180deg, #222324 0%, #292623 50%, #3d3a37 100%);
}
/* Hard switch by theme: the active layer fades in, the other out (600ms). */
:root[data-theme="light"] .bg-layer[data-mood="light"],
:root[data-theme="dark"] .bg-layer[data-mood="dark"] {
  opacity: 1;
}

/* ------------------------------------------------------------------ */
/*  4. Layout primitives.                                              */
/* ------------------------------------------------------------------ */

/* Responsive page grid: 4 / 8 / 12 columns. Mirrors the prototype's
   breakpoints (≤390 / 391–768 / ≥769). */
@utility grid-page {
  display: grid;
  width: 100%;
  max-width: var(--container-max);
  margin-inline: auto;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  padding-inline: 16px;
  gap: 16px;
  @media (min-width: 391px) {
    grid-template-columns: repeat(8, minmax(0, 1fr));
    padding-inline: 24px;
    gap: 24px;
  }
  @media (min-width: 769px) {
    grid-template-columns: repeat(12, minmax(0, 1fr));
    padding-inline: 96px;
    gap: 32px;
  }
}

/* Chrome inset. The floor under the content column below, the distance from the
   *top* the header still uses, and the corner the article overlay pins to:
   32px at desktop, per Figma node 81:303, where 32 is also the distance from
   the top.

   Deliberately *not* grid-page. That centres a 1440px container and pads 96px
   inside it, which lands content 240px from the edge on a 1728 viewport, on a
   third measure that agrees with nothing else on the page. The two narrow steps
   do match grid-page (16 / 24), where an 8px disagreement between the two
   systems would be obvious. Same breakpoints as grid-page. */
:root {
  --page-inset: 16px;
}
@media (min-width: 391px) {
  :root {
    --page-inset: 24px;
  }
}
@media (min-width: 769px) {
  :root {
    --page-inset: 32px;
  }
}

@utility page-inset {
  padding-inline: var(--page-inset);
}

/* The content column — Figma node 79:253. The comp puts four things on one
   centred 1200px measure, x=264 → 1464 on the 1728 frame: the logo (81:252),
   the tagline (81:306), the button cluster's right edge (81:287) and the tile
   row (87:241). So the column is a token both the header and the rail read,
   rather than a number restated in two places — --tile-rail-w *is* this, and
   moving one measure moves the other by construction.

   max(), not a media query: the column can never sit tighter than the chrome
   inset, so the padding falls back to --page-inset on its own once the viewport
   narrows past 1264 (= 1200 + 2 × 32) and there is no breakpoint to keep in
   sync. Below that the rail keeps its own 8px gutter and the two disagree by up
   to 24px — the header floors first, deliberately: chrome 8px off the edge
   reads as a mistake in a way a slightly wider tile row does not.

   The 100% is safe inside a custom property. Custom properties substitute as
   tokens and resolve percentages at the *using* property, so this is the
   padding box's containing block — for <header> and <main>, both full-width
   blocks in the page column, that is the viewport. */
:root {
  --content-max: 1200px;
  --content-inset: max(
    var(--page-inset),
    calc((100% - var(--content-max)) / 2)
  );
}

@utility page-measure {
  padding-inline: var(--content-inset);

  /* With a study open the column hands the header back to the corner (§4c);
     this is the ~232px slide between the two, on the site's one clock. The
     curve is written out rather than var(--ease-out-quint), matching every
     other shorthand in this file. */
  transition: padding-inline 600ms cubic-bezier(0.22, 1, 0.36, 1);
}

/* Tile cluster — Figma node 87:241 ("Frame 20"): a centred row of square tiles
   8px apart. The cap is the content column, so each tile is
   (1200 − 16) / 3 = 394.67 square at full size — the comp's 394.6666.

   Not full-bleed and not grid-page. The same measure as the header and the
   tagline above it, which is the whole point: the comp draws the row from 264
   to 1464 on a 1728 frame and puts the logo glyph and the button cluster's
   right edge on those exact two x's. The 8px gutter is the *minimum* side
   margin, only in play below ~1216px where the cluster stops being able to hold
   its 1200 — and it is a smaller floor than the header's --page-inset, so
   between 769 and 1264 the two edges drift; see --content-inset. */
:root {
  --tile-rail-gutter: 8px;
  --tile-rail-w: var(--content-max);

  /* Minimum gap between the tagline and the first tile. Spent as *padding* on
     the section, never as a margin: the section is placed by `margin-top: auto`
     — the flex push that pins it to the bottom of the column — and a top margin
     would compete with that, whereas padding sits inside the box and leaves the
     push alone. It is also invisible whenever the rail is centred, because the
     row's top edge is measured up from the column's bottom (see the drop) and
     padding above it does not move it. It only shows up once the auto margin
     has collapsed to 0, i.e. stacked, or on a viewport too short to centre in —
     exactly where a floor is wanted. */
  --tile-rail-lead: 16px;

  /* Second cap on the row, this one derived from the viewport *height*: the
     widest row whose square still fits centred without pushing its top edge
     above the ceiling. Invert `(100dvh − tile) / 2 >= ceiling` for the tile,
     then re-inflate it into a row of three plus the two gutters. Without this
     the row would hold 394.67 and the centring would have to give instead — a
     720px window wants a 272 tile, and 394.67 there is 61px off centre.

     A no-op while stacked (it defers to --tile-rail-w) and a no-op above ~843px
     tall (448 + 394.67), where the 1200 cap binds first and the tiles are full
     size. The 240px floor keeps the row from collapsing on an absurdly short
     window; below about 523px tall it is centring that yields, not the row.

     Note the header does *not* follow this cap. It stays on the full column, so
     the chrome never slides sideways when the window is resized vertically —
     the price is that below ~843px tall the row is narrower than the row above
     it. */
  --tile-rail-fit: var(--tile-rail-w);

  /* The tile edge, for the centring math below only. The utility derives height
     from `aspect-ratio: 1` against the real `100%`; a custom property cannot
     see the container, so this restates the same caps in `vw`. The two agree
     exactly from 1216px up, where one of the caps binds, and differ by at most
     a scrollbar width below it — under 3px once halved into the drop. */
  --tile-rail-h: calc(
    (
        min(
          var(--tile-rail-w),
          var(--tile-rail-fit),
          100vw - var(--tile-rail-gutter) * 2
        ) - var(--tile-rail-gutter) * 2
      ) / 3
  );

  /* The highest the row's top edge may go: chrome + tagline (~208px at the top
     of the type clamp — 32 inset + 48 row + 8 + a three-line 120px h1) plus the
     lead. Conservative on purpose: below 1440 the type clamp has not topped out
     and the real block is shorter, so the ceiling only ever errs downward. */
  --tile-rail-ceiling: calc(208px + var(--tile-rail-lead));

  /* How far *below* centre the cluster rides. Zero by default — the placement
     is the viewport's midline — and 5dvh once the window is 800px tall or less,
     where the fit cap has shrunk the row and the composition reads better with
     the extra air opened up under the tagline. Sinking is always safe: it moves
     the row away from the tagline and shortens the column, so it can neither
     collide nor raise a scrollbar. */
  --tile-rail-sink: 0px;

  /* Off the bottom edge — see the `mt-auto` on the section, which pushes the
     cluster down and then lets this lift it back up. Stacked, that is the plain
     gutter: centring is meaningless when the three squares alone are ~1093px on
     a 390 and the page has to scroll regardless. */
  --tile-rail-drop: var(--tile-rail-gutter);
}

/* Centred in the viewport, per node 87:241 — the comp puts the row's midline on
   the frame's (361 + 394.67/2 = 558.3 in 1117, whose centre is 558.5). With
   `mt-auto` pinning the cluster to the bottom of the min-h-dvh column, a bottom
   margin of half the leftover is exactly that.

   Deliberately not gated on height: it is the *row* that yields on a short
   window, not the placement. --tile-rail-fit above has already narrowed the row
   so its square fits centred clear of the tagline, which is why the first term
   of the min() wins everywhere the fit cap is doing its job. --tile-rail-sink
   then rides the row below that midline on windows 800px and shorter.

   The other two terms are pure backstops for the range below the fit's 240px
   floor, where the row has stopped shrinking and the geometry finally runs out:

   · min() against the ceiling. Chrome + tagline occupy the top ~224px, and they
     overlap the row horizontally (they share its left edge now, and a centred
     1200 row starts at x=120 on a 1440), so the row cannot be allowed to ride up
     into them. This pins it under the tagline instead — still as high as it can
     go, and crucially with the column still fitting, where centring anyway would
     have grown it past the fold and scrolled over the empty space beneath.

   · max() against the gutter. Shorter still, even the pinned position overflows
     and the second term goes negative; this stops the margin from inverting and
     dragging the row past the column's bottom edge.

   It has to be arithmetic and not a shrinkable flex item, because the page
   column is `min-h-dvh`: it *grows* to fit its content and never develops the
   negative free space that would make a flex item yield. */
@media (min-width: 769px) {
  :root {
    --tile-rail-fit: max(
      240px,
      calc(
        3 * (100dvh - 2 * var(--tile-rail-ceiling)) +
          var(--tile-rail-gutter) * 2
      )
    );

    --tile-rail-drop: max(
      var(--tile-rail-gutter),
      min(
        calc((100dvh - var(--tile-rail-h)) / 2 - var(--tile-rail-sink)),
        calc(100dvh - var(--tile-rail-h) - var(--tile-rail-ceiling))
      )
    );
  }
}

/* The sink only applies where the fit cap is doing its work, i.e. on a window
   short enough that the row has already given up some size. 800 is the boundary
   the design calls short; above it the row is full-size and dead centre. */
@media (min-width: 769px) and (max-height: 800px) {
  :root {
    --tile-rail-sink: 5dvh;
  }
}

@utility tile-rail {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--tile-rail-gutter);

  /* Three caps, smallest wins: the content column, the height-derived fit that
     keeps the square centred on a short window, and the viewport less a gutter
     each side. min() rather than max-width + padding — padding would eat into
     the 1200 and leave the tiles 389.33 instead of 394.67. This way the box *is*
     1200 when there is room, and 8px off each edge when there is not. */
  width: min(
    var(--tile-rail-w),
    var(--tile-rail-fit),
    100% - var(--tile-rail-gutter) * 2
  );
  margin-inline: auto;

  /* Height comes from the width, which is what makes this composition safe on
     the vertical axis for free: the cluster caps at 1200, so the tiles cap at
     394.67 square and cannot grow to swallow a tall screen — and the fit cap is
     the same trick against a *short* one, trading tile size for staying
     centred. Square is the comp's tile, and it holds all the way down as the
     cluster narrows — including stacked, where each tile is the column width. */
  & > * {
    aspect-ratio: 1;
  }

  /* 769 is the site's own desktop step (see --page-inset and grid-page), not
     Tailwind's 640. Three across at 390px would be 119px wide. */
  @media (min-width: 769px) {
    grid-template-columns: repeat(3, minmax(0, 1fr));
  }
}

/* Registered so it can be transitioned, and inheriting so a parent can ramp
   every descendant surface at once. */
@property --glass-blur {
  syntax: "<length>";
  inherits: true;
  initial-value: 43px;
}

/* Frosted-glass slab, ported from the Figma GLASS effect on node 68:528.
   Frost 43 → blur(43px) · Light 80% @ —45° → the rim gradient and the bevel
   direction · Depth 20 / Splay 100 → a 14px fully-soft inner bevel.
   Refraction 80 and Dispersion 50 have no CSS equivalent: the bevel stands in
   for refraction, dispersion is dropped. See docs/tokens.md. */
@utility glass {
  --glass-depth: 11px;

  background: var(--color-glass);
  /* Prefixed first: Lightning CSS collapses the pair to whichever comes last,
     and Firefox only understands the unprefixed property.

     The radius is inherited, never declared here — declaring it would shadow
     the value the tile cluster animates down during cloud mode. The literal
     fallback covers browsers without @property, where the custom property
     would otherwise be invalid at computed-value time and drop the blur. */
  -webkit-backdrop-filter: blur(var(--glass-blur, 43px));
  backdrop-filter: blur(var(--glass-blur, 43px));

  /* No border: inset shadows paint inside the *padding* box, so a border —
     even a transparent one — would push the rim a pixel inward and leak the
     backdrop past it. The rim is the outermost pixel, as measured.

     Rim first (it paints on top), split into two 1px lines along the —45°
     light axis: dim on top-left, lit on bottom-right. A border-box gradient
     would be more faithful to the smooth ramp, but it tints the whole face
     when the fill is translucent — which ours, at 0.4, is.

     Then the bevel: offset == —spread puts each band's far edge exactly on
     the opposite side, so one shadow shades the top-left and the other
     lights the bottom-right; blur == 2×depth is Splay 100. */
  box-shadow:
    inset 1px 1px 0 0 var(--glass-rim-dim),
    inset -1px -1px 0 0 var(--glass-rim-lit),
    inset var(--glass-depth) var(--glass-depth) calc(var(--glass-depth) * 2)
      calc(var(--glass-depth) * -1) var(--glass-bevel-shadow),
    inset calc(var(--glass-depth) * -1) calc(var(--glass-depth) * -1)
      calc(var(--glass-depth) * 2) calc(var(--glass-depth) * -1)
      var(--glass-bevel-light);
}

/* Shallower bevel for small surfaces — 14px swallows a 64px icon tile.
   Composes with the above: `glass glass-sm`. */
@utility glass-sm {
  --glass-depth: 5px;
}

/* Its own token, NOT --glass-blur. That one is @property-registered and
   inheriting so a whole cluster can be ramped from an ancestor; declaring it on
   a glass element shadows that ramp, which is the trap docs/tokens.md warns
   about. 15px also has no relationship to glass's 43 — it is sized to the media
   it frosts, not to the page. Plain :root rather than @theme: nothing needs a
   `blur-*` utility out of it, and `tile-media-fill` below has to do arithmetic
   on it, which @theme inline would freeze at the wrong moment. */
:root {
  --tile-scrim-blur: 15px;
}

/* The tile's hover scrim — Figma node 88:307, state2. Related to `glass` but
   deliberately not it: frost over a 0.65 fill, no rim and no bevel. `glass` is
   a slab floating over the page; this is a wash *inside* a card, and an inner
   bevel on something already hard-clipped to 8px reads as a second border.

   Pair it with `tile-media-fill` on the media underneath — see the note there,
   which is the difference between this looking frosted and looking framed.

   Radius is inherited, never declared — same reason as `glass`. */
@utility tile-scrim {
  background: var(--color-tile-scrim);
  /* Prefixed first, per the note in `glass`. */
  -webkit-backdrop-filter: blur(var(--tile-scrim-blur));
  backdrop-filter: blur(var(--tile-scrim-blur));
}

/* Media that fills a tile, bled past the tile's edges on every side.

   The bleed is the fix for a real artifact, not a stylistic crop. A blur needs
   to read pixels from beyond each output pixel; where the backdrop simply ends,
   `backdrop-filter` has nothing to gather and resolves back toward the
   unfiltered source. With the media flush to the tile the result was a sharp,
   perfectly readable band around the whole perimeter, about 2× the blur radius
   wide, framing a properly frosted middle. Bleeding the media by that same 2×
   puts real pixels under the scrim's edges; `overflow-hidden` on the tile then
   clips the excess, so nothing outside the 8px radius is ever painted.

   Consequences worth knowing before touching this:
   • It costs a crop. The media is scaled to cover a box ~18% larger than the
     tile, so the outer ~9% is always clipped. Frame accordingly.
   • It is tied to --tile-scrim-blur. Raising the blur without this following
     it brings the band straight back.

   Sized explicitly rather than by opposing insets, and `max-width: none` is
   not optional. Preflight ships `img { max-width: 100%; height: auto }`, and
   100% of an absolutely positioned image's containing block is the tile — so
   the box gets clamped straight back to the tile's own size and the overhang
   collapses onto one corner, leaving the media 2× the blur short on the
   opposite two edges. In light that is invisible (--tile-media is near-white
   and so is the media); in dark it is an obvious L-shaped band. Stating the
   width and killing the max-width settles it in one direction only. Only
   top/left are set, so nothing is over-constrained. */
@utility tile-media-fill {
  position: absolute;
  top: calc(-2 * var(--tile-scrim-blur));
  left: calc(-2 * var(--tile-scrim-blur));
  width: calc(100% + 4 * var(--tile-scrim-blur));
  height: calc(100% + 4 * var(--tile-scrim-blur));
  max-width: none;
  object-fit: cover;
}

/* The first @keyframes in this codebase. Everything else here is a
   transition, which a mount entrance cannot be — there is no "before" state
   to leave from.

   Two things are load-bearing:

   • `backwards`, never `both`. `both` retains the final keyframe for good,
     and animation declarations outrank *inline* styles — so a retained
     `transform: none` would silently override any transform written later,
     from JS or otherwise. `backwards` covers the delay window, so a staggered
     child never flashes in at full opacity before dropping to 0, and then
     lets go. The `to` frame equals the element's natural computed style, so
     letting go is invisible.

   • No static `opacity: 0` anywhere. Reduced motion, no animation support,
     print, or a delay value that invalidates the shorthand all degrade to
     "already visible" rather than to a blank header.

   --reveal-delay and --reveal-from are read only through var() fallbacks and
   are never *declared* here. That is deliberate: this rule is unlayered, and
   an unlayered declaration beats the layered Tailwind utility
   (`[--reveal-delay:80ms]`) that is meant to set it. */
@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(var(--reveal-from, 8px));
  }
  to {
    opacity: 1;
    transform: none;
  }
}

.reveal {
  /* 600ms matches .bg-layer's crossfade and the hero canvas fade, so the page
     settles on one clock. easeOutQuint rather than the file's `ease`: `ease`
     is ease-in-out, and an entrance that starts slowly reads sluggish. */
  animation: reveal 600ms cubic-bezier(0.22, 1, 0.36, 1)
    var(--reveal-delay, 0ms) backwards;
}

/* ------------------------------------------------------------------ */
/*  4b. Cloud mode — the page steps aside for the model.               */
/* ------------------------------------------------------------------ */

/* Driven entirely by [data-cloud] on <html> (written by CloudModeProvider),
   so every element that hides stays a Server Component and only the toggle
   ships JavaScript.

   Four things are load-bearing:

   • `visibility`, never `display: none`. The header's right cluster has to
     keep the boxes of the logo, the LinkedIn button and the theme toggle or
     the cloud button slides left — nodes 81:287 and 87:276 put it in the same
     slot in both states. Same reason the tile rail keeps its height: nothing
     reflows, so nothing has to be re-measured on the way back.

   • visibility is transitioned on the *same* 600ms as the opacity rather than
     flipped instantly. It interpolates discretely, which does exactly the
     right thing in both directions for free: going out it holds `visible` for
     the whole duration and flips at the end, so the fade is seen; coming back
     it flips to `visible` at 0%, so the fade-in is seen. One declaration, no
     delay juggling.

   • This applies to *leaves* only — never to <header>, <main> or the page
     column. An ancestor below opacity 1 forms a backdrop root and leaves any
     glass inside it with nothing to sample (see docs/tokens.md).

     The rail is the one place where something below a .cloud-hide element does
     carry a backdrop-filter: each tile's hover scrim. It survives this fade,
     and the reason is worth stating exactly, because it does not generalise.
     What the scrim samples is its own sibling <img> — content *inside* the
     subtree being faded, which is still there no matter what the opacity is.
     The trap catches surfaces that sample the page *outside* the fading
     element; that content really is gone. `glass` proper under a .cloud-hide
     element would still need the --glass-blur ramp and this fade sequenced by
     hand.

     Measured, not reasoned: hovering a tile with this <section> pinned at
     opacity 0.99 and at 0.6 renders the frost in full, and the render is
     byte-identical with `isolation: isolate` removed from the tile — so the
     isolate is not what saves it and must not be credited with the job.
     Chromium only; re-check if Safari support ever matters.

   • No static `opacity: 0`, per the .reveal note above. With the attribute
     absent — which is every byte of the exported HTML — nothing matches and
     the page is simply visible.

   Keyboard access is not left to `visibility`, which only drops out of the
   tab order once the 600ms is up; CloudModeProvider sets `inert` on the same
   elements in the same tick. */
.cloud-hide {
  transition:
    opacity 600ms cubic-bezier(0.22, 1, 0.36, 1),
    visibility 600ms cubic-bezier(0.22, 1, 0.36, 1);
}

:root[data-cloud="on"] .cloud-hide {
  opacity: 0;
  visibility: hidden;
  /* Immediate, unlike the two above: the moment the mode flips, nothing
     behind the fade should be clickable. */
  pointer-events: none;
}

/* The header chrome retracts into the top edge rather than dissolving in
   place, and drops back down on the way in — the tagline and the tiles still
   fade straight, so the row that surrounds the surviving button is the only
   thing that visibly *moves*.

   Composed with .cloud-hide rather than folded into it, because the transform
   transition must not reach the skip link: it shares .cloud-hide, and a 600ms
   transform there would turn its focus slide-in into a slow crawl. So the
   whole shorthand is restated here — a bare `transition: transform` would
   reset the two properties above.

   Distance defaults to .reveal's own 8px, so the exit retraces the entrance. */
.cloud-hide.cloud-rise {
  transition:
    opacity 600ms cubic-bezier(0.22, 1, 0.36, 1),
    transform 600ms cubic-bezier(0.22, 1, 0.36, 1),
    visibility 600ms cubic-bezier(0.22, 1, 0.36, 1);
}

:root[data-cloud="on"] .cloud-rise {
  transform: translateY(calc(-1 * var(--cloud-rise, 8px)));
}

/* ------------------------------------------------------------------ */
/*  4c. Article mode — a case study opens over the page.               */
/* ------------------------------------------------------------------ */

/* Driven by [data-article="<tile id>"] on <html>, the cloud-mode pattern:
   ArticleProvider writes the attribute (and ArticleScript writes it before
   first paint on a direct #work/<id> load), everything visual below is
   CSS, and the rail plus all three articles stay Server Components.

   The three tile ids appear LITERALLY in this section — the per-article
   display rules, the selected-tile dim, the view-transition names. They
   mirror lib/tiles.ts; renaming one there without renaming it here means
   that article simply stops opening.

   Unlayered on purpose: the open state has to override Tailwind utilities
   (the rail's mt-auto, mb-[--tile-rail-drop], pt-[--tile-rail-lead]), and
   an unlayered rule beats anything in the utilities layer. */

/* Geometry. --article-top clears the header: --page-inset above the 48px
   row plus the same 16px breath --tile-rail-lead gives the tagline.
   --article-tile is the stacked tile's edge: capped at 216, kept
   proportionate on narrow desktops by the 15vw term, floored at 96 — and
   the dvh term guarantees three squares plus two gutters always fit
   between --article-top and the bottom inset, which is what lets the open
   state ignore the entire --tile-rail-drop centring machine above.
   Declared unconditionally: harmless below 769px, where the panel goes
   full-bleed and the rail keeps its stacked flow. */
:root {
  --article-top: calc(var(--page-inset) + 48px + 16px);
  --article-gap: 32px;
  --article-tile: max(
    96px,
    min(
      216px,
      15vw,
      calc(
        (
            100dvh - var(--article-top) - var(--page-inset) - 2 *
              var(--tile-rail-gutter)
          ) / 3
      )
    )
  );
}

/* The header hands the content column back to the corner. Open, the left stack
   and the panel are full-bleed at --page-inset (node 97:274 runs the panel to
   the frame's right edge), and chrome on a 1200 column over an overlay 32px off
   the edge reads as two grids at once — the logo would float 232px inside the
   rail it sits above. Closed, the column is the comp; open, the corner is.

   Re-pointing the variable rather than restating padding on .page-measure: the
   utility already reads it, so nothing has to out-specify the utilities layer
   the way the .article-rail rules below do. The slide rides .page-measure's own
   600ms, the same clock as .article-hide beside it. A no-op below 769px, where
   --content-max already exceeds the viewport and the max() has collapsed to
   --page-inset anyway. */
:root[data-article] {
  --content-inset: var(--page-inset);
}

/* The tagline steps aside for the panel — the .cloud-hide treatment keyed
   to [data-article]: same three declarations, same discrete-visibility
   trick, §4b has the reasoning for each. ArticleProvider sets `inert` in
   the same tick, exactly as CloudModeProvider does. The h1 carries both
   classes; the transitions are identical, so whichever matches last wins
   nothing. */
.article-hide {
  transition:
    opacity 600ms cubic-bezier(0.22, 1, 0.36, 1),
    visibility 600ms cubic-bezier(0.22, 1, 0.36, 1);
}

:root[data-article] .article-hide {
  opacity: 0;
  visibility: hidden;
  pointer-events: none;
}

/* Scroll lock while a study is open. The panel scrolls internally; below
   769px this is also what parks the stacked rail's scroll position behind
   the overlay instead of letting the two scroll in tandem. */
:root[data-article] {
  overflow: hidden;
}

/* Open state, desktop: the rail leaves the flex column for a fixed left
   stack. position: fixed is what neutralises the whole mt-auto /
   --tile-rail-drop centring arithmetic in one move — a fixed box takes no
   part in the column — so the margin and padding are zeroed rather than
   recomputed. The inner grid drops to one column; the utility's
   aspect-ratio keeps the squares. */
@media (min-width: 769px) {
  :root[data-article] .article-rail {
    position: fixed;
    top: var(--article-top);
    left: var(--page-inset);
    width: var(--article-tile);
    margin: 0;
    padding: 0;
  }

  :root[data-article] .article-rail .tile-rail {
    grid-template-columns: minmax(0, 1fr);
    width: 100%;
  }
}

/* The open tile reads selected by receding — 50%, per the comp direction.
   Interaction feedback, so it takes the 300ms clock. Safe under the
   backdrop-root rule for the same measured reason the scrim survives
   .cloud-hide: what the scrim samples is its own sibling <img>, inside
   this same subtree. */
[data-article-tile] {
  transition: opacity 300ms cubic-bezier(0.22, 1, 0.36, 1);
}

:root[data-article="one"] [data-article-tile="one"],
:root[data-article="two"] [data-article-tile="two"],
:root[data-article="three"] [data-article-tile="three"] {
  opacity: 0.5;
}

/* The panel. Opaque --panel, never `glass`: the panel carries .cloud-hide
   (cloud mode clears the whole page, article included), and an ancestor
   below opacity 1 would leave any backdrop-filter inside it sampling
   nothing — so nothing inside may ever frost (§4b). Styled here in full,
   the .skip-link precedent: it is page-level chrome, not grid content.
   display:none while closed keeps it out of the tab order and the
   accessibility tree with no inert bookkeeping. */
.article-panel {
  display: none;
}

:root[data-article] .article-panel {
  display: block;
  position: fixed;
  /* Over the header's 10 — relevant below 769px, where it goes full-bleed. */
  z-index: 20;
  top: var(--article-top);
  right: var(--page-inset);
  bottom: var(--page-inset);
  left: calc(var(--page-inset) + var(--article-tile) + var(--article-gap));
  overflow-y: auto;
  overscroll-behavior: contain;
  border-radius: var(--radius-lg);
  background: var(--color-panel);
  color: var(--color-fg);
  padding: clamp(24px, 5vw, 64px);
}

@media (max-width: 768px) {
  :root[data-article] .article-panel {
    inset: 0;
    border-radius: 0;
  }
}

/* The close control rides the top-right corner and stays there as the
   study scrolls. pointer-events splits row and button so the full-width
   row never blocks selecting the text that slides beneath it; the
   negative margin hands the row's 48px (the icon button's height) back,
   so the title still starts at the panel's padding edge. */
.article-close-row {
  position: sticky;
  top: 0;
  z-index: 1;
  display: flex;
  justify-content: flex-end;
  margin-bottom: -48px;
  pointer-events: none;
}
.article-close-row > * {
  pointer-events: auto;
}

/* One article per tile, all three in the exported HTML; only the one the
   attribute names is rendered. Ids mirror lib/tiles.ts — see the section
   note. */
.article-item {
  display: none;
}

:root[data-article="one"] .article-item[data-article-id="one"],
:root[data-article="two"] .article-item[data-article-id="two"],
:root[data-article="three"] .article-item[data-article-id="three"] {
  display: block;
}

/* --- The morph ---------------------------------------------------- */

/* Same-document view transitions animate the geometry change: the browser
   snapshots each *named* element before and after ArticleProvider flips
   the attribute, then tweens old to new. Hand-rolling this would mean
   measuring six rects per toggle and re-deriving them on every resize —
   and the API is the only practical way to tween between two different
   grid templates at all.

   The root is deliberately NOT captured — this overrides the UA sheet's
   `:root { view-transition-name: root }` — so everything unnamed keeps
   rendering live: the WebGL model keeps moving and the tagline's
   .article-hide fade runs as itself rather than as a frozen snapshot.

   Browsers without the API get the same state flip with no tween;
   prefers-reduced-motion is honoured in JS by skipping the API call, and
   in the reduced-motion block below for an OS-level flip mid-session. */
:root {
  view-transition-name: none;
}

/* The pseudo-tree sits in the top layer over the whole viewport for the
   duration; without this it swallows every pointer event for 600ms —
   including the hero's cursor tracking. */
::view-transition {
  pointer-events: none;
}

/* The site's clock and curve for every captured group. */
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
  animation-duration: 600ms;
  animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}

/* Names must be unique among simultaneously rendered elements or the
   whole transition silently aborts — never reuse these. The panel's name
   is inert while closed: display:none elements are not captured. */
[data-article-tile="one"] {
  view-transition-name: article-tile-one;
}
[data-article-tile="two"] {
  view-transition-name: article-tile-two;
}
[data-article-tile="three"] {
  view-transition-name: article-tile-three;
}
.article-panel {
  view-transition-name: article-panel;
}

/* Panel entrance and exit. :only-child tells open (new snapshot only) and
   close (old only) apart from a swap between two studies, which has both
   and takes the default cross-fade. Partial keyframes on purpose: the
   missing end state is the snapshot's natural place, so close retraces
   open. Desktop steps in from the right on .reveal's short travel; below
   769px the panel slides its full width, like the sheet it is there. */
@keyframes article-panel-in {
  from {
    opacity: 0;
    transform: translateX(32px);
  }
}
@keyframes article-panel-out {
  to {
    opacity: 0;
    transform: translateX(32px);
  }
}
@keyframes article-sheet-in {
  from {
    transform: translateX(100%);
  }
}
@keyframes article-sheet-out {
  to {
    transform: translateX(100%);
  }
}

::view-transition-new(article-panel):only-child {
  animation-name: article-panel-in;
}
::view-transition-old(article-panel):only-child {
  animation-name: article-panel-out;
}

@media (max-width: 768px) {
  ::view-transition-new(article-panel):only-child {
    animation-name: article-sheet-in;
  }
  ::view-transition-old(article-panel):only-child {
    animation-name: article-sheet-out;
  }
}

/* ------------------------------------------------------------------ */
/*  4d. Article prose — the case-study reading measure.                */
/* ------------------------------------------------------------------ */

/* Long-form styles for everything a case study contains, scoped under one
   class so the base layer stays lean. Sizes only ever come from the §2b
   rungs; ink is --text at full or 60% strength; every line is --hairline.
   The muted 60% is color-mix rather than a fourth text token: it tracks
   --text through both themes by construction. */
/* font-family here and nowhere else in §4d: the headings are deliberately
   not restated. @layer base declares var(--font-heading) *directly* on
   h1–h6 and var(--font-mono) directly on code/pre, and a declaration on the
   element always beats an inherited one — so this single line moves the
   prose to Inter and leaves the headings on Outfit and the code on mono by
   construction. Adding .article-prose h2 { … } would not be a tidy-up; it
   would undo the split. Same reason .article-meta dt keeps its mono below. */
.article-prose {
  max-width: 72ch;
  margin-inline: auto;
  font-family: var(--font-reading);
}

/* Vertical rhythm: one owl for the common case, per-element overrides
   below for the few that breathe differently. Preflight has zeroed every
   margin, so this is the single source of spacing. */
.article-prose > * {
  margin-block: 0 1em;
}
.article-prose > :last-child {
  margin-block-end: 0;
}

/* The study's title takes the display rung — the h1's rung, but as an
   <h2>: the deploy gate wants exactly one h1 on /, and the tagline holds
   it. */
.article-prose h2 {
  font-size: var(--text-display);
  line-height: var(--text-display--line-height);
  margin-block: 0 0.5em;
}
.article-prose h3 {
  margin-block: 2.5em 0.75em;
}
.article-prose h4 {
  margin-block: 2em 0.5em;
}

.article-prose .article-lead {
  font-size: var(--text-lead);
  line-height: var(--text-lead--line-height);
}

.article-prose ul,
.article-prose ol {
  padding-inline-start: 1.25em;
}
.article-prose ul {
  list-style: disc;
}
.article-prose ol {
  list-style: decimal;
}
.article-prose li {
  margin-block: 0.35em;
}

.article-prose hr {
  border: 0;
  border-top: 1px solid var(--hairline);
  margin-block: 2.5rem;
}

.article-prose figure {
  margin-block: 2rem;
}
.article-prose figure img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius-md);
}
.article-prose figcaption {
  margin-top: 0.5rem;
  font-size: var(--text-caption);
  line-height: var(--text-caption--line-height);
  color: color-mix(in srgb, var(--text) 60%, transparent);
}

/* Fact strip under the title: Role / Timeline / Team / Tools. */
.article-prose .article-meta {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  gap: 16px 24px;
  margin-block: 2rem;
}
.article-prose .article-meta dt {
  font-family: var(--font-mono);
  font-size: var(--text-caption);
  line-height: var(--text-caption--line-height);
  letter-spacing: var(--tracking-eyebrow);
  text-transform: uppercase;
  color: color-mix(in srgb, var(--text) 60%, transparent);
  margin-block-end: 0.35em;
}
.article-prose .article-meta dd {
  font-size: var(--text-body);
  line-height: var(--text-body--line-height);
}

/* Pull quote. The figcaption sits outside the <blockquote> on purpose —
   the quote element may contain only quoted words. */
.article-prose .article-quote {
  border-left: 2px solid var(--hairline);
  padding-left: 1.25rem;
}
.article-prose .article-quote blockquote p {
  font-size: var(--text-lead);
  line-height: var(--text-lead--line-height);
}

.article-prose table {
  width: 100%;
  border-collapse: collapse;
  margin-block: 2rem;
}
.article-prose th {
  text-align: left;
  font-size: var(--text-label);
  line-height: var(--text-label--line-height);
  font-weight: 600;
  border-bottom: 1px solid var(--hairline);
  padding: 0.5rem 1rem 0.5rem 0;
}
.article-prose td {
  font-size: var(--text-body);
  line-height: var(--text-body--line-height);
  border-bottom: 1px solid var(--hairline);
  padding: 0.5rem 1rem 0.5rem 0;
}

/* Code. --bg-base is the well: one step off --panel in both themes, the
   same delta trick --tile-media plays against the gradient. */
.article-prose pre {
  background: var(--bg-base);
  border-radius: var(--radius-md);
  padding: 16px 20px;
  overflow-x: auto;
  font-size: var(--text-label);
  line-height: 1.6;
  margin-block: 2rem;
}
.article-prose :not(pre) > code {
  background: var(--bg-base);
  border-radius: var(--radius-xs);
  padding: 0.1em 0.4em;
  font-size: 0.9em;
}

/* Two-up media on the rail's own 8px gutter, so paired shots read as one
   plate the way the tiles read as one cluster. */
.article-prose .article-grid-2 {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--tile-rail-gutter);
  margin-block: 2rem;
}
@media (min-width: 391px) {
  .article-prose .article-grid-2 {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
.article-prose .article-grid-2 figure {
  margin-block: 0;
}

/* Outcome numbers. */
.article-prose .article-stats {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: 24px;
  margin-block: 2rem;
}
.article-prose .article-stats strong {
  display: block;
  font-size: var(--text-display);
  line-height: var(--text-display--line-height);
  font-weight: 700;
}
.article-prose .article-stats span {
  font-size: var(--text-caption);
  line-height: var(--text-caption--line-height);
  color: color-mix(in srgb, var(--text) 60%, transparent);
}

/* ------------------------------------------------------------------ */
/*  5. Base layer.                                                     */
/* ------------------------------------------------------------------ */
@layer base {
  html {
    background-color: var(--color-base);
    -webkit-text-size-adjust: 100%;
    scroll-behavior: smooth;
  }

  body {
    isolation: isolate;
    min-height: 100dvh;
    color: var(--color-fg);
    font-family: var(--font-body);
    background: none;
    transition:
      background-color 0.8s ease,
      color 0.5s ease;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-rendering: optimizeLegibility;
    overflow-x: hidden;
  }

  h1,
  h2,
  h3,
  h4,
  h5,
  h6 {
    font-family: var(--font-heading);
    font-weight: 700;
  }

  /* Type scale. Each element takes a rung of the fluid scale, so there are
     no breakpoints here any more — the clamps carry the responsiveness.

     Both properties are stated per element on purpose. Tailwind's paired
     `--text-x--line-height` modifier is only applied by the `text-x`
     *utility*; a bare var(--text-x) in plain CSS pulls the size alone, so
     the line-height has to be read explicitly alongside it.

     h4-h6 previously had no size at all and fell through to the UA
     defaults (1em / .83em / .67em), which ignored the system entirely. */
  h1 {
    font-size: var(--text-display);
    line-height: var(--text-display--line-height);
  }
  h2 {
    font-size: var(--text-title);
    line-height: var(--text-title--line-height);
  }
  h3 {
    font-size: var(--text-heading);
    line-height: var(--text-heading--line-height);
  }
  h4 {
    font-size: var(--text-lead);
    line-height: var(--text-lead--line-height);
  }
  h5 {
    font-size: var(--text-body);
    line-height: var(--text-body--line-height);
  }
  h6 {
    font-size: var(--text-label);
    line-height: var(--text-label--line-height);
  }

  p,
  li {
    font-size: var(--text-body);
    line-height: var(--text-body--line-height);
  }

  code,
  pre {
    font-family: var(--font-mono);
  }

  ::selection {
    background-color: var(--color-fg);
    color: var(--color-base);
  }

  a {
    color: inherit;
  }

  /* The interactive affordance, stated once here rather than as a Tailwind
     utility per component: buttons and button-ish inputs default to the arrow,
     and the hand is what everything on this site should show. */
  a[href],
  button,
  [role="button"],
  summary,
  label,
  select,
  [type="button"],
  [type="submit"],
  [type="reset"] {
    cursor: pointer;
  }
}

@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
  .bg-layer {
    transition: none;
  }
  /* Stated here, not via `motion-reduce:animate-none` — that class ties with
     .reveal on specificity and would win or lose on source order. Nothing
     declares opacity 0, so dropping the animation simply shows the element,
     with no flash. */
  .reveal {
    animation: none;
  }
  /* Cloud mode keeps working; it just stops crossfading. Both selectors from
     §4b are restated verbatim — a media query adds no specificity, so a
     shorter one would lose to the [data-cloud] rule. With no transition,
     `visibility: hidden` applies at once and there is no window in which a
     hidden element is still focusable. */
  .cloud-hide,
  .cloud-hide.cloud-rise,
  :root[data-cloud="on"] .cloud-hide {
    transition: none;
  }
  /* The rise is motion for its own sake, so it goes entirely — the chrome
     just stops being there. */
  :root[data-cloud="on"] .cloud-rise {
    transform: none;
  }
  /* Article mode keeps working without its tweens, same policy as cloud
     mode above; both selectors restated verbatim for the same specificity
     reason. */
  .article-hide,
  :root[data-article] .article-hide {
    transition: none;
  }
  /* Same policy for the header's hand-back to the corner (§4c): the inset still
     lands, it just lands at once instead of sliding 232px. */
  .page-measure {
    transition: none;
  }
  /* Belt and braces: ArticleProvider already skips startViewTransition
     under this media feature, but an OS-level flip mid-session would not
     re-run that check — with no animations the transition resolves in a
     frame. */
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none;
  }
}

/* Accessible skip link. */
.skip-link {
  position: absolute;
  left: 0.75rem;
  top: 0.75rem;
  z-index: 100;
  transform: translateY(-150%);
  border-radius: var(--radius-sm);
  background: var(--color-btn);
  color: var(--color-btn-fg);
  padding: 0.5rem 0.9rem;
  font-size: var(--text-label);
  line-height: var(--text-label--line-height);
  font-weight: 600;
}
.skip-link:focus {
  transform: translateY(0);
}
