/* ============================================================
   Jared Bangal — portfolio
   Home page. Implements Home.dc.html, styled after the
   squarespace.com system (see reference/squarespace-tokens.json
   for the scraped values these are derived from).

   Token architecture is three-layer:
     1. Primitive  — raw values, no meaning  (--ink-60, --space-6)
     2. Semantic   — role names              (--text-secondary, --border-subtle)
     3. Component  — local overrides         (--nav-h, --card-bg)
   Components reference semantic tokens only, never primitives.
   ============================================================ */

/* ── 1. Primitives ──────────────────────────────────────── */
:root {
  /* ── The night ramp ───────────────────────────────────────
     The page's ground since the palette flip. Navy rather than neutral
     grey: every surface carries a blue cast so the violet accent and the
     particle field read as belonging to the page rather than sitting on
     top of a black rectangle.

     Elevation is the same direction the cream ramp used — raised is
     lighter than ground — which is why no component rule had to change
     when the polarity did. */
  --night-950: #050611;   /* under the dark tooth; ink blocks */
  --night-900: #080A18;   /* bands — one step down from the page */
  --night-850: #0D1022;   /* ground */
  --night-800: #141833;   /* raised — cards */
  --night-700: #1C2145;   /* raised, hovered — the binding surface */

  /* Ink. Cool white, not the warm #E8E4DC the cream palette used: a warm
     ink on a blue ground reads as a stain. Every alpha carries its
     measured contrast against --night-700, the lightest surface in the
     ramp and therefore the worst case. Measure against that, never the
     page ground — the flattering number is the one that fails on a
     hovered card. */
  --ink:     #ECEDF8;
  --ink-85:  rgba(236, 237, 248, .85);  /* 10.0:1 */
  --ink-75:  rgba(236, 237, 248, .75);  /*  8.1:1 */
  --ink-70:  rgba(236, 237, 248, .70);  /*  7.2:1 */
  --ink-65:  rgba(236, 237, 248, .65);  /*  6.4:1 — floor for body copy */
  --ink-60:  rgba(236, 237, 248, .60);  /*  5.7:1 — under the floor; avoid */
  --ink-40:  rgba(236, 237, 248, .40);  /*  3.3:1 — large/bold text only */
  --ink-20:  rgba(236, 237, 248, .20);  /*  non-text: rules, borders */
  --ink-12:  rgba(236, 237, 248, .12);
  --ink-10:  rgba(236, 237, 248, .10);
  --ink-08:  rgba(236, 237, 248, .08);

  /* Type. Clarkson is Squarespace's licensed proprietary face and is not
     redistributable — Archivo is the closest freely-licensed grotesque
     with a true 300 weight, which is what carries their display look. */
  --font: "Archivo", "Helvetica Neue", Helvetica, Arial, sans-serif;

  /* Scale. Display sizes are fluid clamps in the same shape as their
     .text--title* ramp; body sits at 15/21 like theirs. */
  --text-2xs: .75rem;    /* 12 — labels, legal */
  --text-xs:  .8125rem;  /* 13 — meta */
  --text-sm:  .875rem;   /* 14 — controls */
  --text-md:  .9375rem;  /* 15 — body */
  --text-lg:  1rem;      /* 16 */
  --text-xl:  1.125rem;  /* 18 */
  --text-2xl: clamp(1.25rem, 1.1rem + .75vw, 1.5rem);      /* 20 → 24 pull quotes */
  --text-3xl: clamp(1.75rem, 1.5rem + 1.2vw, 2.25rem);     /* 28 → 36 */
  --text-4xl: clamp(2.125rem, 1.6rem + 2.4vw, 3.25rem);    /* 34 → 52 section heads */
  --text-5xl: clamp(2.25rem, 1.5rem + 3.4vw, 4.25rem);     /* 36 → 68 hero */

  /* Display type is light and very tightly tracked — the single most
     recognisable thing about their system. */
  --weight-display: 300;
  --leading-display: 1.04;
  --track-display: -.055em;

  --leading-snug:  1.15;
  --leading-body:  1.45;
  --leading-loose: 1.6;

  --track-label: .08em;   /* uppercase labels open up instead */

  /* Spacing — 4pt rhythm */
  --space-1:  4px;   --space-2:  8px;   --space-3:  12px;
  --space-4:  16px;  --space-5:  20px;  --space-6:  24px;
  --space-7:  28px;  --space-8:  32px;  --space-10: 40px;
  --space-12: 48px;  --space-14: 56px;  --space-16: 64px;
  --space-18: 72px;  --space-20: 80px;  --space-24: 96px;
  --space-28: 112px; --space-32: 128px; --space-40: 160px;

  /* Radii — theirs are small and flat: 4px controls, 8px media, 30px pills */
  --radius-xs:   4px;
  --radius-sm:   8px;
  --radius-pill: 30px;

  /* Motion. These four curves are lifted from squarespace.com's
     computed styles; they use easeOutQuart for everything entering. */
  --ease-out:     cubic-bezier(.165, .84, .44, 1);      /* easeOutQuart */
  --ease-in-out:  cubic-bezier(.645, .045, .355, 1);    /* easeInOutCubic */
  --ease-in-out-q:cubic-bezier(.455, .03, .515, .955);  /* easeInOutQuad */
  --ease-in:      cubic-bezier(.895, .03, .685, .22);   /* easeInQuart */

  --dur-fast: 200ms;   /* their button transition */
  --dur-base: 300ms;   /* their background/nav transition */
  --dur-slow: 800ms;   /* scroll entrances */
  --stagger:   80ms;

  /* Nav panels. --dur-panel drives the fade, the column resolve and the
     visibility hand-off, so they cannot drift apart; --dur-panel-settle is
     the long easeOutQuart transform underneath. */
  --dur-panel:        420ms;
  --dur-panel-settle: 1100ms;
  --panel-stagger:     90ms;

  /* Elevation. Four steps, and every one is two shadows: a tight contact
     shadow that sells the edge, and a wide ambient one that sells the
     height. A single shadow can do one or the other, never both — which is
     why the hand-written values this replaced looked flat at rest and
     muddy when raised. Light is from directly above, so the offsets are
     vertical only; nothing on this page is lit from the side. */
  --shadow-1: 0 1px 2px rgba(22, 23, 26, .05),
              0 1px 3px rgba(22, 23, 26, .04);
  --shadow-2: 0 1px 2px rgba(22, 23, 26, .06),
              0 6px 16px rgba(22, 23, 26, .08);
  --shadow-3: 0 2px 4px rgba(22, 23, 26, .07),
              0 16px 40px rgba(22, 23, 26, .13);
  --shadow-4: 0 4px 10px rgba(22, 23, 26, .09),
              0 32px 70px rgba(22, 23, 26, .17);

  /* Layering */
  --z-base: 0;  --z-raised: 10;  --z-nav: 40;  --z-grain: 60;  --z-skip: 100;
}

/* ── 2. Semantic ──────────────────────────────────────────
   The page is night. Everything below names a role, never a value, and
   components reference only this layer — which is what made the polarity
   itself swappable. This site inherited a cream design and flipped it by
   rewriting these ~50 lines; not one component rule had to change, which
   is the entire return on the three-layer split.

   Elevation runs the same direction it did on cream: raised is *lighter*
   than ground, band is darker. On a dark ground that reads as light
   falling on a surface rather than a hole cut in one. */
:root {
  color: var(--text-primary);
  --bg-page:     var(--night-850);
  --bg-band:     var(--night-900);
  --bg-raised:   var(--night-800);
  --bg-raised-h: var(--night-700);
  --bg-chip:     var(--ink-08);
  --bg-chip-h:   var(--ink-12);

  /* Every alpha measured against --night-700, the *lightest* surface in
     the ramp and therefore the binding one. Measuring against the page
     ground instead would flatter every number by about a point and fail
     first on a hovered card, which is the one surface a user is looking
     directly at when it happens. */
  --text-primary:   var(--ink);       /* 13.3:1 on night-700 */
  --text-body:      var(--ink-75);    /*  8.1:1 */
  --text-secondary: var(--ink-70);    /*  7.2:1 */
  --text-muted:     var(--ink-65);    /*  6.4:1 — the floor */
  --text-strong:    var(--ink-85);    /* 10.0:1 */

  --border-subtle: var(--ink-10);
  --border-field:  var(--ink-20);
  --border-strong: var(--ink-20);

  /* Glass. A film of light laid on night, so these are an order of
     magnitude weaker than the cream original's numbers: 62% of white on a
     dark ground reads as a grey box, not as frosted glass. */
  --glass-fill:    color-mix(in srgb, #FFFFFF 6%, transparent);
  --glass-fill-h:  color-mix(in srgb, #FFFFFF 11%, transparent);
  --glass-edge:    color-mix(in srgb, #FFFFFF 14%, transparent);
  --glass-edge-h:  color-mix(in srgb, var(--accent-ink) 55%, transparent);
  --glass-inset:   rgba(255, 255, 255, .08);
  --glass-inset-h: rgba(255, 255, 255, .14);
  --glass-glare:   rgba(255, 255, 255, .16);
  --glass-blur:    blur(16px) saturate(1.1);

  /* ── The accent, and why there are two of them ─────────────
     A bright accent on a dark ground cannot carry white text. Periwinkle
     measures 2.7:1 against white — the "obvious" primary button would be
     illegible — but 7.4:1 against near-black. So the solid pair carries
     DARK text, which is the inverse of what the cream palette did and the
     single easiest thing to get wrong here.

     --accent-grad exists because a gradient cannot be a --accent value:
     it is an <image>, not a <color>, so it works in `background` and
     nowhere else — not in `color`, not in `border-color`, not in
     color-mix(). It therefore gets its own on-colour. Its stops are the
     deeper blue/violet/plum rather than the vivid ones precisely so white
     survives all three: the vivid set bottoms out at 4.60:1, which passes
     AA and fails this site's own 5.8 body floor. */
  --accent:          #8B93FF;   /* 6.9:1 as ink on the page ground */
  --accent-hover:    #A9AFFF;   /* 8.6:1 */
  --accent-ink:      #8B93FF;
  --accent-ink-hover:#B6BBFF;
  --text-on-accent:  var(--night-950);   /* 7.4:1 on --accent */

  --accent-grad: linear-gradient(118deg, #1D4ED8 0%, #6D28D9 52%, #BE185D 100%);
  /* The section version: same three hues run down to block luminance, so
     an .on-dark band reads as the same family as a primary button rather
     than as a different palette. Light ink clears its lightest stop at
     14.9:1. */
  --block-grad: linear-gradient(160deg, #0A0F2C 0%, #1B1040 55%, #2A0E33 100%);
  --accent-grad-h: linear-gradient(118deg, #2563EB 0%, #7C3AED 52%, #DB2777 100%);
  --text-on-grad: #FFFFFF;      /* worst stop 6.0:1, hover set 4.6:1 */

  --focus-ring:   var(--ink);   /* stays the ink — max contrast beats on-theme */

  /* Lightened for a dark ground rather than inverted; both clear the body
     floor on every surface in the ramp (danger 6.6–8.0, success 7.4–8.9). */
  --danger:  #FF9AA2;
  --success: #7FE3A6;

  --bg-ink: var(--night-950);

  /* The nav bar's own surfaces.

     These were three raw rgba() cream literals sitting in component rules —
     the one place this codebase broke its own "components reference
     semantic tokens only" rule — and they are exactly why the bar stayed a
     pale slab across the top of the page for a while after everything else
     went dark. A raw colour in a component survives a palette flip
     completely untouched and reports nothing: the tokens all changed, the
     bar did not, and there is no error to find. That is the failure the
     rule exists to prevent, and this is what it looks like when it happens.

     The scrim also had to *invert*, not merely darken. On cream it was a
     light wash protecting dark type over a dark photograph. Here the type
     is light and the ground beneath it is already dark, so a light scrim
     was actively destroying the contrast it exists to create. */
  --nav-scrim-from: rgba(5, 6, 17, .82);
  --nav-scrim-mid:  rgba(5, 6, 17, .58);
  --nav-scrim-to:   rgba(5, 6, 17, 0);
  --nav-fill:       rgba(13, 16, 34, .72);
  --nav-fill-solid: rgba(13, 16, 34, .97);

  /* Layout — unchanged from the cream original. */
  --shell:  80rem;
  --gutter: var(--space-10);
  --nav-h:  80px;
  --section-y:    var(--space-32);
  --section-y-lg: var(--space-40);
}

/* ── 2b. The deep scope ─────────────────────────────────────
   This used to be "the dark scope" — the inverted palette for the two
   places a cream page needed ink. The page is night now, so inverting is
   no longer what these sections are for: they are the *deeper* blocks, the
   ones that recede, and the rhythm they give the page is the reason they
   survived the flip rather than being deleted.

   The class name stays .on-dark, unchanged, and that is deliberate. It is
   on three sections in index.html plus every generated page's footer, and
   renaming a scope class across generated output to gain nothing but a
   better word is how a rename ends up half-applied.

   Most tokens are inherited rather than redeclared: both scopes are dark
   now, so only the surfaces and the accent genuinely differ. The cream
   version had to restate all forty.

   `color` still has to be re-resolved here, not just the token. It
   inherits as the *computed* value, so anything that merely inherits would
   otherwise keep the value body resolved against the page ramp. Elements
   that set their own colour were never affected, which is exactly what
   makes this bug look random when you hit it. */
.on-dark {
  color: var(--text-primary);

  --bg-page:     var(--night-950);
  --bg-band:     var(--night-950);
  --bg-raised:   var(--night-900);
  --bg-raised-h: var(--night-850);

  /* The surfaces went darker, so the binding surface is now --night-850
     rather than --night-700 and every alpha gains roughly a point of
     contrast. The ramp is left alone rather than re-tightened: spending
     that headroom would make these blocks read louder than the page, and
     they are the quiet ones. */

  /* A brighter accent than the page's. These blocks sit at --night-950,
     where the page's periwinkle would still pass but stops feeling lit;
     this is the same hue two steps up. It carries the same near-black
     text, which is the pairing that has to move together — this block has
     been wrong twice that way on the cream original. Check it whenever the
     brand colour moves. */
  --accent:          #A9AFFF;   /* 10.8:1 on night-950 */
  --accent-hover:    #C6CAFF;
  --accent-ink:      #A9AFFF;
  --accent-ink-hover:#C6CAFF;
  --text-on-accent:  var(--night-950);

  --bg-ink: var(--night-950);
}

/* ── Reset ──────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; padding: 0; }

html {
  scroll-behavior: smooth;
  -webkit-text-size-adjust: 100%;
}

body {
  /* background-color, never the `background` shorthand — the shorthand
     resets background-image, and the paper texture lives there. This rule
     currently sits above the texture block, so it would only bite if the
     order changed, which is exactly the kind of thing that changes. */
  background-color: var(--bg-page);
  color: var(--text-primary);
  font-family: var(--font);
  font-size: var(--text-md);
  line-height: var(--leading-loose);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

img, svg { display: block; max-width: 100%; }
ul, ol { list-style: none; }
button, input { font: inherit; color: inherit; }
a { color: var(--text-primary); }

a, button, input, label, [role="button"] { touch-action: manipulation; }

:target { scroll-margin-top: calc(var(--nav-h) + var(--space-6)); }

/* The halo is the point. An outline alone can land on a surface close to
   its own colour — ink on a dark concept card, the ring on its own button —
   and vanish. The second ring is the page's own colour sitting between
   them, so the pair stays legible on anything. */
:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 3px;
  box-shadow: 0 0 0 5px var(--bg-page);
  border-radius: var(--radius-xs);
}

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

/* ── Utilities ──────────────────────────────────────────── */
.shell {
  width: 100%;
  max-width: var(--shell);
  margin-inline: auto;
  padding-inline: var(--gutter);
}
.shell--flush { padding-inline: 0; }

.u-measure-14 { max-width: 14ch; }
.u-measure-16 { max-width: 16ch; }
.u-measure-20 { max-width: 20ch; }

.u-visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
.skip-link {
  position: fixed;
  top: var(--space-3); left: var(--space-3);
  z-index: var(--z-skip);
  padding: var(--space-3) var(--space-5);
  border-radius: var(--radius-xs);
  background: var(--accent);
  color: var(--text-on-accent);
  font-size: var(--text-sm);
  font-weight: 500;
  text-decoration: none;
  transform: translateY(-200%);
  transition: transform var(--dur-base) var(--ease-out);
}
.skip-link:focus-visible { transform: none; }

#nav-sentinel { position: absolute; top: 0; height: 1px; width: 100%; }

/* ── Texture ───────────────────────────────────────────────
   Paper tooth on every surface. Four octaves of fractal noise in one tile,
   which carries both the fine speckle and the slower mottling — two
   frequencies are what separate paper from digital noise, and fractalNoise
   gives both from one pass.

   **It sits behind the content, not over it**, as a background layer on
   the surface itself. That is the whole reason it can be this strong. The
   film it replaced was a fixed overlay above everything including live
   text, so its strength was capped by contrast and it never got past a
   stdev of 2.2 on a flat surface — invisible. Behind the content the
   glyphs stay solid ink and only the ground varies, exactly like print.
   This runs at 8.2, nearly four times as much, for *better* contrast
   rather than worse.

   **The blend mode is `luminosity`, and the tile is calibrated, not
   guessed.** multiply can only darken, so texture and page colour trade
   against each other — at a visible strength it dragged the ground down
   nearly 30 levels. soft-light and overlay compress at the light end and
   never got past 3.8. luminosity takes the noise's luminance and keeps the
   cream's hue, so the only requirement is that the noise be *centred* on
   the surface's own luminance.

   That centre cannot be derived from the surface's channel value — the
   blend's Lum() weighting and clipping at the top of the range both move
   it. It was calibrated by measuring: slope .70 about centre .775 lands
   the ground at +1.5 levels of untextured, which is nothing. If you change
   --cream-200, re-measure rather than recompute. */
:root { --tex: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='t' x='0' y='0' width='100%25' height='100%25'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='1' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/><feComponentTransfer><feFuncR type='linear' slope='0.380' intercept='0.572'/><feFuncG type='linear' slope='0.380' intercept='0.572'/><feFuncB type='linear' slope='0.380' intercept='0.572'/><feFuncA type='linear' slope='0' intercept='1'/></feComponentTransfer></filter><rect width='100%25' height='100%25' filter='url(%23t)'/></svg>"); }

/* ── The dark tooth ─────────────────────────────────────────
   The cream tile cannot be reused on a black surface, and the reason is
   worth writing down because it looks like a tuning problem and is not.

   `luminosity` *replaces* the backdrop's luminance with the source's. On
   cream that is exactly what you want — the noise carries the variation
   and the cream keeps its hue. On near-black there is nothing below to
   vary into: measured across eight centres and three slopes, every single
   combination lifted a mean of 18 to somewhere between 63 and 180. Even a
   pure-black noise tile lands at ~50. The surface always washes to grey.
   It is the same shape of failure `multiply` had on cream, mirrored.

   `lighten` is the one that works, for the same reason `multiply` works on
   paper: it can only move away from the surface in the one direction the
   surface has room for. Specks of light in ink.

   `color-interpolation-filters='sRGB'` is not optional here. SVG filters
   run in **linearRGB** by default, which is why the cream tile's numbers
   look arbitrary — at the dark end that conversion balloons small values,
   and slope .05 was still lifting the mean by 38. In sRGB the slope means
   what it says.

   Measured: stdev **3.2** at a mean of 19.9 against the flat 18.3, so the
   apparent surface is still --shade-900. That is well under the cream's
   8.2, and it is the honest ceiling — black has no downside headroom.
   Re-measure rather than recompute if --shade-950 moves. */
:root { --tex-dark: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='180' height='180'><filter id='d' x='0' y='0' width='100%25' height='100%25' color-interpolation-filters='sRGB'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='1' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/><feComponentTransfer><feFuncR type='linear' slope='0.160' intercept='0'/><feFuncG type='linear' slope='0.160' intercept='0'/><feFuncB type='linear' slope='0.160' intercept='0'/><feFuncA type='linear' slope='0' intercept='1'/></feComponentTransfer></filter><rect width='100%25' height='100%25' filter='url(%23d)'/></svg>"); }

/* An ink block. Carries .on-dark alongside it — that flips the tokens,
   this paints the surface — and the pair is written as one compound
   selector so it also out-specifies `.band`'s own cream background and
   luminosity tile, which would otherwise win by source order.

   The field does not show through: these are opaque, exactly like .band.
   That is deliberate rather than a limitation. Alternating textured cream
   with solid ink gives the page a rhythm, and the black sections read as
   the quiet ones. */
.on-dark.block--dark {
  position: relative;
  background-color: var(--bg-ink);
  /* Two layers, and the order is the whole trick: the tooth is listed
     first so it sits on top and `lighten` has the gradient beneath it to
     lighten *against*. Reversed, the tooth blends against the flat
     background-color and the gradient paints over the result, burying it.
     background-blend-mode takes one entry per layer, in the same order. */
  background-image: var(--tex-dark), var(--block-grad);
  background-blend-mode: lighten, normal;
  background-repeat: repeat, no-repeat;
  background-size: auto, cover;
}

/* Cinematic edges: the ink does not start at a hard line, it rises out of
   the cream and sinks back into it.

   The fade is the *same surface* under a mask, not a colour gradient to
   `--bg-ink`. A gradient would have to end on a flat ink value while the
   block itself is textured — the block's apparent mean is 19.9 against the
   flat 18.3, and that 1.6-level step shows as a seam right where the eye
   is already looking. Masking the real surface cannot band, because there
   is only one surface.

   `--ink-fade` is opt-in per block so a section can still take a hard edge
   where that is the intent. */
.on-dark.block--dark::before,
.on-dark.block--dark::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  pointer-events: none;
  background-color: var(--bg-ink);
  background-image: var(--tex-dark);
  background-blend-mode: lighten;
  background-repeat: repeat;
}
.on-dark.block--dark::before {
  height: var(--ink-fade-top, var(--ink-fade, 0px));
  top: calc(var(--ink-fade-top, var(--ink-fade, 0px)) * -1);
  /* Eased, not linear, and this is the whole trick.
     A linear ramp does two bad things: it starts with a visible edge (a
     Mach band where the gradient meets flat cream), and it darkens evenly,
     so a fade long enough to look cinematic puts the tail of the About
     copy on a mid-grey ground — measured at ~3.6:1.
     These stops stay under 15% for the first three-fifths and then
     accelerate, so the fade *reads* as starting high up near the middle of
     the portrait while the real darkening happens below the text. */
  -webkit-mask-image: linear-gradient(to bottom,
      transparent 0%, rgba(0,0,0,.02) 35%, rgba(0,0,0,.07) 50%,
      rgba(0,0,0,.15) 62%, rgba(0,0,0,.28) 72%, rgba(0,0,0,.42) 80%,
      rgba(0,0,0,.62) 88%, rgba(0,0,0,.82) 94%, #000 100%);
          mask-image: linear-gradient(to bottom,
      transparent 0%, rgba(0,0,0,.02) 35%, rgba(0,0,0,.07) 50%,
      rgba(0,0,0,.15) 62%, rgba(0,0,0,.28) 72%, rgba(0,0,0,.42) 80%,
      rgba(0,0,0,.62) 88%, rgba(0,0,0,.82) 94%, #000 100%);
}
.on-dark.block--dark::after {
  height: var(--ink-fade-bottom, var(--ink-fade, 0px));
  bottom: calc(var(--ink-fade-bottom, var(--ink-fade, 0px)) * -1);
  -webkit-mask-image: linear-gradient(to top, transparent, #000);
          mask-image: linear-gradient(to top, transparent, #000);
}

/* Client feedback carries the fade: it follows the cream About section and
   is followed by cream again, so both edges are transitions. */
/* The height is derived, not chosen. #feedback is a later sibling, so its
   ::before paints *over* #about — including over content #about raised
   with --z-raised — so the fade has to stay inside the About section's
   bottom padding, which is --section-y-lg. That token is itself
   responsive (160 → 112 → 96px), and a fixed clamp overlapped the last
   line of the About copy by ~40px at tablet and phone widths. Tying the
   two together keeps 20% clearance at every breakpoint without a single
   extra media query. */
/* PARKED with the testimonial marquee (tools/parked/testimonials.html).
   The page had exactly one vignette — About dissolving into Client
   feedback — because that junction carried the most weight: a portrait,
   then the wall of reviews. With the marquee out for having invented
   quotes there is no ink block there to fade into, and About now meets
   Services, cream to cream, needing no transition at all.

   Kept rather than deleted because the section is coming back verbatim
   once there are real quotes. It selects nothing in the meantime.
   Tall enough to read as beginning around the middle of the portrait. */
#feedback {
  --ink-fade-top: min(28rem, 50vh);
  --ink-fade-bottom: 0px;
}

/* Newsletter and footer are one continuous black foot, and both edges are
   straight. */
#newsletter { --ink-fade: 0px; }
.footer.block--dark { border-top-color: var(--border-subtle); }

/* The page carries the dark tooth now, not the paper one, and the blend
   mode has to change with it — this is the single most load-bearing
   consequence of the palette flip.

   `luminosity` *replaces* the backdrop's luminance with the source's. On
   cream that is what you want. On a dark ground there is nothing below to
   vary into: every centre and slope tested lifted a mean of 18 to
   somewhere between 63 and 180, so the surface always washes to grey. It
   is not a tuning problem and no amount of turning the numbers down fixes
   it — see the long note above --tex-dark.

   `lighten` is the one that works, for the same reason `multiply` works on
   paper: it can only move away from the surface in the direction the
   surface has room for. Specks of light in ink. */
body,
.band,
.u-textured {
  background-image: var(--tex-dark);
  background-blend-mode: lighten;
  background-repeat: repeat;
}

body  { background-color: var(--bg-page); }
.band { background-color: var(--bg-band); }

/* ── Type primitives ────────────────────────────────────── */

/* Section labels: small, uppercase, opened-up tracking. */
.label {
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: var(--space-5);
}

/* .h1 and .h2 are the same setting. The distinction is semantic — one per
   document versus one per section — and giving them different sizes would
   make the secondary pages, where the h1 *is* the only head, look like they
   were shouting at the home page. Defining .h1 at all matters: a bare <h1>
   takes the browser's bold 2em default, which is exactly the thing this
   site's whole type direction is against. */
.h1,
.h2 {
  font-size: var(--text-4xl);
  font-weight: var(--weight-display);
  line-height: var(--leading-display);
  letter-spacing: var(--track-display);
  text-wrap: balance;
  margin-bottom: var(--space-5);
}

.section__head {
  text-align: center;
  max-width: 34ch;
  margin: 0 auto var(--space-20);
}
.section__head .h2:last-child { margin-bottom: 0; }
.section__sub {
  font-size: var(--text-md);
  line-height: var(--leading-body);
  color: var(--text-muted);
}
.section__foot { margin-top: var(--space-16); text-align: center; }

.body {
  font-size: var(--text-md);
  line-height: var(--leading-loose);
  color: var(--text-body);
  max-width: 46ch;
}
.body + .body { margin-top: var(--space-4); }

.link-underline {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 44px;
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  position: relative;
  text-decoration: none;
  border-bottom: 1px solid var(--border-strong);
  transition: border-color var(--dur-fast) var(--ease-in-out),
              color var(--dur-fast) var(--ease-in-out);
}
/* The accent rule draws in over the resting border rather than recolouring
   it. transform-origin flips on the way out so it retracts to the edge it
   entered from — same rule as the service card's wipe. */
.link-underline::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: -1px;
  height: 1px;
  background: var(--accent-ink);
  transform: scaleX(0);
  transform-origin: right center;
  transition: transform var(--dur-base) var(--ease-out);
}
.link-underline:hover::after {
  transform: scaleX(1);
  transform-origin: left center;
}
.link-underline span { transition: transform var(--dur-fast) var(--ease-out); }
.link-underline:hover { color: var(--accent-ink-hover); border-color: var(--accent-ink-hover); }
.link-underline:hover span { transform: translateX(4px); }

/* ── Buttons ────────────────────────────────────────────────
   Theirs: 4px radius, uppercase 14/500, ~23px 28px padding,
   colour+background transition on easeInOutCubic at 200ms.    */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: 48px;
  padding: var(--space-4) var(--space-7);
  border: 1px solid transparent;
  border-radius: var(--radius-xs);
  font-size: var(--text-sm);
  font-weight: 500;
  letter-spacing: .04em;
  line-height: 1;
  text-transform: uppercase;
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  /* --press is the button's own transform channel, so :active does not
     have to out-specify anything to use it. */
  transform: scale(var(--press, 1));
  transition: background-color var(--dur-fast) var(--ease-in-out),
              color var(--dur-fast) var(--ease-in-out),
              border-color var(--dur-fast) var(--ease-in-out),
              box-shadow var(--dur-fast) var(--ease-out),
              transform var(--dur-fast) var(--ease-out);
}

/* Press. Apple and Material both want feedback inside 100ms; --dur-fast is
   200ms for the colour, but the scale wants to be quicker than that or the
   button feels spongy under the finger. 2% is enough to register and small
   enough not to shift the label. */
.btn:active { --press: .98; transition-duration: 90ms; }

/* Raised on hover, planted on press — the shadow has to move with the
   scale or the button reads as sliding rather than being pushed. */
.btn--primary:hover,
.btn--ghost:hover { box-shadow: var(--shadow-2); }
.btn:active { box-shadow: var(--shadow-1); }

/* The gradient lives here and only here. --accent stays a real <color>
   for everything that needs one — borders, ink, color-mix — because a
   gradient is an <image> and silently does nothing in those properties.
   White rides these stops at 6.0:1 worst; see the note in the token layer
   for why the deeper set was chosen over the vivid one. */
.btn--primary {
  position: relative;             /* for the hover layer below */
  background-image: var(--accent-grad);
  background-color: #6D28D9;      /* the middle stop, for print and forced-colors */
  color: var(--text-on-grad);

  /* These two are not tidying. .btn carries `border: 1px solid transparent`,
     and a gradient on a bordered box hits a default that is wrong in a way
     nothing warns about:

       background-origin  defaults to padding-box  — sizes the gradient
       background-clip    defaults to border-box   — paints it wider than that
       background-repeat  defaults to repeat       — tiles the difference

     So the gradient was measured against the padding box, painted across
     the border box, and the 1px strip left over on each side was filled by
     the *next tile* — the far end of the gradient wrapped around. Measured
     on the hero button: the left border pixel came out (179,26,109), pink,
     the gradient's last stop, against a fill that starts blue one pixel
     later; the right edge had the mirror of it. It reads as a stray pink
     rule down one side and a blue one down the other, on every primary
     button on the site.

     Either property alone fixes it. Both are set because they fix it for
     different reasons and the pair documents the trap. */
  background-origin: border-box;
  background-repeat: no-repeat;
}

/* Hover is a crossfade, not a swap.

   background-image is not an animatable property — two gradients cannot
   interpolate — so `transition` on the base .btn never applied to it and
   the colour jumped while the shadow and transform eased. A layer that
   fades its opacity animates the same change with a property that can.

   z-index: -1 works because .btn always establishes a stacking context via
   its `transform: scale(var(--press, 1))`. Inside one, a negative-z child
   paints above the element's own background and below its text, which is
   exactly the slot this needs — and it means the label needs no wrapper.

   inset: -1px, not 0. An absolutely positioned pseudo resolves `inset`
   against the padding box, so at 0 it would stop 1px short of the border
   and leave a ring of the un-hovered gradient around the hovered one —
   the same edge artifact this rule exists to remove, reintroduced. */
.btn--primary::after {
  content: "";
  position: absolute;
  inset: -1px;
  z-index: -1;
  border-radius: inherit;
  background-image: var(--accent-grad-h);
  background-origin: border-box;
  background-repeat: no-repeat;
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease-in-out);
}
.btn--primary:hover::after { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .btn--primary::after { transition: none; }
}

.btn--ghost {
  background: transparent;
  color: var(--text-primary);
  border-color: var(--border-strong);
}
.btn--ghost:hover { background: var(--bg-chip); border-color: var(--accent-ink); }

/* The nav CTA, and the only user of --sm. Proportioned from the scraped
   Squarespace values (padding 23/28, 60px tall in an 80px bar) rather than
   guessed: at 44px tall it read as a long thin slab, where theirs is a
   chunky block. Type drops to --text-2xs so it matches the Log In link
   beside it — the pair is a set, and 14px next to 12px made the button
   shout. */
.btn--sm {
  min-height: 56px;
  padding: var(--space-5) var(--space-7);
  font-size: var(--text-2xs);
  letter-spacing: var(--track-label);
}
.btn--lg { min-height: 60px; padding: var(--space-5) var(--space-8); }

.btn[disabled],
.btn[aria-busy="true"] { opacity: .5; cursor: not-allowed; }
.btn[aria-busy="true"]::before {
  content: "";
  width: 14px; height: 14px;
  border: 2px solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin .7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }

/* ── Nav ────────────────────────────────────────────────────
   Transparent over the hero the way theirs is, then a blurred
   translucent bar once the hero is behind you.                */

.nav {
  position: sticky;
  top: 0;
  z-index: var(--z-nav);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-6);
  height: var(--nav-h);
  padding-inline: var(--gutter);
  background: transparent;
  border-bottom: 1px solid transparent;
  backdrop-filter: none;
  transition: background-color var(--dur-base) var(--ease-in-out-q),
              backdrop-filter var(--dur-base) var(--ease-in-out-q),
              border-color var(--dur-base) var(--ease-in-out-q);
}

/* Legibility over the photo comes from a scrim behind the bar, not
   from tinting the bar itself — so the background stays visible. */
.nav::before {
  content: "";
  position: absolute;
  inset: 0 0 -48px 0;   /* overhangs the bar so the fade finishes off it */
  z-index: -1;
  pointer-events: none;
  /* Tokens, not literals — see --nav-scrim-* for why that matters here
     more than anywhere else on the page. The polarity has now flipped
     twice: a dark scrim under light type originally, a light one once the
     hero became a pale photograph, and dark again now the whole page is
     night. Each flip is a one-line token change and none of them touch
     this rule. */
  background: linear-gradient(
    180deg,
    var(--nav-scrim-from)  0%,
    var(--nav-scrim-mid)  45%,
    var(--nav-scrim-to)  100%
  );
  transition: opacity var(--dur-base) var(--ease-in-out-q);
}

/* Solid once past the hero — and while a panel is open, since a
   transparent bar above an opaque panel reads as a mistake.

   This is also where the bar leaves the dark scope. Over the hero it is
   light type on a photo; past it, it is a cream bar and has to carry the
   page's ink. The `all: unset`-style reset is not available for custom
   properties, so the scope is simply not applied to the bar — .nav carries
   .on-dark only through its ::before scrim, and the token values below
   come from :root. */
.nav[data-scrolled],
.nav[data-menu-open],
.nav[data-nav-open] {
  background: var(--nav-fill);
  backdrop-filter: blur(18px) saturate(1.4);
  -webkit-backdrop-filter: blur(18px) saturate(1.4);
  border-bottom-color: var(--border-subtle);
}
.nav[data-scrolled]::before,
.nav[data-menu-open]::before,
.nav[data-nav-open]::before { opacity: 0; }

@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .nav[data-scrolled],
  .nav[data-menu-open] { background: var(--nav-fill-solid); }
}

/* The favicon's mark, at nav scale. It is the only place the accent
   appears as a fill in the bar besides the CTA, so it reads as the mark
   rather than as a second button. */
.nav__brand {
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  min-height: 44px;
  font-size: var(--text-sm);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  text-decoration: none;
  white-space: nowrap;
}
/* The brand mark is the logo itself now, not a lettered tile.

   38px, not the 26px the tile used. The logo is a two-line wordmark —
   "AI" over "run" — so the lower line is only about a fifth of its height;
   at 26px that line renders as a grey smudge. 38 is the smallest size
   where "run" is still a word. Measured by rendering it, not guessed.
   The favicons give up on it entirely and carry "AI" alone.

   No background and no radius: it is artwork with its own silhouette, and
   a tile behind it would read as a sticker stuck on the bar. The file it
   points at carries the dark-surface colours — see
   assets/img/_source/README.md for why there are two versions.

   alt="" is correct here and not an oversight. The brand link says
   "Let AI Run" in text right beside it, so a description would make a
   screen reader announce the name twice. */
.nav__mark {
  flex: none;
  width: 38px;
  height: 38px;
  object-fit: contain;
}


.nav__links > ul { display: flex; align-items: center; gap: var(--space-10); }
/* Kept from the five-trigger experiment. Four fit at the full step, but the
   row still gets tight approaching the drawer breakpoint, and 32px reads no
   differently at these widths. */
@media (max-width: 1180px) {
  .nav__links > ul { gap: var(--space-8); }
}
.nav__item { display: flex; }

.nav__item > a,
.nav__trigger,
.nav__email {
  position: relative;
  display: flex;
  align-items: center;
  min-height: 44px;
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-strong);
  text-decoration: none;
  transition: color var(--dur-fast) var(--ease-in-out);
}
.nav__trigger {
  gap: var(--space-2);
  background: none;
  border: 0;
  cursor: pointer;
}

.nav__item > a:hover,
.nav__trigger:hover,
.nav__email:hover,
.nav__trigger[aria-expanded="true"] { color: var(--accent-ink-hover); }

.nav__item > a::after,
.nav__trigger::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 16px;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--dur-fast) var(--ease-out);
}
.nav__item > a[aria-current="true"],
.nav__trigger[aria-current="true"] { color: var(--text-primary); }
.nav__item > a[aria-current="true"]::after,
.nav__trigger[aria-current="true"]::after,
.nav__item > a:hover::after,
.nav__trigger:hover::after,
.nav__trigger[aria-expanded="true"]::after { transform: scaleX(1); }

/* The disclosure arrow. Points down at rest, flips when the panel opens. */
.nav__chev {
  width: 10px;
  height: 6px;
  flex: none;
  transition: transform var(--dur-base) var(--ease-out);
}
.nav__trigger[aria-expanded="true"] .nav__chev { transform: rotate(180deg); }

.nav__actions { display: flex; align-items: center; gap: var(--space-6); }

.nav__burger {
  display: none;
  align-items: center;
  gap: var(--space-3);
  min-height: 44px;
  padding: 0;
  background: none;
  border: 0;
  cursor: pointer;
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-strong);
}
.nav__burger-bars { display: grid; gap: 5px; width: 20px; }
.nav__burger-bars i {
  display: block;
  height: 1.5px;
  background: currentColor;
  transition: transform var(--dur-base) var(--ease-out);
}
.nav__burger[aria-expanded="true"] { color: var(--accent-ink-hover); }
.nav__burger[aria-expanded="true"] i:first-child { transform: translateY(3.25px) rotate(45deg); }
.nav__burger[aria-expanded="true"] i:last-child  { transform: translateY(-3.25px) rotate(-45deg); }

/* ── Nav panels ─────────────────────────────────────────────────
   Anchored to the bar, not the trigger, so the wide one can centre
   without running off the left edge. visibility (not display) so the
   panel can transition while still leaving the tab order when closed. */
.nav__panel {
  position: absolute;
  top: calc(100% + 8px);
  left: 50%;
  z-index: 1;
  display: grid;
  gap: var(--space-10);
  width: max-content;
  max-width: calc(100vw - var(--space-10) * 2);
  padding: var(--space-10);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-sm);
  /* The page colour, not --bg-band. The panel is not a different surface
     from the page, it is the page lifted off it, so the shadow does all
     the separating — which is also why the border and the shadow both
     stay. */
  background: var(--bg-page);
  box-shadow: var(--shadow-4);
  opacity: 0;
  visibility: hidden;
  /* Their timing, scraped from the live site: the shell settles over 750ms
     on easeOutQuart while opacity resolves in 300ms. The long, slow
     transform under a quick fade is what makes it feel like it arrives
     rather than switches on. */
  transform: translateX(-50%) translateY(10px);
  transition: opacity var(--dur-panel) var(--ease-in-out),
              transform var(--dur-panel-settle) var(--ease-out),
              visibility 0s linear var(--dur-panel);
}
/* A caret pointing back at whichever trigger is open, and the 8px gap above
   that lets it read. The panel is centred on the *bar*, not on its trigger,
   so without a pointer a wide panel belongs to no item in particular.

   ::before, not a child element: `.nav__panel > *` carries the column
   stagger, and a real element here would be treated as a fourth column and
   shift every nth-child delay after it.

   A rotated square rather than a border triangle, so it can carry the
   panel's own background and border and read as part of the same surface.

   The clip keeps the triangle around the square's **top-left** corner. Under
   rotate(45deg) that corner lands at the top, so its two edges become the
   two upper faces of an upward point. Keeping the top-*right* triangle
   instead — which is what this did first — rotates into a right-pointing
   arrow, because TR lands at the right. Work it out from where the corner
   ends up, not from how the polygon looks unrotated.

   --caret-x is set by main.js from the trigger's centre. */
.nav__panel::before {
  content: "";
  position: absolute;
  top: -8px;
  left: var(--caret-x, 50%);
  width: 15px;
  height: 15px;
  margin-left: -7.5px;
  /* --border-strong, not --border-subtle like the panel's own edge. The
     panel sits only 1.15:1 from the page, so the caret's fill has almost
     nothing to separate it — the two visible border faces are what draw
     the arrow, and at 10% ink they were invisible. */
  border: 1px solid var(--border-strong);
  background: var(--bg-page);
  transform: rotate(45deg);
  clip-path: polygon(-40% -40%, 140% -40%, -40% 140%);
  pointer-events: none;
}

.nav__panel--wide {
  grid-template-columns: 240px minmax(0, 1fr) 260px;
  width: min(1040px, calc(100vw - var(--space-10) * 2));
}

.nav__item[data-open] .nav__panel {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0);
  transition-delay: 0s;
}

/* The columns resolve after the shell, left to right — that stagger against
   the settling panel is what reads as the diagonal. Theirs fades its
   detail column separately on easeInOutCubic at the same 300ms. */
.nav__panel > * {
  opacity: 0;
  transform: translateX(-12px);
  transition: opacity var(--dur-panel) var(--ease-in-out),
              transform var(--dur-panel) var(--ease-out);
}
.nav__item[data-open] .nav__panel > * {
  opacity: 1;
  transform: translateX(0);
}
/* ── The no-JS path for the panels ────────────────────────────
   [data-open] is set by main.js and by nothing else, so without the script
   every panel stays hidden and every link inside one is unreachable — while
   the triggers remain <button>s that a pointer can click to no effect. The
   footer still carries the full link set, so the site is navigable either
   way, but the primary nav being dead breaks this project's own contract:
   the page must navigate with main.js removed, and a hidden start state may
   only exist when something is present to undo it. Without the script,
   nothing was.

   :hover covers a pointer. :focus-within covers a keyboard, and is the more
   important half — tabbing is the only way into a panel when no click
   handler exists.

   Scoped to :not(.js) even though main.js opens on hover too, so the two
   look equivalent on a mouse. They are not: with the script, hover sets
   [data-open], which also drives the trigger's aria-expanded, the click
   toggle, the Escape handler and the outside-click close. A CSS rule that
   fired alongside it would show an open panel whose button still reports
   aria-expanded="false" to a screen reader, and which Escape could not
   close. The fallback has to be mutually exclusive with the real thing, not
   merely similar to it.

   The children are re-declared as well as the shell: they carry their own
   hidden start state a few rules above, and a visible panel full of
   invisible columns is the same bug one level down. */
:root:not(.js) .nav__item:hover .nav__panel,
:root:not(.js) .nav__item:focus-within .nav__panel {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0);
}
:root:not(.js) .nav__item:hover .nav__panel > *,
:root:not(.js) .nav__item:focus-within .nav__panel > * {
  opacity: 1;
  transform: translateX(0);
}

.nav__item[data-open] .nav__panel > *:nth-child(1) { transition-delay: calc(var(--panel-stagger) * 1); }
.nav__item[data-open] .nav__panel > *:nth-child(2) { transition-delay: calc(var(--panel-stagger) * 2); }
.nav__item[data-open] .nav__panel > *:nth-child(3) { transition-delay: calc(var(--panel-stagger) * 3); }

@media (prefers-reduced-motion: reduce) {
  .nav__panel > *,
  .nav__item[data-open] .nav__panel > * {
    opacity: 1;
    transform: none;
    transition-delay: 0s;
  }
}

/* Rule between the index and the explore panel, as in theirs. */
.panel__col--explore {
  border-left: 1px solid var(--border-subtle);
  padding-left: var(--space-10);
}

.panel__label {
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: var(--space-6);
}

/* The one filled button inside a panel, and only where the disclosure has a
   whole page behind it — Services and Pricing. Column one is an index of
   anchors *within* that page; without this the page itself has no entry,
   which is how four pages ended up reachable only from the footer.

   Process and About do not get one: Process has no page by design, and
   About's destination is a section of the home page that its first link
   already names. A button to nowhere is worse than no button. */
.panel__cta {
  margin-top: var(--space-6);
  align-self: flex-start;
}

.panel__list { display: flex; flex-direction: column; }
.panel__list a {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-height: 44px;
  font-size: var(--text-lg);
  font-weight: 400;
  letter-spacing: -.01em;
  color: var(--text-strong);
  text-decoration: none;
  transition: color var(--dur-fast) var(--ease-in-out);
}
.panel__list a:hover { color: var(--accent-ink-hover); }
.panel__list--numbered em {
  font-style: normal;
  font-size: var(--text-2xs);
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
}

/* Data rows rather than links — a price tier is information, not a
   destination; the panel's one CTA carries the action. */
.panel__facts { display: flex; flex-direction: column; gap: var(--space-5); }
.panel__facts > div {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 0 var(--space-4);
  padding-bottom: var(--space-5);
  border-bottom: 1px solid var(--border-subtle);
}
.panel__facts > div:last-child { border-bottom: 0; padding-bottom: 0; }
.panel__facts b {
  font-size: var(--text-lg);
  font-weight: 400;
  letter-spacing: -.01em;
}
.panel__facts u {
  text-decoration: none;
  font-size: var(--text-md);
  color: var(--text-strong);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.panel__facts span {
  grid-column: 1 / -1;
  margin-top: var(--space-1);
  font-size: var(--text-2xs);
  line-height: var(--leading-body);
  color: var(--text-muted);
}

.panel__headline {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  gap: var(--space-2);
  font-size: var(--text-xl);
  font-weight: 400;
  letter-spacing: -.02em;
  text-decoration: none;
  margin-bottom: var(--space-3);
}
.panel__headline span { transition: transform var(--dur-fast) var(--ease-out); }
.panel__headline:hover { color: var(--accent-ink-hover); }
.panel__headline:hover span { transform: translateX(4px); }
.panel__headline--sm {
  margin-top: var(--space-5);
  margin-bottom: 0;
  font-size: var(--text-xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
}

.panel__text {
  font-size: var(--text-xs);
  line-height: var(--leading-body);
  color: var(--text-secondary);
  max-width: 40ch;
  margin-bottom: var(--space-6);
}

/* Squarespace's chip row — rgba(255,255,255,.1) at a 30px radius. Theirs
   are 25px tall; ours keep that look but carry a 44px hit area on a
   pseudo-element, since the panel is tappable on touch. Row gap is 12px so
   the extended areas meet without overlapping. */
.panel__pills { display: flex; flex-wrap: wrap; gap: var(--space-3) var(--space-2); }
.panel__pills a {
  position: relative;
  display: inline-flex;
  align-items: center;
  min-height: 32px;
  padding: 0 var(--space-4);
  border-radius: var(--radius-pill);
  background: var(--bg-chip);
  font-size: var(--text-xs);
  color: var(--text-strong);
  text-decoration: none;
  transition: background-color var(--dur-base) var(--ease-in-out),
              color var(--dur-base) var(--ease-in-out);
}
.panel__pills a::after { content: ""; position: absolute; inset: -6px 0; }

/* Facts, not links — no hover, no hit area. */
.panel__pills--static li {
  display: inline-flex;
  align-items: center;
  min-height: 32px;
  padding: 0 var(--space-4);
  border-radius: var(--radius-pill);
  background: var(--bg-chip);
  font-size: var(--text-xs);
  color: var(--text-strong);
}
.panel__pills a:hover { background: var(--bg-chip-h); color: var(--accent-ink-hover); }

.panel__promo { display: block; text-decoration: none; }
.panel__promo img {
  width: 100%;
  /* height:auto is load-bearing — the height attribute is a presentational
     hint that otherwise wins over aspect-ratio. */
  height: auto;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  object-position: 50% 0;
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-xs);
  transition: border-color var(--dur-base) var(--ease-in-out);
}
.panel__promo:hover img { border-color: var(--border-strong); }
.panel__promo-cap {
  display: block;
  margin-top: var(--space-3);
  font-size: var(--text-2xs);
  color: var(--text-muted);
}

/* Four triggers plus a brand plus actions stop fitting well before the
   panels do. Below this the row becomes a drawer and each panel becomes an
   accordion section — same markup, same triggers, same JS. */
@media (max-width: 900px) {
  .nav__burger { display: inline-flex; }

  /* No caret in the accordion — the panel sits directly under its own
     trigger there, so there is nothing for a pointer to disambiguate. */
  .nav__panel::before { display: none; }

  .nav__links {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    max-height: calc(100svh - var(--nav-h));
    overflow-y: auto;
    padding: var(--space-4) var(--gutter) var(--space-8);
    background: var(--bg-band);
    border-top: 1px solid var(--border-subtle);
    box-shadow: var(--shadow-4);
    opacity: 0;
    visibility: hidden;
    transform: translateY(-8px);
    transition: opacity var(--dur-base) var(--ease-out),
                transform var(--dur-base) var(--ease-out),
                visibility 0s linear var(--dur-base);
  }
  .nav[data-nav-open] .nav__links {
    opacity: 1;
    visibility: visible;
    transform: none;
    transition-delay: 0s;
  }

  .nav__links > ul { flex-direction: column; align-items: stretch; gap: 0; }
  .nav__item { flex-direction: column; align-items: stretch; }
  .nav__item > a,
  .nav__trigger {
    justify-content: space-between;
    min-height: 56px;
    font-size: var(--text-sm);
    border-bottom: 1px solid var(--border-subtle);
  }
  .nav__item > a::after,
  .nav__trigger::after { display: none; }

  /* Accordion: collapse by height, not opacity, so the drawer resizes. */
  .nav__panel,
  .nav__panel--wide {
    display: none;
    position: static;
    width: auto;
    max-width: none;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-6);
    padding: var(--space-6) 0 var(--space-8);
    background: transparent;
    border: 0;
    border-radius: 0;
    box-shadow: none;
    opacity: 1;
    visibility: visible;
    transform: none;
  }
  .nav__item[data-open] .nav__panel { display: grid; transform: none; }
  .panel__col--explore { border-left: 0; padding-left: 0; }
  .panel__label { margin-bottom: var(--space-4); }
}

/* Below this the three columns stop fitting; drop the promo and stack. */
@media (max-width: 980px) {
  .nav__panel--wide {
    grid-template-columns: minmax(0, 1fr);
    width: min(420px, calc(100vw - var(--space-6) * 2));
    gap: var(--space-8);
  }
  .panel__col--explore {
    border-left: 0;
    padding-left: 0;
    border-top: 1px solid var(--border-subtle);
    padding-top: var(--space-8);
  }
  .panel__promo { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .nav__panel { transform: translateX(-50%); }
  .nav__item[data-open] .nav__panel { transform: translateX(-50%); }
}

/* ── Hero ───────────────────────────────────────────────────
   Centred stack, huge light display type, one solid CTA and a
   short reassurance line — their homepage shape exactly.       */
/* The hero is a column of two parts now: the copy, which sizes to its own
   content, and the stage below it, which takes whatever height is left and
   clips the screens against the fold. `.hero__inner` used to own the whole
   100svh and centre inside it; that is what held the headline at the middle
   of the screen, and with a stage underneath it has to give the height up
   or the two fight over the viewport. */
.hero {
  position: relative;
  min-height: 100svh;
  margin-top: calc(var(--nav-h) * -1);   /* nav floats over the hero */
  overflow: hidden;
  display: flex;
  flex-direction: column;
  /* With the stage sized to its card the column no longer fills the
     viewport by itself, so spare height is shared above and below rather
     than pooling under the artwork. */
  justify-content: center;
}

/* ── The particle field ─────────────────────────────────────
   Fixed behind the entire page. The canvas is transparent, so the cream
   ground and its paper texture still show through — the particles are an
   extra layer on the paper, not a replacement for it.

   z-index 0 with the content at 1: the field has to sit *above* body's
   background and *below* everything else. `.band` and the cards keep their
   own opaque fills, so the field passes behind them exactly as it passes
   behind the ground. */
#field {
  position: fixed;
  inset: 0;
  z-index: 0;
  display: block;
  pointer-events: none;
}

/* Anything that carries content has to clear the field. */
.nav, main, footer { position: relative; z-index: 1; }
.nav { z-index: var(--z-nav); }

/* --hero-drop lowers the CTA and its note toward the bottom of the particle
   sphere, leaving the title on the sphere's centre line.

   It has to be paid for twice, and that is the whole trick. .hero__inner is
   centred, so a margin added below the title makes the column taller and
   the *whole group* rises by half of it — the CTA would end up barely lower
   and the title noticeably higher. Adding the same value to padding-top
   pushes the padded box back down by that same half. Net: the title does
   not move and the CTA drops by the full --hero-drop. Change one of the two
   without the other and the title drifts.

   Sized in vmin because the thing it is aiming at is: particles.js draws the
   shell at 0.52 * min(halfH, halfW), and both half-extents project to half
   the viewport, so the sphere's lower edge sits at 50vh + 26vmin. A fixed px
   drop would part company with it on the first unusual viewport. */
.hero__inner {
  --hero-drop: clamp(20px, 7vmin, 72px);
  position: relative;
  z-index: var(--z-raised);
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  padding-top: calc(var(--nav-h) + var(--space-20) + var(--hero-drop));
  padding-bottom: var(--space-10);
}


.hero__title {
  font-size: var(--text-5xl);
  font-weight: var(--weight-display);
  line-height: var(--leading-display);
  letter-spacing: var(--track-display);
  margin-bottom: var(--space-10);
}
.hero__title span { display: block; }

.hero__cta {
  display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-4);
  margin-top: var(--hero-drop);   /* see --hero-drop on .hero__inner */
}

.hero__note {
  margin-top: var(--space-6);
  font-size: var(--text-md);
  color: var(--text-strong);
}

/* ── Hero stage ─────────────────────────────────────────────
   Six concept sites under the headline, cut off by the fold.

   The geometry is Selected Work's, not a 3D carousel: flat cards in a row
   with a real gap and the neighbours peeking. It ran as a coverflow first
   — screens set back and turned on `rotateY` — and the two patterns sat
   badly together on one page, since the same six concepts appear twice.
   One language for a row of concepts.

   Where it differs from Selected Work is the mechanism: that track is
   native scroll-snap because it is a thing you browse, and this one
   advances itself, so it moves on a transform. Transform stays off the
   main thread and does not fight a user's own scrolling the way a
   programmatic `scrollLeft` every two seconds would. */
.hero__stage {
  /* Bounded by the *viewport height* as well as its width. Sizing from
     width alone made the card 396px tall at 1440, which pushed the hero to
     989px — the card was whole but the fold cut it anyway, which looks
     identical to the clipping this was meant to remove. Deriving the width
     back from the height budget keeps the ratio at 16:9 and the whole card
     on screen. */
  /* What is *left* after the copy, not a fraction of the viewport. The
     hero's headline/CTA/note/controls block measures 514–558px across
     breakpoints — near-constant, because the display clamp only moves it a
     little — so a proportional budget overshot on short screens (a 1280×800
     window put the card 23px below the fold). 36rem is that block plus
     margin; the floor stops it collapsing on unusually short portraits. */
  --stage-max-h: max(120px, calc(100svh - 36rem));
  --slide-w: min(44rem, 50vw, calc(var(--stage-max-h) * 16 / 9));
  --slide-gap: var(--space-6);
  /* 16:9 — these are website mockups, so a screen ratio reads correctly,
     and it is what lets the card be *whole*. The slides used to be tall
     crops cut off by the stage, which left their bottom corners square
     while the top two were rounded. A card that is complete on all four
     corners cannot be produced by clipping; the artwork has to fit. */
  --card-h: calc(var(--slide-w) * 9 / 16);
  position: relative;
  z-index: var(--z-raised);
  /* Sized to the card plus room for the neighbours' drop and the shadow —
     no vertical clipping at all now, at any breakpoint. */
  flex: 0 0 auto;
  height: calc(var(--card-h) + var(--space-10));
  overflow: hidden;
}

/* `left: 50%` puts the track's origin at the centre of the viewport, so
   pulling it back half a slide centres slide 0 and every further index is
   one slide-plus-gap. That is the whole positioning model — no per-slide
   maths, and --i is the only thing script ever sets. */
.stage__track {
  --i: 1;                          /* without JS: slide 1 centred, both
                                      neighbours peeking. JS re-bases this
                                      to the first real slide once the
                                      clones are in. */
  position: absolute;
  top: 0;
  left: 50%;
  display: flex;
  gap: var(--slide-gap);
  list-style: none;
  margin: 0;
  padding: 0;
  transform: translate3d(
    calc(var(--slide-w) / -2 - var(--i) * (var(--slide-w) + var(--slide-gap))), 0, 0);

  /* The centre slide stands proud of its neighbours, so the slides carry
     their own depth and the track has to give them a viewpoint.

     `perspective-origin` has to track --i, and this is the part that is
     easy to get wrong: the default 50% resolves against the *whole* track,
     which is ten slides wide — the vanishing point would sit 3,600px off
     to one side of everything on screen. It is placed on the centre of the
     active slide instead, and it is transitioned alongside the transform,
     or the viewpoint would snap to the destination while the track was
     still travelling and the neighbours would warp mid-move. */
  perspective: 1400px;
  perspective-origin:
    calc(var(--i) * (var(--slide-w) + var(--slide-gap)) + var(--slide-w) / 2) 30%;
  transition: transform         var(--dur-slow) var(--ease-out),
              perspective-origin var(--dur-slow) var(--ease-out);
}

/* The loop correction must not animate — it is a jump of exactly one set,
   and seeing it is the artefact the clones exist to hide. Same rule as the
   Selected Work track, which has to defeat `scroll-behavior: smooth` for
   the same reason. */
/* The slides have to be silenced too, not just the track. At the
   correction every element changes which position it holds — the clone
   handing off to the real slide — so without this the whole set would
   visibly re-stack while the track jumped. */
.stage__track[data-jump],
.stage__track[data-jump] .stage__slide,
.stage__track[data-jump] .stage__slide img { transition: none; }

.stage__slide {
  flex: 0 0 var(--slide-w);
  transition: transform var(--dur-slow) var(--ease-out);
  /* Painting order has to be set explicitly, not left to the DOM.
     Pushing a neighbour back on Z also slides it toward the vanishing
     point, so each one laps about 19px over the centre — and in source
     order that meant the *right* neighbour painted over the centre while
     the left went under it. The middle has to be over both, or the raised
     card is the one being covered. z-index applies to flex items without
     `position`, and the track is `transform-style: flat`, so the browser
     stacks by this rather than by 3D depth. */
  z-index: 0;
}
.stage__slide[data-pos="-1"],
.stage__slide[data-pos="1"]  { z-index: 1; }
.stage__slide[data-pos="0"]  { z-index: 2; }

.stage__slide img {
  display: block;
  width: 100%;
  height: var(--card-h);
  /* The crop is 800x620; at 16:9 the bottom is trimmed rather than the
     card, and `top` keeps each concept's masthead — the part that
     identifies it — in frame. */
  object-fit: cover;
  object-position: top center;
  border-radius: var(--radius-sm);        /* 8px — the media/card step */
  /* No border. These are full-bleed screenshots, so a 1px light edge reads
     as a white hairline drawn on the artwork rather than as a frame — most
     obvious on the dark concepts. The shadow does the separating. */
  box-shadow: var(--shadow-2);            /* slides rest at 2, as elsewhere */
  background: var(--bg-raised);   /* placeholder behind a loading shot */
  transition: box-shadow var(--dur-slow) var(--ease-out);
}

/* Depth, by position in the run. The centre keeps the level the flat
   version sat at — nothing rises above it — and the neighbours drop and
   turn away instead, which is what reads as the middle being raised
   without the stage needing any more height. Shadow does the rest: the
   centre steps to 3 while the neighbours hold the slides' resting 2. */
.stage__slide[data-pos="0"]  { transform: none; }
.stage__slide[data-pos="0"] img { box-shadow: var(--shadow-3); }

.stage__slide[data-pos="-1"] { transform: translate3d(0, 2.5%, -140px) rotateY(10deg); }
.stage__slide[data-pos="1"]  { transform: translate3d(0, 2.5%, -140px) rotateY(-10deg); }

/* Clamped at ±2: these are off the edge at every breakpoint, and they
   exist only so a slide entering the frame is already turned and set back
   rather than popping into the angle as it arrives. */
.stage__slide[data-pos="-2"] { transform: translate3d(0, 3.5%, -230px) rotateY(13deg); }
.stage__slide[data-pos="2"]  { transform: translate3d(0, 3.5%, -230px) rotateY(-13deg); }

/* ── Stage controls ─────────────────────────────────────────
   Built by main.js. Previous / dots / next — no pause button: taking hold
   of the arrows or a dot stops the auto-advance for good, which is the
   stop mechanism WCAG 2.2.2 asks for and is reachable by touch and
   keyboard alike. Hover only suspends it. */
.stage__controls {
  position: relative;
  z-index: var(--z-raised);
  flex: 0 0 auto;                   /* a flex child of .hero — never squash */
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  margin-bottom: var(--space-5);
}

.stage__dot,
.stage__arrow {
  /* 44px is a floor, not a starting size — flex must not shrink these.
     Eight controls plus gaps overrun a 360px viewport, and the default
     `flex-shrink: 1` silently traded the touch target away to make it fit.
     The gap goes instead; the visible mark is far smaller than the box. */
  flex: 0 0 44px;
  width: 44px;
  height: 44px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  display: grid;
  place-items: center;
}
.stage__dot:focus-visible,
.stage__arrow:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: -8px;
  border-radius: var(--radius-sm);
}

.stage__dot::before {
  content: "";
  width: 20px;
  height: 2px;
  border-radius: 2px;
  background: var(--border-strong);
  transition: background var(--dur-fast) var(--ease-in-out),
              transform  var(--dur-fast) var(--ease-in-out);
}
.stage__dot[aria-current="true"]::before { background: var(--accent-ink); transform: scaleY(2); }
.stage__dot:hover::before                { background: var(--accent-ink-hover); }

.stage__arrow {
  color: var(--text-muted);
  transition: color var(--dur-fast) var(--ease-in-out);
}
.stage__arrow:hover { color: var(--accent-ink-hover); }
.stage__arrow svg   { width: 8px; height: 13px; fill: none; stroke: currentColor; stroke-width: 1.6; }
.stage__arrow--prev { margin-right: var(--space-2); }
.stage__arrow--next { margin-left:  var(--space-2); }

/* Reduced motion: main.js never starts the timer, so the only movement
   left comes from a deliberate press. Keep it, but short. */
@media (prefers-reduced-motion: reduce) {
  .stage__track,
  .stage__slide,
  .stage__slide img { transition-duration: .01ms; }
}

/* ── Section frames ─────────────────────────────────────── */
.section { padding-block: var(--section-y); }

/* Two joins are tighter than the 128/128 default. Services and Selected
   Work are one thought and close from both sides (256 → 144). What I do
   only closes from below (256 → 208) — it introduces Services rather than
   belonging to it, so it keeps more air than that pair does. */
.section--tight-bottom { padding-bottom: var(--space-20); }
.section--tight-top    { padding-top: var(--space-16); }

.band { padding: var(--section-y) var(--gutter); }
.band--tight { padding-block: var(--space-24); }
/* Lets a child run the full width of the band — the testimonial marquee
   has to reach both edges for its fade to read as off-screen rather than
   as a border. Children that should stay in the grid re-add the gutter
   with .shell. */
.band--flush { padding-inline: 0; }

/* ── The case ───────────────────────────────────────────────
   Their "Join millions of entrepreneurs" band, on Jared's argument. Sits
   on --bg-page rather than --bg-band on purpose: the hero dissolves into
   the page colour, and a band here would put a hard edge immediately
   under that fade and undo it.

   Deliberately half the height it first ran at. It is a preamble, not a
   destination — its job is to land the problem and get out of the way of
   "What I do", which is the section people actually came for. Every
   measure below is halved from the original: the figure clamp, the lead's
   margin, the block padding. */
.stats { padding-block: var(--space-16); }
.stats__grid + * { margin-top: 0; }

.stats__lead {
  max-width: 52ch;
  margin: 0 auto var(--space-10);
  font-size: var(--text-md);
  line-height: var(--leading-body);
  color: var(--text-muted);
}

/* Narrowed, not just gapped. At the full shell width the three figures sat
   a third of a screen apart and read as three separate facts; the argument
   only lands when they are close enough to take in together. */
.stats__grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-6);
  max-width: 56rem;
  margin-inline: auto;
  list-style: none;
}

.stat__figure {
  display: flex;
  align-items: baseline;
  justify-content: center;
  font-size: clamp(2rem, 1.4rem + 2.4vw, 3.25rem);
  font-weight: var(--weight-display);
  line-height: 1;
  letter-spacing: var(--track-display);
  margin-bottom: var(--space-3);
}

/* Tabular figures are not decoration here. The number counts up from 0,
   and proportional digits would change the element's width on every frame,
   shuffling the row for the whole animation. */
.stat__num { font-variant-numeric: tabular-nums; }

.stat__label {
  max-width: 24ch;
  margin: 0 auto var(--space-2);
  font-size: var(--text-md);
  font-weight: 400;
  line-height: var(--leading-snug);
  letter-spacing: -.01em;
}

.stat__source {
  max-width: 28ch;
  margin-inline: auto;
  font-size: var(--text-2xs);
  line-height: var(--leading-body);
  color: var(--text-muted);
}

/* Hover, in the same language as the Process steps and service cards: the
   words take the accent. The figure only lifts — the numeral stays page
   ink, because on cream the orange is a 2.6:1 colour and a percentage is
   the one thing on the page you actually have to read. The unit can go
   accent with the label; it is a glyph, not information. */
.stat__figure,
.stat__unit,
.stat__label { transition: color var(--dur-fast) var(--ease-in-out),
                           transform var(--dur-base) var(--ease-out); }

.stat:hover .stat__figure { transform: translateY(-4px); }
.stat:hover .stat__label,
.stat:hover .stat__unit { color: var(--accent-ink-hover); }

/* The unit resolves after the card has arrived, so the count-up is the
   thing you notice rather than one motion among three. */
.js .stat .stat__unit {
  opacity: 0;
  transform: translateY(.12em);
  transition: opacity var(--dur-base) var(--ease-out) var(--dur-base),
              transform var(--dur-base) var(--ease-out) var(--dur-base),
              color var(--dur-fast) var(--ease-in-out);
}
.js .stat.is-in .stat__unit { opacity: 1; transform: none; }

@media (max-width: 860px) {
  .stats__grid { grid-template-columns: 1fr; gap: var(--space-10); }
  .stats__lead { margin-bottom: var(--space-8); }
}

/* ── What I do ──────────────────────────────────────────────
   The page's plain-language opener: one statement, then how I work — kept
   deliberately distinct from Services, which lists what you can buy.

   Centred throughout, which puts it on the same axis as every other
   section head on the page. Centring only works because nothing here runs
   long: the measures below cap the statement at 20ch and the points at
   ~34ch, and centred text stops being readable past about four lines. */
.intro__lead {
  max-width: 62ch;
  margin-inline: auto;
  margin-bottom: var(--space-20);
  text-align: center;
}
.intro__statement {
  max-width: 20ch;
  margin-inline: auto;
  margin-bottom: var(--space-6);
}
.intro__body {
  max-width: 56ch;
  margin-inline: auto;
}

.intro__points {
  position: relative;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-6);
  text-align: center;
  /* No `perspective` here, deliberately. It used to be, and one vanishing
     point for a three-column row means only the middle card is ever on
     axis: at an identical rotateY(5deg) the three rendered 395.04 / 382.63
     / 370.21px wide — a 25px spread, the outer two keystoned. The depth is
     declared per card instead, as --persp. */
}

/* Glass needs something behind it — backdrop-filter over a flat colour is
   just a translucent panel. The ink block gives the blur only its paper
   texture to work on, so this puts two very soft blooms under the row.
   Light, because the ground is dark; kept far below anything that reads as
   a coloured shape, so it lands as depth and not as decoration.

   Vertical bleed only. A negative *horizontal* inset once made the document
   wider than the viewport at every breakpoint — a scrollbar traded for a
   gradient edge nobody can see. */
.intro__points::before {
  content: "";
  position: absolute;
  inset: -10% 0 -14%;
  z-index: 0;
  pointer-events: none;
  background:
    radial-gradient(46% 60% at 24% 38%, color-mix(in srgb, #FFFFFF 7%, transparent), transparent 70%),
    /* The second bloom takes the accent rather than the old blue-grey
       literal, so it follows the brand instead of quietly staying the
       previous palette's colour. */
    radial-gradient(42% 56% at 76% 56%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 70%);
  filter: blur(28px);
}

.intro__point {
  /* Each card is its own perspective origin — see .intro__points. Long,
     because a short perspective on a 300px card reads as a fisheye rather
     than a considered lean. */
  --persp: 1100px;
  position: relative;
  z-index: 1;
  overflow: hidden;
  isolation: isolate;
  border-radius: var(--radius-sm);
  padding: var(--space-8) var(--space-6) var(--space-10);
  background: var(--glass-fill);
  border: 1px solid var(--glass-edge);
  -webkit-backdrop-filter: var(--glass-blur);
          backdrop-filter: var(--glass-blur);
  box-shadow: 0 1px 1px var(--glass-inset) inset, var(--shadow-2);
}

/* No backdrop-filter, no glass — fall back to the opaque raised surface
   rather than a washed-out translucent one. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .intro__point { background: var(--bg-raised); }
}

/* The card carries .reveal, so it must never set `transform` itself —
   `.js .reveal.is-in` is (0,3,0) and would win. The tilt and the lift go
   through --rx / --ry / --lift, which that rule applies. See CLAUDE.md.

   The entrance owns the transform transition at --dur-slow; a hover at that
   speed feels broken, so the timing is handed over once .is-settled lands.
   Specificity is deliberately (0,4,0) so it clears `.js .reveal.is-in`
   wherever the two sit in the file. */
/* perspective() has to be *in* the transform, because the tilt is applied
   by the reveal system and a card cannot set `transform` itself. First in
   the chain or it projects the rotation that has already happened. Same
   (0,4,0) trick as below. */
.js .intro__points .intro__point.is-in {
  transform: perspective(var(--persp))
             translateY(var(--lift, 0px))
             rotateX(var(--rx, 0deg))
             rotateY(var(--ry, 0deg));
}

.js .intro__points .intro__point.is-settled {
  transition: transform var(--dur-base) var(--ease-out),
              opacity var(--dur-base) var(--ease-out),
              background-color var(--dur-base) var(--ease-in-out),
              border-color var(--dur-base) var(--ease-in-out),
              box-shadow var(--dur-base) var(--ease-out);
}

/* While the pointer is being tracked the transform must not lag behind it —
   a transition here turns the lean into elastic drag. main.js sets the flag
   on enter and drops it on leave, so the *return* to flat still eases. */
.js .intro__points .intro__point.is-settled[data-tracking] {
  transition-property: background-color, border-color, box-shadow;
}

/* The glare. Moved by transform rather than by repainting a gradient's
   centre. It sits under the copy, so it lights the card's surface instead
   of washing the type. */
.intro__glare {
  position: absolute;
  top: 0;
  left: 0;
  z-index: -1;
  width: 78%;
  aspect-ratio: 1;
  border-radius: 50%;
  background: radial-gradient(circle,
              var(--glass-glare) 0%,
              color-mix(in srgb, var(--glass-glare) 40%, transparent) 38%,
              transparent 68%);
  opacity: 0;
  pointer-events: none;
  transform: translate3d(calc(var(--gx, 50%) - 50%), calc(var(--gy, 50%) - 50%), 0);
  transition: opacity var(--dur-base) var(--ease-out);
}
.intro__point:hover .intro__glare { opacity: 1; }

/* Third of the set — Process steps, Services, these. Same staggering as the
   others: the heading takes the accent at --dur-fast so colour lands first,
   the card lifts at --dur-base, and the rule wipes at --dur-slow. */
.intro__point h3 { transition: color var(--dur-fast) var(--ease-in-out); }
.intro__point:hover {
  --lift: -6px;
  background: var(--glass-fill-h);
  border-color: var(--glass-edge-h);
  box-shadow: 0 1px 1px var(--glass-inset-h) inset, var(--shadow-3);
}
.intro__point:hover h3 { color: var(--accent-ink-hover); }

/* The wipe. transform-origin flips on the way out so it retracts to the
   edge it entered from instead of jumping across to reverse. */
.intro__point::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: 0;
  height: 2px;
  background: var(--accent-ink);
  transform: scaleX(0);
  transform-origin: right center;
  transition: transform var(--dur-slow) var(--ease-out);
}
.intro__point:hover::after {
  transform: scaleX(1);
  transform-origin: left center;
}

/* A touch device cannot reverse a tilt and would be left holding a card at
   an angle; under reduced motion the lean is pinned flat and the glare goes
   entirely, since the global reduce rule only shortens durations. */
@media (prefers-reduced-motion: reduce) {
  .intro__point:hover { --lift: 0px; --rx: 0deg; --ry: 0deg; }
  .intro__glare { display: none; }
  .intro__point::after { transition-duration: 1ms; }
}

.intro__point h3 {
  font-size: var(--text-lg);
  font-weight: 400;
  letter-spacing: -.02em;
  margin-bottom: var(--space-3);
}
.intro__point p {
  max-width: 34ch;
  margin-inline: auto;
  font-size: var(--text-xs);
  line-height: var(--leading-body);
  color: var(--text-secondary);
}

@media (max-width: 860px) {
  .intro__lead { margin-bottom: var(--space-12); }
  /* Capped, not full-bleed. As bare rules these could stack to the shell
     width and still read; as cards they cannot — an 800px-wide card holding
     34ch of centred text is a banner with a sentence in it. The cap keeps
     each one card-shaped whatever the viewport does. */
  .intro__points {
    grid-template-columns: 1fr;
    gap: var(--space-6);
    max-width: 30rem;
    margin-inline: auto;
  }
}

/* ── About ──────────────────────────────────────────────── */
/* Tracks sized to their content and the pair centred, not fractions of
   the shell. At .7fr/1.3fr the copy column came out 738px wide while the
   text inside it is capped at its measure (395px for body, 476px for the
   heading) — so 343px of the column was empty and the whole section
   visually hugged the left edge with a hole beside it. Fractions balance
   the *columns*; what has to balance is the ink. */
.about {
  position: relative;
  padding-block: var(--section-y-lg);
  display: grid;
  grid-template-columns: minmax(0, 24rem) minmax(0, 30rem);
  justify-content: center;
  gap: var(--space-16);
  align-items: center;
  overflow: hidden;
}

.about__lines {
  position: absolute;
  left: -80px;
  top: var(--space-5);
  width: 500px;
  height: 400px;
  color: var(--text-primary);
  opacity: .12;
  pointer-events: none;
}

/* The whole frame, uncropped. A fixed height plus object-fit: cover was
   throwing away the top and bottom of a 2:3 portrait to fit a near-square
   box. height: auto lets the source ratio stand — and it has to be
   explicit, because the width/height attributes on the <img> are a
   presentational hint that otherwise wins.

   The column is narrowed to .7fr to pay for it: at .85fr the full-height
   photo ran ~690px and towered over the copy beside it. */
.about__photo {
  position: relative;
  z-index: var(--z-raised);
  width: 100%;
  height: auto;
  border-radius: var(--radius-sm);
}

.about__copy { position: relative; z-index: var(--z-raised); }

/* The right-hand column. .about__lines above is NOT this — it is the
   portfolio's decorative wash, absolutely positioned at opacity .12 behind a
   portrait, and body copy placed in it renders as a ghost underneath the
   heading rather than as text. That layout had a photograph to fill this
   column; this one has the prose instead, which is why the section reads as
   two columns rather than one column and an empty gap.

   align-self: center is inherited from the grid, so a short heading and two
   paragraphs sit on a shared optical centre line rather than both hugging
   the top. */
.about__prose {
  position: relative;
  z-index: var(--z-raised);
  display: grid;
  gap: var(--space-5);
  font-size: var(--text-md);
  line-height: var(--leading-body);
  color: var(--text-body);
  max-width: 62ch;
}


/* ── Accordion ──────────────────────────────────────────────
   Two of them on the page, and each is a *single* disclosure holding a
   whole list — Services and the FAQ. Ten separate rows read as a wall;
   two closed doors read as a choice.

   Rows are written **open** in the HTML and closed by main.js, the same
   contract as the rest of the page: with the script gone these are two
   headed lists of prose, not dead buttons hiding their own content.

   The panel animates on `grid-template-rows: 1fr → 0fr` rather than
   max-height. max-height needs a magic number larger than the content will
   ever be, which makes the close start late and the open finish early;
   `fr` measures the content itself, so the easing is honest at any length. */
.acc--solo { border-top: 1px solid var(--border-strong); }
.acc--solo .acc__item { border-bottom: 1px solid var(--border-strong); }

/* Services and the FAQ are two rows of one list, not two sections. They
   close right up against each other: no padding between them, and the FAQ
   drops its own border-top or the shared rule doubles to 2px. Each row still
   breathes internally — .acc__label carries 64px above the label, inside the
   rule — so closing the outer gap tightens the list without cramping it. */
.services { padding-bottom: 0; }
.faq { padding-top: 0; }
.faq .acc--solo { border-top: 0; }
.acc__h { margin: 0; font: inherit; }
.acc__label { padding-top: var(--space-8); color: var(--text-muted); }

.acc__head {
  width: 100%;
  display: flex;
  align-items: center;
  gap: var(--space-6);
  padding: var(--space-3) 0 var(--space-8);
  min-height: 44px;
  border: 0;
  background: none;
  cursor: pointer;
  text-align: left;
  color: var(--text-primary);
  transition: color var(--dur-fast) var(--ease-in-out);
}
.acc__head:hover { color: var(--accent-ink-hover); }
.acc__head:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 4px; }

/* The tab title *is* the section heading, so it takes a display setting
   rather than a control's — but two steps down from a full section head,
   since it sits on a row with a control rather than centred in open space,
   and the row is the widest measure on the page. */
.acc__title {
  flex: 1;
  font-size: var(--text-2xl);
  font-weight: var(--weight-display);
  line-height: var(--leading-display);
  letter-spacing: var(--track-display);
}

/* The plus. Two bars; the upright one scales away on open, so the sign
   reads + → − and the affordance is legible before anyone clicks. */
.acc__sign { position: relative; flex: none; width: 30px; height: 30px; }
.acc__sign::before,
.acc__sign::after {
  content: "";
  position: absolute;
  inset: 0;
  margin: auto;
  background: currentColor;
}
.acc__sign::before { width: 100%; height: 2px; }
.acc__sign::after  { width: 2px; height: 100%; transition: transform var(--dur-base) var(--ease-out); }
.acc__head[aria-expanded="true"] .acc__sign::after { transform: scaleY(0); }

.acc__panel {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows var(--dur-slow) var(--ease-out);
}
.acc__inner { overflow: hidden; visibility: visible; }
.acc__sub { margin: 0 0 var(--space-10); text-align: left; max-width: 46ch; }

/* An inline link, not `.link-underline` — that one is a standalone CTA
   (uppercase, 12px, tracked) and reads as a caption dropped into the
   middle of a sentence. Inside prose a link should keep the sentence's
   size and weight and only carry a rule. */
.acc__sub a {
  color: inherit;
  text-decoration: underline;
  text-decoration-thickness: 1px;
  text-underline-offset: .2em;
  text-decoration-color: var(--border-strong);
  transition: color var(--dur-fast) var(--ease-in-out),
              text-decoration-color var(--dur-fast) var(--ease-in-out);
}
.acc__sub a:hover { color: var(--accent-ink-hover); text-decoration-color: currentColor; }
.acc__sub a:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }

.acc__item[data-collapsed] .acc__panel { grid-template-rows: 0fr; }
/* Leaves the tab order and the accessibility tree once it has finished
   closing — a zero-height row is still focusable without this. */
.acc__item[data-collapsed] .acc__inner {
  visibility: hidden;
  transition: visibility 0s linear var(--dur-slow);
}

/* Two columns so an opened tab stays a panel rather than a page.

   Each entry spans the grid's own rows via `subgrid`, which is what keeps
   the columns level. Without it every cell is an independent block, so a
   question that wraps to two lines in one column pushes its answer down
   while the neighbour's answer stays put — the pair still *starts* level
   but nothing inside them lines up. Subgrid puts every question in one row
   band and every answer in the next, whatever the wrapping does. */
.svc, .qa {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-10) var(--space-16);
  margin: 0 0 var(--space-12);
  padding: 0;
  list-style: none;
}

@supports (grid-template-rows: subgrid) {
  .svc__item, .qa__item {
    display: grid;
    grid-template-rows: subgrid;
    row-gap: var(--space-2);
  }
  .svc__item { grid-row: span 3; }   /* number, title, text  */
  .qa__item  { grid-row: span 2; }   /* question, answer     */
  /* The row gap owns the spacing now; leftover margins would double it. */
  .svc__num, .svc__title, .qa__q { margin-bottom: 0; }
}
.svc__num {
  display: block;
  margin-bottom: var(--space-2);
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  color: var(--text-muted);
}
.svc__title, .qa__q {
  margin: 0 0 var(--space-2);
  font-size: var(--text-xl);
  font-weight: 500;
  letter-spacing: -.01em;
}
.svc__text, .qa__a {
  margin: 0;
  font-size: var(--text-md);
  line-height: 1.65;
  color: var(--text-body);
}

@media (max-width: 860px) {
  .svc, .qa { grid-template-columns: 1fr; gap: var(--space-8); }
}
@media (max-width: 620px) {
  .acc__head { gap: var(--space-4); padding-bottom: var(--space-6); }
  .acc__sign { width: 24px; height: 24px; }
}

@media (prefers-reduced-motion: reduce) {
  .acc__panel, .acc__sign::after { transition-duration: .01ms; }
}

/* ── Selected work: tab-driven carousel ─────────────────────
   Native scroll-snap does the moving; the tabs are shortcuts that set
   scrollLeft, so it still works with JS off and with a keyboard on the
   scroll container itself. */
.showcase__tabs {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-2);
  margin-bottom: var(--space-12);
}
.showcase__tab {
  min-height: 44px;
  padding: 0 var(--space-5);
  border: 1px solid transparent;
  border-radius: var(--radius-pill);
  background: var(--bg-chip);
  color: var(--text-strong);
  font-size: var(--text-sm);
  font-weight: 400;
  letter-spacing: -.01em;
  cursor: pointer;
  transition: background-color var(--dur-fast) var(--ease-in-out),
              color var(--dur-fast) var(--ease-in-out);
}
.showcase__tab:hover { background: var(--bg-chip-h); color: var(--accent-ink-hover); }
.showcase__tab[aria-current="true"] {
  background: var(--accent);
  color: var(--text-on-accent);
}

.showcase__track {
  display: flex;
  gap: var(--space-6);
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  scroll-behavior: smooth;
  /* Centres the first and last slide, which is what produces the peek of
     the neighbouring cards. */
  padding-inline: max(var(--gutter), calc((100% - 1040px) / 2));
  padding-block: var(--space-1);
  scrollbar-width: none;
}
.showcase__track::-webkit-scrollbar { display: none; }
.showcase__track:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 4px; }

@media (prefers-reduced-motion: reduce) {
  .showcase__track { scroll-behavior: auto; }
}

.slide {
  flex: 0 0 min(1040px, 86vw);
  scroll-snap-align: center;
}

/* On the dark page the cards separated by luminance alone. On cream they
   do not — Meridian's #E4E2DC card *is* the page colour, so without an
   edge it dissolves into it. The shadow is two layers on purpose: a tight
   one for the contact edge, a wide soft one for the lift. */
.slide__link {
  position: relative;
  display: block;
  height: 100%;
  aspect-ratio: 16 / 9;
  min-height: 420px;
  overflow: hidden;
  border-radius: var(--radius-sm);
  background: var(--slide-bg);
  color: var(--slide-ink);
  text-decoration: none;
  box-shadow: var(--shadow-2);
  transition: transform var(--dur-base) var(--ease-out),
              box-shadow var(--dur-base) var(--ease-out);
}
.slide__link:hover {
  transform: translateY(-4px);
  box-shadow: var(--shadow-3);
}

/* Sized against the card, not in ch — ch resolves at the container's own
   font-size, which made the display-size title wrap a word per line. */
.slide__copy {
  position: relative;
  z-index: 1;
  width: min(46%, 460px);
  padding: var(--space-10);
}
.slide__eyebrow {
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  /* Mix percentages are solved, not chosen: at 72%/80% the eyebrow and body
     dropped to 3.3:1 on the lower-contrast cards. 88%/92% clears 4.5:1 on
     all six with the darkest card (Kettle) at 7.1:1. Re-solve if a card
     colour changes. */
  color: color-mix(in srgb, var(--slide-ink) 88%, var(--slide-bg));
  margin-bottom: var(--space-5);
}
.slide__title {
  font-size: var(--text-3xl);
  font-weight: var(--weight-display);
  line-height: var(--leading-display);
  letter-spacing: var(--track-display);
  margin-bottom: var(--space-4);
}
.slide__text {
  font-size: var(--text-md);
  line-height: var(--leading-body);
  color: color-mix(in srgb, var(--slide-ink) 92%, var(--slide-bg));
  margin-bottom: var(--space-6);
}
.slide__cta {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
}
.slide__cta span { transition: transform var(--dur-fast) var(--ease-out); }
.slide__link:hover .slide__cta span { transform: translateX(4px); }

/* The mockup sits bottom-right and is clipped by the card, the way their
   device shots run off the bottom edge. */
.slide__frame {
  position: absolute;
  right: 4%;
  bottom: -12%;
  width: 46%;
  aspect-ratio: 4 / 3;
  overflow: hidden;
  border-radius: var(--radius-xs);
  box-shadow: var(--shadow-4);
}
.slide__shot { width: 100%; height: auto; display: block; }

/* Same travel trick as the old tiles: the mockup is a full-page render, so
   hovering the card scrolls through it. */
@supports (container-type: size) {
  @media (hover: hover) and (pointer: fine) {
    .slide__frame { container-type: size; }
    .slide__shot {
      transform: translateY(0);
      /* Solved against the tile height, not chosen. The travel is the whole
         render, so the duration sets a *speed*: 2400ms was right when the
         tiles were ~1600px. They are capped at 2600 (tools/build_shots.py),
         and 3800ms keeps the same px/ms. Re-solve if MAX_TILE_H moves. */
      transition: transform 3800ms var(--ease-in-out);
    }
    .slide__link:hover .slide__shot,
    .slide__link:focus-visible .slide__shot { transform: translateY(calc(100cqh - 100%)); }
  }
}
@media (prefers-reduced-motion: reduce) {
  .slide__link:hover .slide__shot,
  .slide__link:focus-visible .slide__shot { transform: none; }
}

@media (max-width: 860px) {
  .slide { flex: 0 0 min(1040px, 88vw); }
  /* On the dark page the cards separated by luminance alone. On cream they
   do not — Meridian's #E4E2DC card *is* the page colour, so without an
   edge it dissolves into it. The shadow is two layers on purpose: a tight
   one for the contact edge, a wide soft one for the lift. */
.slide__link { aspect-ratio: 4 / 3; min-height: 0; }
  .slide__copy { width: min(58%, 420px); padding: var(--space-8); }
  .slide__frame { width: 56%; right: 6%; bottom: -14%; }
}

@media (max-width: 620px) {
  /* 44px, not the 40px this used to run at. Six pills wrap to three rows
     here, so the gap has to carry the 8px between-target spacing too. */
  .showcase__tabs { gap: var(--space-2); margin-bottom: var(--space-8); }
  .showcase__tab { min-height: 44px; padding: 0 var(--space-4); font-size: var(--text-xs); }
  /* On the dark page the cards separated by luminance alone. On cream they
   do not — Meridian's #E4E2DC card *is* the page colour, so without an
   edge it dissolves into it. The shadow is two layers on purpose: a tight
   one for the contact edge, a wide soft one for the lift. */
.slide__link { aspect-ratio: 3 / 4; }
  .slide__copy { width: auto; padding: var(--space-6); }
  .slide__frame { width: 78%; right: 11%; bottom: -10%; }
}

/* ── Testimonials ─────────────────────────────────────────
   A marquee rather than the concept carousel's snap track. Reviews are
   short and there are many, so the interesting thing is the wall of them
   moving, not landing on one — there is nothing here to pick.

   The track holds the set twice and animates to exactly -50%, so the
   moment it lands there the second set is sitting where the first began
   and the reset is invisible. The duplicate is inserted by main.js and
   marked aria-hidden; without the script there is no duplicate, no
   animation, and the viewport is simply a scrollable row.

   Transform only — never scrollLeft — so this stays off the main thread.  */
.marquee {
  --marquee-dur: 80s;
  --marquee-gap: var(--space-6);
  position: relative;
  margin-top: var(--space-16);
}

/* Fades the row into the band at both ends so cards enter and leave
   rather than being clipped. mask, not a gradient overlay: the band sits
   on --bg-band and an overlay would have to hard-code that colour. */
.marquee__viewport {
  overflow-x: auto;
  overflow-y: hidden;
  scrollbar-width: none;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
          mask-image: linear-gradient(90deg, transparent, #000 8%, #000 92%, transparent);
}
.marquee__viewport::-webkit-scrollbar { display: none; }

.marquee__track {
  display: flex;
  gap: var(--marquee-gap);
  width: max-content;
  padding-inline: var(--gutter);
  list-style: none;
}

/* Only once main.js has doubled the set — a half-width translate on a
   single set would walk the whole row off the screen. */
.marquee[data-marquee-ready] .marquee__track {
  padding-inline: 0;
  animation: marquee-drift var(--marquee-dur) linear infinite;
}

/* Not a plain -50%. The track is 16 cards with 15 gaps between them, so
   half its width is 8 cards + 7.5 gaps — but landing the second set where
   the first started needs 8 cards + 8 gaps. That missing half-gap is a
   12px jolt once every cycle, which is exactly the artefact the duplicate
   set exists to prevent. Hence the correction. */
@keyframes marquee-drift {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(calc(-50% - var(--marquee-gap) / 2), 0, 0); }
}

/* Three ways to stop it, because hover alone reaches only a mouse.
   WCAG 2.2.2 wants a mechanism for motion that runs past five seconds:
   the toggle is that mechanism, and hover and focus are the conveniences
   on top of it. */
.marquee:hover .marquee__track,
.marquee:focus-within .marquee__track,
.marquee[data-paused] .marquee__track { animation-play-state: paused; }

.marquee__toggle {
  position: absolute;
  right: var(--gutter);
  bottom: calc(var(--space-10) * -1);
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 44px;
  padding: 0 var(--space-4);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-pill);
  background: transparent;
  color: var(--text-muted);
  font: inherit;
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  cursor: pointer;
  transition: color var(--dur-fast) var(--ease-in-out),
              border-color var(--dur-fast) var(--ease-in-out);
}
.marquee__toggle:hover { color: var(--text-primary); border-color: var(--accent-ink); }

.quote {
  flex: none;
  width: 34ch;
  padding: var(--space-8);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-sm);
  background: var(--bg-raised);
}

/* Smaller than the old two-up pull quotes ran. At --text-2xl in a 34ch
   card these were six lines deep and the card stopped being scannable
   while it drifted. */
.quote blockquote {
  font-size: var(--text-xl);
  font-weight: 400;
  line-height: var(--leading-body);
  letter-spacing: -.02em;
  text-wrap: pretty;
  margin-bottom: var(--space-6);
}
/* Unfilled avatar slot — a calm tile, not a dashed box. Lived with the work
   grid until that became a carousel; the testimonials still need it.
   Tokens, not the rgba it used to carry: the page went cream and a
   hard-coded light ink disappeared against it. */
.slot {
  display: grid;
  place-items: center;
  background: var(--bg-chip);
  border: 1px solid var(--border-subtle);
}
.slot--avatar {
  width: 44px;
  height: 44px;
  flex: none;
  border-radius: 50%;
}

.quote figcaption { display: flex; align-items: center; gap: var(--space-3); }
.quote__name { font-size: var(--text-xs); font-weight: 500; }
.quote__role { font-size: var(--text-2xs); color: var(--text-muted); }

/* ── Contact ────────────────────────────────────────────── */
.contact {
  padding-block: var(--section-y-lg);
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-16);
}
.contact .body { max-width: 38ch; margin-bottom: var(--space-8); }
.contact .btn { align-self: start; }

.form { display: flex; flex-direction: column; gap: var(--space-5); }
.form__submit { margin-top: var(--space-2); align-self: start; }

.field { display: flex; flex-direction: column; gap: var(--space-2); }
.field__label {
  display: flex;
  align-items: baseline;
  gap: var(--space-1);
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-muted);
}
.field__req { color: var(--danger); }
.field__hint { font-size: var(--text-2xs); color: var(--text-muted); }
.field__error {
  display: none;
  font-size: var(--text-2xs);
  font-weight: 500;
  color: var(--danger);
}
.field--invalid .field__error { display: block; }
.field--invalid .field__hint  { display: none; }

input[type="text"],
input[type="email"] {
  width: 100%;
  min-height: 56px;
  background: transparent;
  border: 1px solid var(--border-field);
  border-radius: var(--radius-xs);
  color: var(--text-primary);
  font-size: var(--text-md);
  padding: var(--space-4);
  transition: border-color var(--dur-fast) var(--ease-in-out),
              background-color var(--dur-fast) var(--ease-in-out);
}
input::placeholder { color: var(--text-muted); }
input:hover { border-color: var(--border-strong); }
input:focus {
  outline: none;
  border-color: var(--accent-ink);
  background: var(--bg-chip);
}
input:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
input[aria-invalid="true"] { border-color: var(--danger); }

.form__status {
  font-size: var(--text-xs);
  font-weight: 500;
  min-height: 1.2em;
}
.form__status[data-state="error"]   { color: var(--danger); }
.form__status[data-state="success"] { color: var(--success); }

/* ── Newsletter ─────────────────────────────────────────── */
/* A footnote, not a section. It is the last thing before the footer and
   asks for one field, so it takes a slimmer band than --band-tight and
   pulls its internal rhythm in to match — at full section height an email
   input floated in the middle of an empty screen. */
.band--slim { padding-block: var(--space-14); }

.newsletter { text-align: center; }
.newsletter__title {
  font-size: var(--text-2xl);
  font-weight: var(--weight-display);
  line-height: var(--leading-display);
  letter-spacing: var(--track-display);
  margin-bottom: var(--space-2);
}
.newsletter__text {
  font-size: var(--text-xs);
  color: var(--text-muted);
  max-width: 46ch;
  margin: 0 auto var(--space-5);
}

.newsletter__form {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-3);
}
.newsletter__form input { width: 320px; }
.newsletter__status { flex: 1 0 100%; }

/* ── Secondary pages ────────────────────────────────────── */
/* Services, Pricing, the case studies and the legal pages. They share one
   head so a visitor always knows they have left home without the nav having
   to say so: label, title, one line of lead, and nothing else. There is no
   hero stage and no photograph — the particle field carries the top of the
   page on its own. */
.page-head {
  padding-top: calc(var(--nav-h) + var(--space-24));
  padding-bottom: var(--space-16);
  text-align: center;
}
.page-head__title {
  max-width: 20ch;
  margin: var(--space-4) auto var(--space-5);
}
.page-head__lead {
  max-width: 54ch;
  margin-inline: auto;
  font-size: var(--text-md);
  color: var(--text-muted);
}
/* A page head is the first thing under a transparent nav, so it cannot rely
   on the reveal to bring it in — it is already on screen at load. */
.page-head .reveal { opacity: 1; }

/* Long-form prose: the legal pages, and any case-study body copy. Capped at
   a reading measure and left-aligned, which is the one place on this site
   that centred type would actively hurt. */
.prose {
  max-width: 68ch;
  margin-inline: auto;
}
.prose h2 {
  font-size: var(--text-2xl);
  font-weight: var(--weight-display);
  line-height: var(--leading-display);
  letter-spacing: var(--track-display);
  margin: var(--space-12) 0 var(--space-4);
}
.prose h2:first-child { margin-top: 0; }
.prose h3 {
  font-size: var(--text-lg);
  font-weight: 400;
  letter-spacing: -.02em;
  margin: var(--space-8) 0 var(--space-2);
}
.prose p,
.prose li {
  font-size: var(--text-xs);
  line-height: var(--leading-body);
  color: var(--text-body);
}
.prose p { margin-bottom: var(--space-4); }
.prose ul { margin: 0 0 var(--space-4) var(--space-5); }
.prose li { margin-bottom: var(--space-2); }
/* :not(.btn) is load-bearing. `.prose a` is (0,1,1) and `.btn--primary` is
   (0,1,0), so without it the prose link colour out-specifies the button's
   own and every CTA inside a .prose block rendered near-black ink on a
   black fill — 1.06:1, an invisible button on four pages. */
.prose a:not(.btn) { color: var(--text-primary); text-underline-offset: 3px; }
.prose a:not(.btn):hover { color: var(--accent-ink-hover); }
.prose code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: .92em;
  padding: .1em .35em;
  border-radius: var(--radius-xs);
  background: var(--bg-chip);
}
.prose__meta {
  font-size: var(--text-2xs);
  color: var(--text-muted);
  margin-bottom: var(--space-10);
}
/* A standing note the reader should not skim past — used where a page says
   something about itself rather than to the reader. */
.prose__note {
  border-left: 2px solid var(--accent-ink);
  padding: var(--space-4) 0 var(--space-1) var(--space-5);
  margin-bottom: var(--space-8);
}
.prose__note p:last-child { margin-bottom: 0; }

/* ── Services page ──────────────────────────────────────── */
/* One row per service, alternating nothing — the rule and the numeral do
   the separating, the same language the home page's Services tab uses. */
.svc-row {
  display: grid;
  grid-template-columns: 4rem 1fr 20rem;
  gap: var(--space-10);
  padding: var(--space-14) 0;
  border-top: 1px solid var(--border-strong);
  scroll-margin-top: calc(var(--nav-h) + var(--space-8));
}
.svc-row:last-child { border-bottom: 1px solid var(--border-strong); }
.svc-row__num {
  font-size: var(--text-md);
  color: var(--text-muted);
  font-variant-numeric: tabular-nums;
}
/* The one deliberate exception to "don't bold headings". These four names
   are what a visitor scans the page for, and at the display weight they sat
   level with the body copy beside them. Tracking is relaxed from
   --track-display: -.055em is drawn for thin type, and at 600 it closes the
   counters up into a smudge. */
.svc-row__title {
  font-size: var(--text-3xl);
  font-weight: 600;
  line-height: var(--leading-display);
  letter-spacing: -.02em;
  margin-bottom: var(--space-4);
}
.svc-row__text {
  font-size: var(--text-xs);
  line-height: var(--leading-body);
  color: var(--text-body);
  max-width: 52ch;
  margin-bottom: var(--space-5);
}
.svc-row__list { list-style: none; margin: 0; padding: 0; }
.svc-row__list li {
  font-size: var(--text-2xs);
  line-height: var(--leading-body);
  color: var(--text-muted);
  padding: var(--space-2) 0;
  border-bottom: 1px solid var(--border-subtle);
}
.svc-row__list li:last-child { border-bottom: 0; }
.svc-row__aside {
  font-size: var(--text-2xs);
  color: var(--text-muted);
  line-height: var(--leading-body);
}
.svc-row__price {
  display: block;
  font-size: var(--text-lg);
  color: var(--text-primary);
  margin-bottom: var(--space-2);
  font-variant-numeric: tabular-nums;
}

/* ── Pricing page ───────────────────────────────────────── */
.price-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-5);
  align-items: start;
}
.price-card {
  background: var(--bg-raised);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-sm);
  padding: var(--space-8) var(--space-6) var(--space-8);
  box-shadow: var(--shadow-1);
  scroll-margin-top: calc(var(--nav-h) + var(--space-8));
  display: flex;
  flex-direction: column;
  height: 100%;
}
/* Head centred, body left. The name, figure and unit are the thing being
   compared across three cards, so centring them puts all three on one axis
   the eye can run down; the feature list stays left because a centred list
   cannot be scanned. */
.price-card__head {
  text-align: center;
  padding-bottom: var(--space-6);
  margin-bottom: var(--space-6);
  border-bottom: 1px solid var(--border-subtle);
}
.price-card__name {
  font-size: var(--text-2xl);
  font-weight: 600;
  letter-spacing: -.02em;
  margin-bottom: var(--space-3);
}
.price-card__fig {
  font-size: var(--text-3xl);
  font-weight: var(--weight-display);
  line-height: 1;
  letter-spacing: var(--track-display);
  font-variant-numeric: tabular-nums;
  margin-bottom: var(--space-1);
}
.price-card__unit {
  font-size: var(--text-2xs);
  color: var(--text-muted);
}
.price-card__text {
  font-size: var(--text-xs);
  line-height: var(--leading-body);
  color: var(--text-body);
  margin-bottom: var(--space-6);
}
.price-card__list { list-style: none; margin: 0 0 var(--space-8); padding: 0; }
.price-card__list li {
  font-size: var(--text-2xs);
  line-height: var(--leading-body);
  color: var(--text-muted);
  padding: var(--space-2) 0;
  border-bottom: 1px solid var(--border-subtle);
}
.price-card__list li:last-child { border-bottom: 0; }
.price-card .btn { margin-top: auto; }

/* ── Case studies ───────────────────────────────────────── */
.case__meta {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: var(--space-6);
  padding: var(--space-8) 0;
  border-block: 1px solid var(--border-strong);
  margin-bottom: var(--space-16);
}
.case__meta dt {
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: var(--space-2);
}
.case__meta dd {
  margin: 0;
  font-size: var(--text-xs);
  line-height: var(--leading-body);
  color: var(--text-body);
}
/* The render. Shown whole rather than cropped — the point of the page is
   the layout, so a fold across it defeats the exercise. */
.case__shot {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow-2);
}
.case__figure { margin: 0 0 var(--space-16); }
.case__caption {
  margin-top: var(--space-3);
  font-size: var(--text-2xs);
  color: var(--text-muted);
  text-align: center;
}
.case__swatches {
  display: flex;
  gap: var(--space-2);
  list-style: none;
  margin: 0 0 var(--space-4);
  padding: 0;
}
.case__swatches li {
  width: 34px;
  height: 34px;
  border-radius: var(--radius-xs);
  border: 1px solid var(--border-subtle);
}

/* Previous / next between the six. A pager is not a nav bar — it names the
   destination rather than pointing at an arrow and hoping. */
.pager {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-5);
  border-top: 1px solid var(--border-strong);
  padding-top: var(--space-8);
}
.pager a {
  text-decoration: none;
  min-height: 44px;
  display: flex;
  flex-direction: column;
  justify-content: center;
}
.pager a:last-child { text-align: right; }
.pager__dir {
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: var(--space-1);
}
.pager__name {
  font-size: var(--text-lg);
  font-weight: 400;
  letter-spacing: -.02em;
  transition: color var(--dur-fast) var(--ease-in-out);
}
.pager a:hover .pager__name { color: var(--accent-ink-hover); }

/* ── 404 ────────────────────────────────────────────────── */
.notfound {
  min-height: calc(100svh - var(--nav-h));
  display: grid;
  place-content: center;
  text-align: center;
  padding: var(--space-24) var(--gutter);
}
.notfound__code {
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: var(--space-4);
}
.notfound__actions {
  display: flex;
  gap: var(--space-3);
  justify-content: center;
  flex-wrap: wrap;
  margin-top: var(--space-8);
}

@media (max-width: 900px) {
  .svc-row { grid-template-columns: 3rem 1fr; }
  .svc-row__aside { grid-column: 2; }
  .price-grid { grid-template-columns: 1fr; max-width: 30rem; margin-inline: auto; }
  .case__meta { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 620px) {
  .page-head { padding-top: calc(var(--nav-h) + var(--space-12)); }
  .svc-row { grid-template-columns: 1fr; gap: var(--space-5); }
  .svc-row__aside { grid-column: 1; }
  .case__meta { grid-template-columns: 1fr; }
  .pager { grid-template-columns: 1fr; }
  .pager a:last-child { text-align: left; }
}

/* ── Footer ─────────────────────────────────────────────── */
.footer {
  background: var(--bg-band);
  border-top: 1px solid var(--border-subtle);
  padding: var(--space-20) var(--gutter) var(--space-12);
}
.footer__inner {
  display: flex;
  justify-content: space-between;
  align-items: flex-end;
  flex-wrap: wrap;
  gap: var(--space-10);
}
.footer__wordmark {
  font-size: var(--text-3xl);
  font-weight: var(--weight-display);
  line-height: var(--leading-display);
  letter-spacing: var(--track-display);
  max-width: 12ch;
}
.footer__meta {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-6) var(--space-10);
}
.footer__links { display: flex; flex-wrap: wrap; gap: var(--space-2) var(--space-6); }
.footer__links a {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-muted);
  text-decoration: none;
  transition: color var(--dur-fast) var(--ease-in-out);
}
.footer__links a:hover { color: var(--text-primary); }
.footer__copy { font-size: var(--text-2xs); color: var(--text-muted); }

/* ── Scroll reveal ──────────────────────────────────────────
   easeOutQuart over 800ms with a 24px rise — their entrance. */
.js .reveal {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity var(--dur-slow) var(--ease-out),
              transform var(--dur-slow) var(--ease-out);
}
/* Not `transform: none`. This selector is (0,3,0) and every hover lift on
   the page is (0,2,0), so a settled reveal silently pinned them all — the
   service cards were setting translateY(-6px) that never rendered. Routing
   the settled transform through a variable lets a component set --lift
   without having to out-specify the reveal system. */
.js .reveal.is-in {
  opacity: 1;
  transform: translateY(var(--lift, 0px))
             rotateX(var(--rx, 0deg))
             rotateY(var(--ry, 0deg));
}

.js .reveal:nth-child(2) { transition-delay: calc(var(--stagger) * 1); }
.js .reveal:nth-child(3) { transition-delay: calc(var(--stagger) * 2); }
.js .reveal:nth-child(4) { transition-delay: calc(var(--stagger) * 3); }

/* The stagger belongs to the entrance and must not outlive it. A
   transition-delay is not a one-off — it applies to *every* later
   transition on that element, so the fourth card in a row was starting its
   hover a quarter of a second after the first, and the third and second
   were in between. It read as those cards being less responsive, which is
   exactly what it was.

   main.js adds .is-settled once the entrance transition ends. Same
   specificity as the nth-child rules above, so it has to stay below
   them. */
.js .reveal.is-settled { transition-delay: 0s; }

@media (prefers-reduced-motion: reduce) {
  .js .reveal { opacity: 1; transform: translateY(var(--lift, 0px)); }
}

/* ── Responsive ─────────────────────────────────────────── */
@media (max-width: 1080px) {
  :root {
    --gutter: var(--space-8);
    --section-y: var(--space-24);
    --section-y-lg: var(--space-28);
  }

  .cards { grid-template-columns: repeat(2, 1fr); }
  .about, .contact { gap: var(--space-12); }
  .quotes { gap: var(--space-10); }
  .section__head { margin-bottom: var(--space-16); }
}

@media (max-width: 860px) {
  :root { --gutter: var(--space-6); --nav-h: 72px; }

  /* 16px minimum on controls — anything smaller makes iOS zoom on focus. */
  body { font-size: var(--text-lg); }
  input[type="text"], input[type="email"] { font-size: var(--text-lg); }
  .body, .section__sub, .hero__note { font-size: var(--text-lg); }

  .nav__email { display: none; }
  .nav__links > ul { gap: var(--space-6); }

  .about { grid-template-columns: 1fr; gap: var(--space-10); }
  /* Full width in one column would run the uncropped 2:3 portrait to ~585px
     on a phone. Cap the width instead of the height — cropping is what we
     just took out. */
  .about__photo { max-width: 320px; margin-inline: auto; }
  /* One column now, so the prose runs the full width of the shell
     rather than keeping a measure meant for a 30rem column. */
  .about__prose { max-width: none; }
  .about__lines { top: auto; bottom: -40px; }

  .work { grid-template-columns: repeat(2, 1fr); gap: var(--space-12) var(--space-6); }

  .contact { grid-template-columns: 1fr; }
  .quotes { grid-template-columns: 1fr; }
}

@media (max-width: 620px) {
  :root {
    --gutter: var(--space-5);
    --nav-h: 64px;
    --section-y: var(--space-20);
    --section-y-lg: var(--space-24);
  }

  /* Brand + the three section links fill the bar here; the "Get Started"
     pill would force a wrap, and the hero CTA sits right below it. */
  .nav { gap: var(--space-3); }
  .nav__actions { display: none; }
  .nav__links > ul { gap: var(--space-4); }
  .nav__links a { font-size: 11px; letter-spacing: .06em; }
  .nav__brand { font-size: var(--text-2xs); }

  .hero__inner { padding-top: calc(var(--nav-h) + var(--space-16)); padding-bottom: var(--space-8); }
  .hero__cta { width: 100%; }
  .hero__cta .btn { flex: 1 1 auto; }

  /* 56vw of a phone is a 220px screen — too small to read as a website at
     all. The centre slide takes most of the width here and the neighbours
     simply run further off both edges, which is the same composition. */
  /* The centre slide is narrower here than the raw viewport fraction would
     suggest, and that is the point: at 78vw the neighbours peeked by 43px
     of a 304px slide — 14%, against ~52% on desktop — so the row read as
     one screen with slivers beside it rather than as a carousel. 62vw
     restores the desktop proportion at phone scale.

     The hero also stops claiming 100svh here. A phone cannot fit a screen
     tall enough to reach the fold *and* keep the neighbours in frame — the
     slide would need to be 611px wide on a 390px viewport. Left as a flex
     filler the stage just grew past the artwork and left dead cream under
     it, so its height comes off the slide's own aspect (620/800) instead,
     cropping the same fifth it crops on desktop. The next section peeking
     under the fold is a better scroll affordance than blank ground. */
  .stage__controls { gap: 0; }

  .hero { min-height: 0; }
  .hero__stage { --slide-w: min(40rem, 62vw, calc(var(--stage-max-h) * 16 / 9)); }

  .cards { grid-template-columns: 1fr; }
  .work { grid-template-columns: 1fr; gap: var(--space-10); }

  .newsletter__form input { width: 100%; }
  .newsletter__form .btn { width: 100%; }

  .footer { padding-block: var(--space-16) var(--space-10); }
  .footer__inner { align-items: flex-start; gap: var(--space-8); }
  .footer__meta { gap: var(--space-3) var(--space-6); }
}

/* Portrait viewports of any width. The stage takes the height left over
   under the copy, but a slide's height is fixed by the *viewport width*
   (slide-w × 620/800) — so on anything portrait the artwork cannot reach
   the fold and the stage grew past it, leaving dead cream underneath. At
   768×1024 that was 216px of nothing.

   The condition is the aspect ratio, not a width: 1024×768 clips correctly
   at the same width that fails at 768×1024. Portrait screens therefore
   size the stage from the slide and give up the full-height hero, exactly
   as phones already did — the next section peeking under the fold is a
   better scroll affordance than blank ground. */
@media (max-aspect-ratio: 1/1) {
  /* The full-height hero is KEPT on portrait, which reverses the cream
     original. That rule existed because the hero carried a 16:9 stage of
     screenshots that could not share 100svh with the copy without becoming
     unreadable. There is no stage here, and dropping the height had a cost
     the old layout never paid: the particle field is fixed to the viewport
     and centres on it, so a short hero puts the next section — which is
     opaque — straight through the middle of the diamond. A globe survives
     being half-covered by reading as a glow. A shape with cusps does not;
     it reads as clipped artwork.

     Landscape phones still give it up below, where there genuinely is no
     room for either. */
  .hero { min-height: 100svh; }
}

/* Landscape phones: the hero can't claim a whole 400px-tall viewport, and
   there is no room left for a stage under the copy — it would be a 60px
   sliver of screenshot. Drop it rather than clip it to nothing. */
@media (max-height: 520px) and (orientation: landscape) {
  .hero { min-height: 0; }
  .hero__inner { min-height: 0; padding-block: calc(var(--nav-h) + var(--space-10)) var(--space-12); }
  .hero__stage { display: none; }
}

/* ============================================================
   Let AI Run — storefront components
   Everything above this line is inherited from the portfolio and should
   stay in sync with it. Everything below exists because a storefront has
   three things a portfolio does not: a catalogue, a price list that is not
   a quote, and posts. Same rules apply — semantic tokens only, contrast
   measured not guessed, every motion rule has a reduced-motion escape.
   ============================================================ */

/* ── Two-column nav panel ─────────────────────────────────────
   .nav__panel--wide is 240px / 1fr / 260px, where the third column is a
   promo image. There are no product photographs yet, and an empty 260px
   grid column is not a neutral placeholder — it drags the panel's content
   off-centre under a trigger that is still centred on it. This variant is
   the same panel minus that column. Delete it and go back to --wide the
   day there is real product art to put there. */
.nav__panel--duo {
  grid-template-columns: 240px minmax(0, 1fr);
  width: min(760px, calc(100vw - var(--space-10) * 2));
}

/* ── Product grid ─────────────────────────────────────────────
   auto-fit rather than a fixed column count: the catalogue is three items
   today and an unknown number later, and a hard `repeat(3, 1fr)` turns a
   fourth product into a lonely orphan row. minmax's floor is 260px because
   below that the two-line titles start breaking mid-word. */
.prod-grid {
  display: grid;
  /* auto-FILL, not auto-fit. auto-fit collapses the empty tracks, so a kind
     holding a single product stretches that one card across the full 1100px
     container -- a 1100x180px slab that reads as a banner rather than a
     card. auto-fill keeps the tracks, so one product is a card-sized card
     with space beside it and three fill the row. The difference is invisible
     until a category has fewer items than columns, which on a new store is
     every category. */
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: var(--space-6);
  margin-top: var(--space-12);
  padding: 0;
  list-style: none;
}

/* The card is a link, not a div containing one: the whole surface is the
   target, which is what makes it work on touch without a 44px hit-area
   fight. Grid rather than flex so the footer row pins to the bottom and
   cards in a row end up the same height with unequal copy. */
.prod {
  display: grid;
  grid-template-rows: auto auto 1fr auto;
  gap: var(--space-3);
  padding: var(--space-7);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius-sm);
  background: var(--bg-raised);
  box-shadow: var(--shadow-1);
  color: inherit;
  text-decoration: none;
  transition: background var(--dur-base) var(--ease-out),
              border-color var(--dur-base) var(--ease-out),
              box-shadow var(--dur-base) var(--ease-out),
              transform var(--dur-base) var(--ease-out);
}

/* Cards rest at 1 and hover at 3 — the same two steps every other surface
   on this site uses. --lift, not a bare translateY: anything carrying
   .reveal has its transform driven by main.js, and writing `transform`
   here would be silently overridden on exactly the cards that scrolled
   into view. See the entrance-stagger note in CLAUDE.md. */
.prod { --lift: 0px; transform: translateY(var(--lift)); }
.prod:hover {
  --lift: -4px;
  background: var(--bg-raised-h);
  border-color: var(--border-strong);
  box-shadow: var(--shadow-3);
}
.prod:active { --lift: -1px; box-shadow: var(--shadow-2); }

.prod__kind {
  font-size: var(--text-2xs);
  font-weight: 500;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--accent-ink);
}
.prod__title {
  font-size: var(--text-xl);
  font-weight: 500;
  line-height: var(--leading-snug);
  letter-spacing: -.02em;
  color: var(--text-primary);
}
.prod:hover .prod__title { color: var(--accent-ink-hover); }
.prod__text {
  font-size: var(--text-md);
  line-height: var(--leading-body);
  color: var(--text-body);
}

.prod__foot {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-4);
  margin-top: var(--space-2);
  padding-top: var(--space-4);
  border-top: 1px solid var(--border-subtle);
}
/* Tabular figures so a column of prices lines up on the decimal instead of
   shimmying by a few pixels per glyph. */
.prod__price {
  font-variant-numeric: tabular-nums;
  font-size: var(--text-lg);
  font-weight: 500;
  color: var(--text-primary);
}
.prod__status {
  font-size: var(--text-xs);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-muted);
}

/* The example-entry notice. --danger rather than a muted grey precisely
   because it should be uncomfortable to leave on a live page. */
.prod--example { border-style: dashed; border-color: var(--danger); }
.prod__flag {
  font-size: var(--text-2xs);
  font-weight: 600;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--danger);
}

/* ── Notes index ──────────────────────────────────────────────
   A list, not cards. Posts are a stack of titles and dates that people
   scan top to bottom; putting each one in a box adds 200px of chrome per
   item and makes six posts look like a landing page for six products. */
.notes-list { margin-top: var(--space-12); padding: 0; list-style: none; }
.note-row { border-top: 1px solid var(--border-subtle); }
.note-row:last-child { border-bottom: 1px solid var(--border-subtle); }
.note-row a {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-2);
  padding: var(--space-7) 0;
  color: inherit;
  text-decoration: none;
  transition: color var(--dur-base) var(--ease-out);
}
@media (min-width: 720px) {
  .note-row a { grid-template-columns: 128px 1fr; gap: var(--space-8); align-items: baseline; }
}
.note-row__date {
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--text-muted);
}
.note-row__title {
  font-size: var(--text-2xl);
  font-weight: 400;
  line-height: var(--leading-snug);
  letter-spacing: -.03em;
  color: var(--text-primary);
  transition: color var(--dur-base) var(--ease-out);
}
.note-row a:hover .note-row__title { color: var(--accent-ink-hover); }
.note-row__sum {
  margin-top: var(--space-2);
  font-size: var(--text-md);
  line-height: var(--leading-body);
  color: var(--text-body);
}

/* ── Buy block ────────────────────────────────────────────────
   Sits at the end of a product page. --bg-band so it separates from the
   prose above it without needing a border of its own. */
.buy {
  display: grid;
  gap: var(--space-5);
  margin-top: var(--space-16);
  padding: var(--space-10);
  border-radius: var(--radius-sm);
  background: var(--bg-band);
}
.buy__price {
  font-variant-numeric: tabular-nums;
  font-size: var(--text-3xl);
  font-weight: var(--weight-display);
  letter-spacing: var(--track-display);
  color: var(--text-primary);
}
.buy__note { font-size: var(--text-sm); color: var(--text-muted); }
.buy .btn { justify-self: start; }

@media (prefers-reduced-motion: reduce) {
  .prod, .prod__title, .note-row a, .note-row__title { transition: none; }
  .prod:hover, .prod:active { --lift: 0px; }
}
