/* ==========================================================================
   home.css — v2 homepage sections

   Sizes and breakpoints here are read off the live reference build, which
   steps at Tailwind's stops (sm 640, md 768, lg 1024) rather than
   interpolating. Keep them stepped.
   ========================================================================== */

/* --- Section rhythm ------------------------------------------------------ */

.sec {
  border-bottom: 1px solid var(--border);
}

.sec__inner { padding-block: var(--space-3xl); }

@media (min-width: 1024px) {
  .sec__inner { padding-block: var(--space-4xl); }
}

/* Quietens the circuit ground under #about rather than blacking it out.
   The REASON is unchanged from when this covered the 48px grid: #about is
   the page's one human moment, portrait and first-person prose, and the
   hero's claim is "Human insight. Machine precision." - the ground is the
   machine half, so it should recede here. What changed is that a circuit
   trace at low alpha reads as depth, where a half-hidden grid line read as
   a mistake. That is why the old rule had to be fully opaque and this one
   does not; it is not a reversal of the earlier finding, the ground is a
   different thing now.

   --background is the same tone the ground sits on, so --veil changes only
   how much of the field survives. Compared at 100 / 88 / 76 / 0 against the
   real section: 100 read as a black slab once everything around it had a
   living ground, and 0 ran traces through the headline. 76 is visible
   enough to be legible as a choice without competing with the prose. */
.sec--solid {
  background: color-mix(in oklab, var(--background) var(--veil, 76%), transparent);
}

.sec__head { margin-bottom: var(--space-xl); }

.sec__title {
  margin-top: var(--space-md);
  font-size: var(--text-h2);
  font-weight: 600;
}

@media (min-width: 640px) {
  .sec__title { font-size: var(--text-h2-lg); }
}

.sec__lede {
  max-width: 62ch;
  margin-top: var(--space-md);
  font-size: var(--text-lead);
  line-height: var(--lh-relaxed);
  color: var(--muted-foreground);
  text-wrap: pretty;
}

.prose {
  color: var(--muted-foreground);
  line-height: var(--lh-relaxed);
  text-wrap: pretty;
}
.prose p + p { margin-top: var(--space-sm); }

/* --- Hero ---------------------------------------------------------------- */

.hero {
  position: relative;
  overflow: hidden;
  border-bottom: 1px solid var(--border);
}

/* dvh, not vh. On iOS and Android 100vh is the viewport with the browser
   toolbar RETRACTED, so on the one full-screen section of the page the hero
   overflowed by the toolbar's height and the first scroll was a jump rather
   than a scroll. The rest of this file already uses dvh; this was the
   inconsistency, not the policy. */
.hero__inner {
  display: flex;
  align-items: center;
  min-height: calc(100dvh - var(--nav-h));
  padding-block: var(--space-2xl);
}

.hero__panel { width: 100%; padding: var(--space-xl) var(--space-md); }

@media (min-width: 640px) { .hero__panel { padding-inline: 2.5rem; } }
@media (min-width: 1024px) {
  .hero__panel { padding: var(--space-2xl); }
}

/* The panel leaves a little faster than the page, so it separates from the
   48px grid ground instead of the two sliding off together as one flat slab.
   motion.js writes --hero-p from 0 to 1 across the hero's own exit.

   The travel is deliberately small. This is depth, not a departure: at 56px
   over a whole viewport of scrolling it is felt rather than noticed, and
   anything larger starts to look like the panel is falling off the page.

   On .hero__panel rather than an inner wrapper because the panel is the plane
   that should move. It also carries .hud's backdrop-filter, so if this ever
   shows edge artifacts or drops frames, move the transform inward before
   reaching for the blur — the separation is the point, the blur is the style. */
@media (prefers-reduced-motion: no-preference) {
  .js .hero__panel {
    transform: translate3d(0, calc(var(--hero-p, 0) * -56px), 0);
    will-change: transform;
  }
}

/* --- Panel boot ----------------------------------------------------------
   The panel comes up as a system rather than as a page: the frame strikes at
   full --signal, the two corner marks punch outward and settle, a bloom spikes
   and decays, and the word cascade below runs underneath it unchanged.

   Three things here are structural rather than styling:

   1. It is a ONE-SHOT. No `infinite` anywhere in this block, and the class is
      added exactly once and never removed. The page's ceiling of four ambient
      loops — scanline, hero dot, tool band, circuit field — is already spent,
      and this does not spend a fifth.

   2. The flare is on a pseudo-element, NOT on .hero__panel. The panel is
      already in the --hero-p beat's transform above, and index.html's rule is
      that no element may be in two animation systems at once: they fight over
      the same property. The flash owns ::after, the corners own their own
      transform, the beat keeps the panel's. Nothing overlaps.

   3. fill-mode is the default `none` and the resting state is opacity: 0, so
      the moment the animation ends the panel is exactly what .hud renders. The
      border never changes colour — the flare is drawn ON TOP of it at
      inset: -1px, which is the border box, rather than by recolouring it.
      There is no bright border to get stuck on, with or without JS, and no
      animationend handler to clean anything up.

   The class is written by initFlow() at the same instant it arms the cascade,
   behind document.fonts.ready — deliberately not an animation-delay here. On a
   cold load the face lands hundreds of milliseconds after parse, and a CSS
   delay would have flashed an empty panel and then filled it.
   ------------------------------------------------------------------------ */
.hero__panel::after {
  content: "";
  position: absolute;
  /* -1px, so this sits ON .hud's hairline instead of drawing a second line
     just inside it. A flare one pixel in reads as a nested box. */
  inset: -1px;
  pointer-events: none;
  border: 1px solid var(--signal);
  box-shadow: var(--glow-flare),
              inset 0 0 40px color-mix(in oklab, var(--signal) 6%, transparent);
  opacity: 0;
}

@media (prefers-reduced-motion: no-preference) {
  .hero__panel.is-booting::after {
    animation: hero-flare 600ms var(--ease-out);
  }

  /* Outward, then back. The pair moves on opposite diagonals so the panel
     reads as being sized rather than nudged. 7px is deliberately small: these
     are registration marks, and a big throw makes them look detached from the
     frame they are registering. translate, never width/height — the latter is
     a layout invalidation inside a backdrop-filtered panel, sixty times over.
     Not scale() either: it would scale the 2px border into a blur, which reads
     soft where this wants to read sharp. */
  .hero__panel.is-booting .corner--tl { animation: hero-corner-tl 600ms var(--ease-out); }
  .hero__panel.is-booting .corner--br { animation: hero-corner-br 600ms var(--ease-out); }
}

/* The strike is 7% of 600ms — about 42ms, three frames. Anything slower is a
   fade-in, and a fade-in is not a boot. The long tail is the decay. */
@keyframes hero-flare {
  0%   { opacity: 0; }
  7%   { opacity: 1; }
  30%  { opacity: .5; }
  100% { opacity: 0; }
}

@keyframes hero-corner-tl {
  0%   { transform: translate3d(0, 0, 0); }
  14%  { transform: translate3d(-7px, -7px, 0); }
  100% { transform: translate3d(0, 0, 0); }
}

@keyframes hero-corner-br {
  0%   { transform: translate3d(0, 0, 0); }
  14%  { transform: translate3d(7px, 7px, 0); }
  100% { transform: translate3d(0, 0, 0); }
}

/* The scan line. A 1px full-width gradient that sweeps the hero on a 5s
   loop — one of the page's three pieces of ambient motion, with the hero dot
   below and the tool band's drift. Fixed 52rem of travel,
   matching the reference, so it needs no measuring at runtime. */
.scanline {
  position: absolute;
  inset-inline: 0;
  top: 0;
  height: 1px;
  pointer-events: none;
  background: linear-gradient(90deg, transparent, var(--signal), transparent);
}

@media (prefers-reduced-motion: no-preference) {
  .scanline { animation: scan 5s var(--ease-in-out) infinite; }
}

@keyframes scan {
  0%, 100% { opacity: 0; transform: translateY(0); }
  12%      { opacity: .35; }
  88%      { opacity: .35; }
  100%     { opacity: 0; transform: translateY(52rem); }
}

/* Two badges wear this: the hero's "System online" and the closer's "Booking
   open". They are the same device on purpose — the page opens and closes on
   the same signal — so they share one rule and cannot drift apart. The
   contact copy lives in home.css's Contact block; only the shape is here. */
.hero__badge,
.contact__badge {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  padding: 6px var(--space-xs);
  border: 1px solid color-mix(in oklab, var(--primary) 50%, transparent);
  background: var(--signal-soft);
}

.hero__badge .label,
.contact__badge .label {
  font-size: var(--text-micro);
  font-weight: 700;
  letter-spacing: var(--track-widest);
  color: var(--signal);
}

.hero__dot,
.contact__dot {
  width: .5rem;
  height: .5rem;
  border-radius: var(--radius-full);
  background: var(--success);
  flex: none;
}

/* The pulse is the HERO's alone. .contact__dot shares the shape but stays
   still on purpose — two things blinking on one page is ambient decoration,
   and the live signal means something only where it is the first thing seen. */
@media (prefers-reduced-motion: no-preference) {
  .hero__dot { animation: pulse 2s cubic-bezier(.4, 0, .6, 1) infinite; }
}

@keyframes pulse { 50% { opacity: .5; } }

.hero__title {
  max-width: 15ch;
  margin-top: var(--space-lg);
  font-size: var(--text-h1);
  font-weight: 700;
  line-height: var(--lh-display);
  text-transform: uppercase;
  text-wrap: balance;
}
.hero__title em { font-style: normal; color: var(--signal); }

@media (min-width: 640px)  { .hero__title { font-size: 4.5rem; } }
@media (min-width: 1024px) { .hero__title { font-size: 6rem; } }

.hero__lede {
  max-width: 62ch;
  margin-top: 1.75rem;
  font-size: var(--text-lead);
  line-height: var(--lh-relaxed);
  color: var(--muted-foreground);
  text-wrap: pretty;
}

@media (min-width: 640px) { .hero__lede { font-size: 1.25rem; } }

.hero__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-sm);
  margin-top: 2.25rem;
}

/* --- Short viewports: close the frame ------------------------------------
   The panel is a fixed ~786px of content, so on anything under about 965px of
   viewport it ran past the fold and the bottom-right corner mark went with it.
   .hud states its frame with a PAIR of corners; showing only the top-left
   leaves the device half-said, so it reads as overflow rather than as a crop.
   That happens at exactly the heights most laptops have.

   The h1 steps back to 4.5rem, which is the scale's OWN second stop, not a new
   value invented for this - the type scale is stepped and a clamp() between
   stops would be visibly wrong. Tall screens keep the 6rem.
   ------------------------------------------------------------------------ */

@media (max-height: 980px) {
  .hero__inner { padding-block: var(--space-lg); }
}

@media (min-width: 1024px) and (max-height: 980px) {
  .hero__title { font-size: 4.5rem; }
  .hero__panel { padding: var(--space-xl); }
}

/* A second step for 13-inch laptops, where the first one still overran. The
   rhythm inside the panel tightens rather than the type dropping again. */
@media (max-height: 860px) {
  .hero__actions { margin-top: var(--space-md); }
  .stats { margin-top: var(--space-md); }
  .hero__lede { margin-top: var(--space-sm); }
}

/* Third step, for a short window on a laptop - a 740px viewport is a 900px
   screen with a toolbar, a bookmarks bar and a dock. Nothing is removed; the
   stat cells just stop carrying square padding. */
@media (max-height: 800px) {
  .hero__panel { padding-block: var(--space-lg); }
  .stat { padding: var(--space-2xs) var(--space-sm); }
  .hero__inner { padding-block: var(--space-sm); }
}

/* --- Stat row ------------------------------------------------------------ */

.stats {
  grid-template-columns: repeat(2, 1fr);
  margin-top: var(--space-xl);
}

@media (min-width: 640px) {
  .stats { grid-template-columns: repeat(4, 1fr); }
}

.stat { padding: var(--space-sm); }

.stat__num {
  margin-top: var(--space-2xs);
  font-family: var(--font-display);
  font-size: 1.5rem;
  font-weight: 600;
  line-height: 1.3;
  color: var(--foreground);
}
.stat__num--sm { font-size: var(--text-lead); }

/* --- The turn ------------------------------------------------------------
   One sentence, one viewport. This is the page's only full-height block of
   type and the emphasis comes from the ground around it, not from a larger
   font: the line reuses the h1 stops and stops one step BELOW the hero, so
   the hero stays the biggest thing on the page.

   No border-bottom. .sec draws one; this reads as open ground between the
   hero and the deck rather than another boxed row.
   ------------------------------------------------------------------------ */

.statement {
  position: relative;
  display: flex;
  align-items: center;
  /* dvh for the same reason .hero__inner uses it: on mobile 100vh is the
     viewport with the toolbar retracted, and this is the second full-height
     block the reader meets. */
  min-height: 78dvh;
  padding-block: var(--space-4xl);
}

.statement__in {
  width: 100%;
  text-align: center;
}

.statement__line {
  max-width: 20ch;
  margin: 0 auto;
  font-family: var(--font-display);
  font-size: var(--text-h1);
  font-weight: 600;
  line-height: var(--lh-display);
  /* The line has to break into two or three stacked lines or the whole
     gesture is lost — balance keeps those lines even instead of leaving one
     orphan word under a full-width first line. */
  text-wrap: balance;
}

@media (min-width: 640px) {
  /* 4.5rem is the h1 scale's SECOND stop, taken verbatim. The scale is
     stepped, not fluid — do not clamp() a new value in between, and do not
     take the third stop (6rem): that one belongs to the hero. */
  .statement__line { font-size: 4.5rem; }
}

.statement__sub {
  /* Narrow, and set well clear of the line above it. Against 72px display
     type a 54ch measure sat too close and too wide to read as a caption to
     the statement rather than as the next paragraph. */
  max-width: 52ch;
  margin: var(--space-xl) auto 0;
  font-size: var(--text-lead);
  line-height: var(--lh-relaxed);
  color: var(--muted-foreground);
  text-wrap: pretty;
}

/* inherits: TRUE, unlike --deck-p. Every word span reads this one number to
   work out its own state, so it has to reach them, and the subtree it
   invalidates is a few dozen spans - the trade being made on purpose.

   initial-value is 1, not 0, so anything this is never written to renders
   FINISHED rather than blank: no JS, no runway at this width, reduced motion.
   A scrub system that defaults to invisible is one bug away from a blank
   page. */
@property --scrub-p {
  syntax: "<number>";
  inherits: true;
  initial-value: 1;
}

/* --- The pin ---
   The section becomes a runway and .statement__pin holds still inside it.
   Desktop and motion-allowed only: on a phone a pinned screen costs the
   reader two flicks to get past one sentence, and under reduced motion the
   whole point of it is gone.

   160vh leaves about 0.6 of a screen of scrolling to assemble the sentence
   once the pin's own height is taken out - roughly six notches of a wheel.
   220vh was tried first and read as slow: the words light up per notch of
   scroll, so a longer runway does not make the effect bigger, it makes the
   same effect arrive more slowly. Scroll distance before a visitor reaches
   any proof of the work has a cost on a page meant to win clients. */
@media (min-width: 900px) and (prefers-reduced-motion: no-preference) {
  .js .statement {
    height: 160vh;
    min-height: 0;
    padding-block: 0;
    display: block;
  }

  .js .statement__pin {
    position: sticky;
    top: var(--nav-h);
    height: calc(100dvh - var(--nav-h));
    display: flex;
    align-items: center;
  }
}

/* --- Scrubbed words ---
   Each word derives its own state from ONE number. --w is 0 before the
   scroll reaches this word and 1 after, ramping across it:

     progress x wordCount - thisWordsIndex,  clamped to 0..1

   so at --scrub-p 0.5 with 8 words, word 4 is mid-ramp and everything before
   it is lit. No per-word JS, no timers, and it runs backwards correctly when
   the reader scrolls up because the value is read from position.

   --n is written per element by initScrub(); the fallbacks make an unscripted
   page render fully lit. */
.js [data-scrub] .fw {
  --w: clamp(0, calc(var(--scrub-p, 1) * var(--n, 1) - var(--i, 0)), 1);
  opacity: calc(.14 + var(--w) * .86);
  filter: blur(calc((1 - var(--w)) * 5px));
  will-change: opacity, filter;
}

/* --- Scrubbed LINES ---
   The second heading resolves a line at a time rather than a word at a time.
   Identical arithmetic with --li/--ln in place of --i/--n, so a whole line
   shares one state and the heading reads as a block coming into focus
   instead of as a ticker. The blur starts deeper (8px) because a whole line
   softening needs to be unmistakably out of focus to be worth watching. */
.js [data-scrub-lines] .fw {
  --w: clamp(0, calc(var(--scrub-p, 1) * var(--ln, 1) - var(--li, 0)), 1);
  opacity: calc(.1 + var(--w) * .9);
  filter: blur(calc((1 - var(--w)) * 8px));
  will-change: opacity, filter;
}

/* The tail waits for the lines above it to finish, then comes up as one
   piece over the last quarter of the runway. */
.js [data-scrub-tail] {
  opacity: clamp(0, calc((var(--scrub-p, 1) - .72) * 3.6), 1);
  transform: translate3d(0, calc((1 - clamp(0, calc((var(--scrub-p, 1) - .72) * 3.6), 1)) * 12px), 0);
}

/* The eyebrow leads instead of trailing - it is the chapter number, so it
   should be legible before the thing it numbers. */
.js [data-scrub-tail-early] {
  opacity: clamp(.25, calc(var(--scrub-p, 1) * 5), 1);
}

/* --- The deck head's runway ---
   Its own pin ahead of the cards, so the head finishes resolving before the
   first card rides up under it. Shorter than the statement's runway on
   purpose: this is a signpost saying the capabilities are next, where the
   statement is the turn the whole page pivots on.

   It sits OUTSIDE .deck__stack, so the cards' own sticky offsets are
   untouched - see _deckcheck.mjs, which asserts all six of them. */
@media (min-width: 900px) and (prefers-reduced-motion: no-preference) {
  .js .deckhead { height: 140vh; }

  .js .deckhead__pin {
    position: sticky;
    top: var(--nav-h);
    height: calc(100dvh - var(--nav-h));
    display: flex;
    align-items: center;
    justify-content: center;
  }

  /* The head carries its own bottom margin at every other width; pinned, the
     runway below it is the spacing. */
  .js .deckhead .sec__head { margin-bottom: 0; }
}

/* A phone, a reduced-motion reader and a JS-less browser all get the finished
   state rather than a half-lit one. */
/* A phone, a reduced-motion reader and a JS-less browser all get the
   finished state rather than a half-lit one. Two queries rather than one
   list, because they are two different reasons. */
@media (prefers-reduced-motion: reduce) {
  .js [data-scrub] .fw,
  .js [data-scrub-lines] .fw { opacity: 1; filter: none; }
  .js [data-scrub-tail],
  .js [data-scrub-tail-early] { opacity: 1; transform: none; }
}
@media (max-width: 899px) {
  .js [data-scrub] .fw,
  .js [data-scrub-lines] .fw { opacity: 1; filter: none; }
  .js [data-scrub-tail],
  .js [data-scrub-tail-early] { opacity: 1; transform: none; }
}

/* --- Centred section head ------------------------------------------------
   The deck's head answers the statement above it, so it is centred to match.
   Only .deck wears this; every other section head stays ranged left. */

.sec__head--mid { text-align: center; }

/* Both have a max-width and will sit hard left inside their own box without
   this, which centres the text and not the block it lives in. */
.sec__head--mid .sec__title,
.sec__head--mid .sec__lede { margin-inline: auto; }

/* 62ch is a left-ranged measure. Centred, it rags badly and drops a two-word
   last line; 52ch breaks it evenly under the title. */
.sec__head--mid .sec__lede { max-width: 52ch; }

/* --- About ---------------------------------------------------------------
   Two columns on desktop with the portrait leading, not a full-bleed band.
   ------------------------------------------------------------------------ */

.about__grid {
  display: grid;
  gap: var(--space-lg);
}

@media (min-width: 1024px) {
  .about__grid {
    grid-template-columns: 0.85fr 1.15fr;
    align-items: center;
    gap: 2.5rem;
  }
}

/* The portrait is a cut-out with a transparent ground, so the ground is built
   HERE rather than baked into the pixels. That is what lets the bloom behind
   him use the live --signal token and answer a pointer.

   --paper-sunken, not #000: he is photographed in a black suit, and on a
   truly black field the jacket dissolves into the page and only a head and
   two hands are left floating. A near-black keeps the silhouette. */
.about__figure {
  position: relative;
  margin: 0;
  border: 1px solid var(--border);
  background: var(--paper-sunken);
  overflow: hidden;
  isolation: isolate;
}

/* The bloom, behind him. Sized and placed to sit around the head and
   shoulders rather than centred in the frame, which would put the brightest
   part of it behind the jacket where it does nothing. */
.about__figure::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 0;
  background: radial-gradient(
    60% 45% at 50% 32%,
    color-mix(in oklab, var(--signal) 22%, transparent),
    transparent 70%
  );
  transition: opacity var(--dur-slow) var(--ease-in-out);
}

/* The rim, ON him. The bloom behind was not enough on its own: he is in a
   black suit on a ground that is nearly black, so the jacket dissolved into
   the frame and all that read was a head and two hands. This traces the
   silhouette instead of lighting the space around it.

   It works because the WebP is an alpha CUT-OUT and the ground is painted in
   CSS — drop-shadow follows the alpha channel, so it draws the edge of the
   man rather than the edge of the box. Two shadows, not one: the tight one
   is the edge itself and the wide one is its falloff, which is what stops it
   reading as a sticker outline.

   Both are --signal, so the rim joins the bloom's light family rather than
   introducing a second colour. Radii stay small deliberately — see the JPEG
   note below, and a large-radius drop-shadow on a 1120px image is a real
   cost to rasterise even once. */
.about__img {
  position: relative;
  z-index: 1;
  width: 100%;
  height: auto;
  aspect-ratio: 4 / 5;
  object-fit: cover;
  object-position: top;
  filter:
    drop-shadow(0 0 1px color-mix(in oklab, var(--signal) 55%, transparent))
    drop-shadow(0 0 9px color-mix(in oklab, var(--signal) 28%, transparent));
  transition: filter var(--dur-slow) var(--ease-in-out);
}

/* The JPEG fallback is flattened onto the ground by _portrait-v2.mjs, so it
   has no alpha and these shadows trace its rectangle instead of his edge.
   That path is only reached by a browser without alpha WebP, which in
   practice is none — but it is why the radii are 1px and 9px rather than the
   20px that would look better on the cut-out. At this size the degradation
   reads as a soft vignette on the frame, not as a glowing box. If that ever
   stops being true, bake the rim into the JPEG in _portrait-v2.mjs and scope
   this rule to the WebP instead. */

/* Hover brightens the light rather than restoring the colour — the photo is
   already in colour, so the old grayscale(1) -> grayscale(0) move had
   nothing left to reveal. The rim lifts with the bloom so the two read as
   one light coming up, not as two separate things reacting. */
.about__figure:hover::before { opacity: 1.6; }
.about__figure:hover .about__img {
  filter:
    drop-shadow(0 0 1px color-mix(in oklab, var(--signal) 75%, transparent))
    drop-shadow(0 0 14px color-mix(in oklab, var(--signal) 40%, transparent));
}

@media (prefers-reduced-motion: reduce) {
  .about__figure::before { transition: none; }
  .about__img { transition: none; }
}

/* Above the portrait, not under it. The figure is a three-layer stack now —
   bloom (0), cut-out (1), caption (2) — and the caption is a chip ON the
   photograph, so it has to clear the image that the bloom sits behind. */
.about__cap {
  position: absolute;
  z-index: 2;
  left: var(--space-sm);
  bottom: var(--space-sm);
  padding: var(--space-3xs) var(--space-2xs);
  border: 1px solid var(--border);
  background: color-mix(in oklab, var(--background) 80%, transparent);
  backdrop-filter: blur(6px);
  color: var(--signal);
}

.about__title {
  margin-top: var(--space-md);
  font-size: var(--text-h2);
  font-weight: 600;
}

@media (min-width: 640px) { .about__title { font-size: var(--text-h2-lg); } }

.about__body { margin-top: var(--space-md); }

.traits {
  grid-template-columns: 1fr;
  margin-top: var(--space-lg);
}

@media (min-width: 640px) { .traits { grid-template-columns: 1fr 1fr; } }

.trait { padding: var(--space-sm); }

.trait__val {
  margin-top: var(--space-2xs);
  font-family: var(--font-display);
  font-size: var(--text-h3);
  font-weight: 600;
  color: var(--foreground);
}

/* --- Testimonials ----------------------------------------------------------
   DORMANT, NOT DEAD. Nothing on the page uses these right now - the field
   reports section is commented out in index.html because the quotes in it were
   fabricated. They are kept so that the first REAL testimonial is an HTML-only
   change: paste the template from that block into the reserved quote slot in
   the contact section and it is already styled. Do not strip these in a
   cleanup pass. ------------------------------------------------------------ */

.quotes { grid-template-columns: 1fr; }

@media (min-width: 768px) { .quotes { grid-template-columns: 1fr 1fr; } }

.quote { padding: var(--space-lg) var(--space-md); }

.quote__text {
  font-size: var(--text-lead);
  line-height: var(--lh-relaxed);
  color: var(--foreground);
  text-wrap: pretty;
}

.quote__who { margin-top: var(--space-md); font-weight: 500; }

.quote__org { margin-top: 2px; }

/* --- Contact ---------------------------------------------------------------
   The one centred section on the page. It earns that by closing the loop the
   hero opens: same badge device, same accent on the heading. Below 640px it
   goes back to flush left — centred ragged copy on a narrow column reads as a
   mistake, and the reference does the same thing.
   ------------------------------------------------------------------------ */

.contact__cta { max-width: 44rem; }

@media (min-width: 640px) {
  .contact__cta { margin-inline: auto; text-align: center; }
}

.contact__title {
  margin-top: var(--space-md);
  font-size: var(--text-h2);
  font-weight: 600;
  text-wrap: balance;
}
/* Same device as .hero__title em. Used once, on the payoff half of the line. */
.contact__title em { font-style: normal; color: var(--signal); }

@media (min-width: 640px) { .contact__title { font-size: var(--text-h2-lg); } }

.contact__lede {
  max-width: 56ch;
  margin-top: var(--space-md);
  font-size: var(--text-lead);
  line-height: var(--lh-relaxed);
  color: var(--muted-foreground);
  text-wrap: pretty;
}

@media (min-width: 640px) { .contact__lede { margin-inline: auto; } }

.contact__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
  margin-top: 2.25rem;
}

@media (min-width: 640px) { .contact__actions { justify-content: center; } }

.contact__meta { margin-top: var(--space-md); }

/* --- Booking ---------------------------------------------------------------
   The frame is a bezel, not a card: a hairline HUD panel with just enough
   padding to read as the screen surround for whatever Calendly paints inside.
   ------------------------------------------------------------------------ */

.booking {
  max-width: 48rem;
  margin-top: var(--space-2xl);
  margin-inline: auto;
}

.booking__frame { padding: var(--space-xs); }

/* Chrome for the embed: a hairline-separated caption strip, so the white pane
   under it reads as a screen this page is showing rather than a gap in it. The
   rule is what does the work — it gives the widget a top edge that belongs to
   us, since the widget's own canvas does not. */
.booking__cap {
  display: block;
  padding: var(--space-3xs) var(--space-2xs) var(--space-xs);
  border-bottom: 1px solid var(--border);
  margin-bottom: var(--space-xs);
  color: var(--signal);
}

/* One height, no breakpoint — measured, not guessed. Calendly's own content
   is 830px at 390 and 878px at 768 and up, and picking a date does not grow
   it (the time list scrolls inside the widget's own pane), so 900 clears
   every case with a little slack. A shorter box is what puts that internal
   scrollbar on the calendar. */
/* Calendly's snippet ships min-width:320px. It is dropped on purpose: inside
   a 1.5rem-gutter wrap plus the frame's own padding there is only ~303px at
   390px wide, so that floor makes the widget spill past the panel border it
   is supposed to sit inside. min-width:0 is the same rule the galleries need
   for the same reason. */
.calendly-inline-widget {
  min-width: 0;
  width: 100%;
}

/* The tall box is reserved only when something can actually fill it. With JS
   off, or when widget.js fails to load, the frame collapses around the
   fallback button instead of fencing off 900px of empty panel. Same .js gate
   the reveals and the archive detail use. */
.js .calendly-inline-widget { height: 900px; }
.js .calendly-inline-widget.is-unavailable { height: auto; }

/* Nothing inside Calendly's document paints the white ground — its html and
   body are both transparent. The white is the iframe CANVAS, painted by the
   browser for an embedded document whose color-scheme resolves light, and it
   is not reachable from this side: color-scheme on the iframe and a
   background on the iframe element both leave it white. See the note in
   index.html for why the widget is therefore left light rather than dark. */

/* Only ever seen if the widget does not arrive. motion.js sets [hidden] on it
   once an iframe actually lands in the div — and because display:flex would
   otherwise outbid the UA's [hidden] { display: none }, the reset below is
   what makes that work at all. */
.booking__fallback {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100%;
  padding: var(--space-lg);
}
.booking__fallback[hidden] { display: none; }

/* Stays FULLY opaque, unlike #about above. This is a panel, not a ground:
   its fill exists so it reads as a panel rather than as a patch, and that
   reason is unaffected by what is drawn behind it. Letting traces through a
   box this small makes it look weak rather than layered. The padding is not
   decoration either - without it the fill stops hard on the text's own box
   and the patch is back. */
.booking__alt {
  margin-top: var(--space-lg);
  padding: var(--space-md);
  background: var(--background);
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}

.booking__altlead { margin-bottom: var(--space-sm); }

@media (min-width: 640px) { .booking__alt { text-align: center; } }

.channels { list-style: none; }
.channels li + li { margin-top: var(--space-xs); }

.channels a, .channels span {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2xs);
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  color: var(--muted-foreground);
  text-decoration: none;
  transition: color var(--dur-fast) var(--ease-in-out);
}
.channels a:hover { color: var(--signal); }

/* Wide enough for one line: a slash-joined row, the concept's own separator.
   The slash is a ::before on every item but the first, so it cannot end up
   dangling after the last one. */
@media (min-width: 640px) {
  .channels {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--space-2xs) var(--space-sm);
  }
  .channels li + li { margin-top: 0; }
  .channels li { display: flex; align-items: center; gap: var(--space-sm); }
  .channels li + li::before {
    content: "//";
    font-family: var(--font-mono);
    font-size: var(--text-sm);
    color: var(--signal);
  }
}

/* ==========================================================================
   02 Capabilities — sticky stacking deck

   BASE STATE, always on: six bordered cards in ordinary flow under an
   ordinary section head. The deck is layered on at 900px, where the card body
   becomes two columns and there is room for a card to sit below the nav
   without swallowing the viewport.

   Unlike the pinned carousel this replaced, NOTHING here needs JS to lay out.
   The stack sticks, stacks and reads correctly with the script blocked;
   motion.js only adds the depth. That is why the structure carries no `.js`
   prefix and why there is no cleanup branch anywhere — see --deck-p below.

   The card INTERIOR keeps its svcx__ class names. Six hand-authored SVG
   diagrams and a hundred lines of figure styling moved from the carousel
   without a diff, and renaming them would have been a rewrite pretending to
   be a rename.
   ========================================================================== */

/* Declared so that writing it does not invalidate the style of the card's
   whole subtree. Each card holds a ~30-node diagram, and an INHERITED custom
   property written sixty times a second across six cards is six subtree
   invalidations a frame. `inherits: false` makes it one element each.

   Where @property is unsupported, var(--deck-p, 0) renders the same frame. */
@property --deck-p {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}

/* Non-inheriting for the same reason --deck-p is, and the reason bites harder
   here: each archive card holds a full .prj-detail subtree with a six-frame
   gallery in it, so an inheriting property written sixty times a second on an
   ancestor of all six would be six subtree invalidations every frame. It is
   written straight onto .prjmark__fill instead, which has no styled
   descendants at all — that is what data-runway-target is for.

   initial-value 0 is safe here ONLY because .prjmark is display:none whenever
   nothing writes to it: no JS, reduced motion. An unwritten rail is never
   shown, so it cannot render as a dead empty control. That is the same lesson
   --scrub-p learned, solved by hiding rather than by defaulting to finished,
   because a full rail on arrival would be its own kind of lie. */
@property --prj-p {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}

.deck { border-bottom: 1px solid var(--border); }

.deck__in { padding-block: var(--space-3xl); }
@media (min-width: 1024px) { .deck__in { padding-block: var(--space-4xl); } }

.deck__stack { list-style: none; }

/* Opaque, deliberately NOT .hud. These cards overlap by design, so a panel at
   --panel's 88% alpha would show the card underneath through the live one's
   paragraph, and six stacked backdrop-filters is the most expensive thing you
   can ask of a compositor while the page is moving. The bloom stays where it
   already lives, on the lit .svcx__figure. */
.deck__card {
  position: relative;
  padding: var(--space-lg);
  border: 1px solid var(--border);
  background: var(--card);
}
.deck__card + .deck__card { margin-top: var(--space-md); }

.svcx__id {
  font-family: var(--font-mono);
  font-size: var(--text-label);
  font-weight: 700;
  letter-spacing: var(--track-label);
  color: var(--signal);
}

.svcx__head {
  margin-top: var(--space-xs);
  font-size: var(--text-h3);
  font-weight: 600;
}

.svcx__rule { display: none; }
.svcx__body { margin-top: var(--space-md); }

/* The figure is the one framed box on a capability card, and the circuit
   corners hang off it — so it owns the trace colour, and the four corners read
   it through currentColor. Same reason the diagram nodes below carry colour on
   `color`: one declaration cannot leave part of the artwork behind.

   Dim by default. The lit state is a colour change, not a second palette. */
.svcx__figure {
  position: relative;
  border: 1px solid var(--border);
  background: var(--card);
  padding: var(--space-sm);
  color: color-mix(in oklab, var(--signal) 45%, transparent);
  transition: color var(--dur-base) var(--ease-in-out),
              border-color var(--dur-base) var(--ease-in-out),
              box-shadow var(--dur-base) var(--ease-in-out);
}

/* Lit: the card the reader is actually on — the one currently pinned at the
   sticky line — or a hovered/focused card anywhere. The bloom is .hud's value
   verbatim (site.css) so the figure joins the hero panel's light family
   instead of inventing a second glow.

   There is deliberately no ambient animation HERE. This one answers the
   reader's own scroll or pointer, which is what earns it a place next to the
   band below. */
.deck__card.is-live .svcx__figure,
.svcx__figure:hover,
.deck__card:focus-within .svcx__figure {
  color: var(--signal);
  border-color: color-mix(in oklab, var(--signal) 55%, var(--border));
  box-shadow: var(--glow);
}

/* One authored top-left corner (symbol in index.html), rotated into the other
   three. Rotation on a square box keeps every corner pixel-identical, which a
   flipped or separately-drawn corner would not. The plain 1px border carries
   the edges between them — the traces are a corner device, not a perimeter. */
.fcorner {
  position: absolute;
  width: 4rem;
  height: 4rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.25;
  pointer-events: none;
}
.fcorner--tl { top: 0; left: 0; }
.fcorner--tr { top: 0; right: 0; transform: rotate(90deg); }
.fcorner--br { right: 0; bottom: 0; transform: rotate(180deg); }
.fcorner--bl { bottom: 0; left: 0; transform: rotate(270deg); }

/* On a phone the figure is ~340px wide; a 64px corner on each end leaves the
   diagram almost nothing. */
@media (max-width: 639px) {
  .fcorner { width: 2.5rem; height: 2.5rem; }
}

.svcx__dia { width: 100%; height: auto; }

/* One grammar for all six diagrams: outlined nodes, thin wires, and exactly
   one accented element per figure — the thing that decides, or the thing the
   others resolve into.

   The glyphs inside the nodes are DRAWN here, not pulled from the tool sprite.
   They were brand logos until the capabilities section was made tool-free; the
   marks still exist and are still used, but only in the tools band, which is
   the one place on the page that is about the tools.

   Node colour rides on `color` and the glyphs read it through currentColor, so
   a glyph cannot fall out of step with the node holding it. That matters now in
   a way it did not when these were logos: the same mark appears as the key node
   in one figure and as a plain node in another, and hardcoding the accent is
   how a figure silently ends up with two accented elements. */
.dia-n { color: var(--muted-foreground); }
.dia-n--key { color: var(--signal); }

/* Direct-child selectors: the node's own box, never a rect inside a glyph. */
.dia-n > rect { fill: var(--secondary); stroke: var(--border); stroke-width: 1; }
.dia-n--key > rect { fill: color-mix(in oklab, var(--signal) 12%, transparent); stroke: var(--signal); }

/* Glyphs: outlined by default, .dia-solid for the filled parts of one. */
.dia-g { fill: none; stroke: currentColor; stroke-width: 1.25; stroke-linecap: round; stroke-linejoin: round; }
.dia-g .dia-solid { fill: currentColor; stroke: none; }

.dia-w { fill: none; stroke: var(--draft-soft); stroke-width: 1.5; }
.dia-loop { stroke-dasharray: 4 4; }
.dia-s rect { fill: var(--secondary); stroke: var(--border); stroke-width: 1; }
.dia-fill { fill: var(--signal); stroke: none; }

.svcx__text p { margin-top: var(--space-md); color: var(--muted-foreground); text-wrap: pretty; }

@media (min-width: 900px) {
  .deck__card { padding: var(--space-xl); }

  .svcx__head { font-size: var(--text-h2); }

  /* A hairline under the heading, only where the card is wide enough for the
     head and the body to read as two separate registers. */
  .svcx__rule {
    display: block;
    height: 1px;
    margin-top: var(--space-md);
    background: var(--border);
  }

  .svcx__body {
    display: grid;
    grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
    gap: var(--space-2xl);
    align-items: center;
    margin-top: var(--space-lg);
  }

  .svcx__text p { margin-top: 0; font-size: var(--text-lead); line-height: var(--lh-relaxed); }

  /* Alternated by POSITION, never by DOM order: the reading order stays
     figure-then-text on all six, so a screen reader never hears a layout
     decision.

     The track sizes mirror along with the placement. Without that second
     rule the figure lands in the 6fr column on even cards and the six
     diagrams render at two different scales down the stack. */
  .deck__card:nth-child(even) .svcx__body   { grid-template-columns: minmax(0, 6fr) minmax(0, 5fr); }
  .deck__card:nth-child(even) .svcx__figure { grid-column: 2; grid-row: 1; }
  .deck__card:nth-child(even) .svcx__text   { grid-column: 1; grid-row: 1; }
}

/* --- The stack itself ---------------------------------------------------
   Six cards sticky to the same offset. Each one settles back — a little
   smaller, a little darker — as the next rides up over it, so six
   capabilities read as depth rather than as a list.

   Everything that makes this move lives inside this query. Below 900px or
   under reduced motion the base rules above are the entire rendering: six
   bordered, auto-height, single-column cards in ordinary flow. That is why
   there is no `transform: none !important` override anywhere and nothing for
   JS to strip — a stale --deck-p is inert, because the rule that reads it is
   simply not applied.
   ------------------------------------------------------------------------ */
@media (min-width: 900px) and (prefers-reduced-motion: no-preference) {
  .deck__stack {
    /* Derived, not chosen: the tallest card's content at THIS breakpoint is
       the SYS_A2 paragraph at ~445px including padding, and 512 clears it
       with about 20px of slack. The reference this came from used 628px,
       which left a third of every card empty and cost 700px of dead scroll.

       Change this and re-screenshot all six at 900, 1024, 1280 and 1440:
       `overflow: clip` below turns an under-estimate into a silently
       truncated line of copy. */
    --deck-h: 32rem;
  }

  .deck__card {
    position: sticky;
    /* Must clear the sticky nav. Flush at var(--nav-h) welds the card's top
       border to the nav's bottom border and the card reads as vanishing into
       the chrome rather than sliding under it. */
    top: calc(var(--nav-h) + var(--space-md));
    height: var(--deck-h);

    /* clip, not hidden: `hidden` makes the card a programmatically
       scrollable box, so focus landing inside a clipped card scrolls it
       internally and the content never comes back. */
    overflow: clip;

    /* Shrink from the pinned edge, so the one edge the reader is watching
       stays exactly where it is and only the far edge moves. */
    transform-origin: 50% 0;

    /* NO transition. This is scrubbed straight from the scroll, and a
       transition would make every frame chase the last by its own duration.

       No z-index either: later siblings paint over earlier ones by DOM
       order, which IS the effect. Adding one inverts the stack. */
    transform: scale(calc(1 - var(--deck-p, 0) * .05));
    filter: brightness(calc(1 - var(--deck-p, 0) * .45));

    margin-bottom: var(--space-xl);
  }

  .deck__card + .deck__card { margin-top: 0; }
  .deck__card:last-child { margin-bottom: 0; }

  /* Six promoted layers at roughly 1150x512 is ~14MB of compositor memory to
     hold permanently for a section the reader passes once. motion.js toggles
     .is-near when the stack is within a viewport and a half of the fold.

     If this ever costs frames, delete the declaration before touching the
     scrub: brightness is a colour matrix, not a sampling kernel, and at
     --deck-p 0 it is brightness(1) and free. */
  .js .deck.is-near .deck__card { will-change: transform, filter; }
}

/* ==========================================================================
   Tools band
   ========================================================================== */

.tools {
  border-bottom: 1px solid var(--border);
  background: color-mix(in oklab, var(--background) 80%, transparent);
  padding-block: var(--space-3xl);
}

/* Left, not centred. The centring made sense when the band below was a
   full-bleed marquee with no left edge to answer to; against a contained,
   left-aligned group grid it just orphans the heading. It also leaves the
   contact closer as the page's single centred moment, which is worth more
   as one deliberate change of tempo than as one of two. */
.tools__head { margin-bottom: var(--space-xl); }

.tools__title {
  max-width: 24ch;
  font-size: var(--text-h2);
  font-weight: 600;
}

@media (min-width: 640px) { .tools__title { font-size: var(--text-h2-lg); } }

/* Two rows, drifting opposite ways, ungrouped.

   The category headings are gone deliberately. They were carrying the claim
   that the grouping told a reader something — which parts of an operation
   this practice reaches — and it does not: anyone who recognises these marks
   already knows n8n automates and Stripe takes money. What the band is
   actually for is recognition at a glance, and sorting a wall of familiar
   logos into labelled bins slows that down.

   The drift is the page's THIRD ambient animation, against a rule that used
   to cap it at two — the hero scanline and the status dot. That rule is
   changed, not broken: a wall of logos holding perfectly still reads as a
   screenshot of a wall of logos, and this one is not a timer running in the
   corner of the eye. It answers the reader (motion.js adds their scroll
   velocity on top of the drift, so scrolling up reverses it) and an
   IntersectionObserver stops it dead whenever the band is off-screen, which
   is most of the page. */
.toolband {
  display: grid;
  gap: var(--space-sm);
}

/* The clip box. Marks run off both edges rather than stopping at the
   container, because a marquee with visible ends is a list that moves.

   min-width: 0 is load-bearing and not obvious. This is a grid item, and a
   grid item's automatic minimum size is its CONTENT's size — so the row grew
   to its max-content track (1952px) and pushed the whole document sideways,
   giving the page a horizontal scrollbar at every width and a 1952px-wide
   body at 375px. `overflow: clip` does not save you from that; it clips what
   is inside the box, it does not stop the box itself from growing. */
.toolrow {
  min-width: 0;
  overflow: clip;
  /* Fade both ends into the section ground so marks arrive and leave rather
     than popping at a hard edge. */
  mask-image: linear-gradient(90deg, transparent, #000 6rem, #000 calc(100% - 6rem), transparent);
}

/* The track holds one authored set of marks; motion.js clones it to fit the
   row and measures the exact repeat period off the clones' offsetLeft, so the
   wrap has no seam at any viewport width. */
.toolrow__track {
  display: flex;
  width: max-content;
  gap: var(--space-2xs);
}

/* Only while the band's loop is actually running. motion.js adds .is-live when
   the IntersectionObserver lets the rAF start and drops it when it stops, so
   two wide compositor layers are not held for the whole page — the same
   discipline as .deck.is-near. */
.tools.is-live .toolrow__track { will-change: transform; }

/* No JS: the rows are simply two static strips, still readable, still
   correct, just not moving. Same under reduced motion. */
@media (prefers-reduced-motion: reduce) {
  .toolrow { mask-image: none; }
  .toolrow__track { flex-wrap: wrap; width: auto; will-change: auto; transform: none !important; }
}

.tool {
  display: flex;
  align-items: center;
  gap: var(--space-2xs);
  padding: var(--space-2xs) var(--space-sm) var(--space-2xs) var(--space-xs);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--secondary);
  color: var(--secondary-foreground);
  font-size: var(--text-sm);
  line-height: 1;
  white-space: nowrap;
}

.tool__icon { width: 20px; height: 20px; flex: none; fill: currentColor; }

/* The one pointer answer in the band. The row keeps moving under it — pausing
   the drift on hover would make the mark the reader is reaching for the only
   still thing on a moving strip, which is harder to hit, not easier. */
.tool {
  transition: color var(--dur-base) var(--ease-out),
              border-color var(--dur-base) var(--ease-out),
              background var(--dur-base) var(--ease-out);
}
.tool:hover {
  color: var(--signal);
  border-color: color-mix(in oklab, var(--signal) 55%, var(--border));
  background: var(--signal-soft);
}

/* ==========================================================================
   04 Project archive — card grid, inline detail, dialog
   ========================================================================== */

.prjgrid {
  list-style: none;
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-md);
}

@media (min-width: 768px) { .prjgrid { grid-template-columns: repeat(2, 1fr); } }

/* --- The archive's marker -------------------------------------------------
   A sticky strip that takes over from the section head once it has scrolled
   away. The grid runs full width underneath it, untouched — that is the whole
   point. A pinned horizontal track was designed for this section and rejected:
   it forces a linear sequence on a reader who wants to jump straight to the
   relevant project, and it never shows more than two cards, which is the same
   objection the modal already answers for. This holds the reader's place
   without taking their random access away.

   Sticky, not fixed: fixed leaves flow and needs a placeholder height written
   by JS and then stripped again, which is the old carousel's arrangement and
   the thing the deck was built to get away from.

   NOTHING between this and <body> may clip, filter or transform — sticky dies
   silently if an ancestor does, with no error and no warning, and that is
   exactly how the old carousel's stage and viewport wrappers killed the cards
   inside them. The chain here is .wrap.sec__inner -> section.sec -> main ->
   body, all clean. _deckcheck.mjs asserts it stays clean.

   Hidden until BOTH .js and motion-allowed. A rail that never fills is a dead
   control, and a dead control is worse than none - the same rule that keeps
   .gal__bar and .prj-card__open hidden until the script that drives them
   lands. There is no width gate: unlike the deck, this works at every size,
   which is why the label rather than the whole strip is what drops on a phone.
   ------------------------------------------------------------------------ */
.prjmark { display: none; }

@media (prefers-reduced-motion: no-preference) {
  .js .prjmark {
    display: flex;
    align-items: center;
    gap: var(--space-md);
    position: sticky;
    /* Clears the nav the same way the deck's cards do. The nav is z-index 50,
       so 2 is over the cards' hover lift and safely under the chrome. */
    top: var(--nav-h);
    z-index: 2;
    margin-bottom: var(--space-md);
    padding-block: var(--space-2xs);
    /* The page's ground is animated, so the strip needs its own floor: without
       one the circuit field reads straight through the label as the cards pass
       underneath it. */
    background: var(--background);
    border-bottom: 1px solid var(--border);
  }

  /* Reuses .seclabel wholesale rather than restating the mono/tracking/colour
     stack, so the strip's label and the head's label above it cannot drift. */
  .js .prjmark__label { flex: none; }

  .js .prjmark__rail {
    position: relative;
    flex: 1 1 auto;
    height: 2px;
    background: var(--border);
  }

  /* Reads --prj-p directly, so the rail cannot fall out of step with the
     scroll: beyond the one number there is nothing for JS to keep in sync.
     No transition — this is scrubbed straight off scroll position, and a
     transition makes every frame chase the last by its own duration. Same
     reason .progress__bar has none. */
  .js .prjmark__fill {
    position: absolute;
    inset: 0;
    transform-origin: 0 50%;
    transform: scaleX(var(--prj-p, 0));
    background: linear-gradient(90deg,
      color-mix(in oklab, var(--signal) 55%, transparent), var(--signal));
    box-shadow: var(--glow-node);
    will-change: transform;
  }

  .js .prjmark__count {
    flex: none;
    font-family: var(--font-mono);
    font-size: var(--text-label);
    font-weight: 700;
    letter-spacing: var(--track-widest);
    color: var(--muted-foreground);
    /* Tabular, so the strip does not twitch as the number changes. */
    font-variant-numeric: tabular-nums;
  }

  /* The label is the first thing to go when three things will not fit on one
     line. The rail and the counter are the part doing the work. */
  @media (max-width: 559px) {
    .js .prjmark__label { display: none; }
  }
}

.prj-card {
  display: flex;
  flex-direction: column;
  padding: var(--space-sm);
  /* Grid and flex items default to min-width:auto, which refuses to shrink
     below the min-content of the gallery inside. Without this the card is
     sized by a 1600px canvas and scrolls the whole page sideways on a phone. */
  min-width: 0;
}

.prj-card__shot {
  border: 1px solid var(--border);
  background: var(--card);
  overflow: hidden;
  /* The ratio moves from the image to the frame, because the image is now
     taller than the frame and the frame is what has to hold the card's shape. */
  aspect-ratio: 16 / 9;
}

/* The picture is 116% of the frame and drifts inside it as the card crosses
   the viewport, so the grid answers the reader card by card instead of
   arriving as one block. motion.js writes --shot-y between -12% and -2%; the
   16% of slack is what that has to stay inside, or a strip of bare card shows.

   The translate is HERE and the hover scale is on the img, deliberately. Both
   on one element means one transform property and the second declaration
   wins — the hover would erase the scroll position, or the transition on the
   hover would lag the scroll by its own duration and feel like mud. */
.prj-card__shot picture {
  display: block;
  height: 116%;
  transform: translate3d(0, var(--shot-y, -7%), 0);
  will-change: transform;
}

.prj-card__shot img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.prj-card__id {
  margin-top: var(--space-md);
  font-family: var(--font-mono);
  font-size: var(--text-label);
  font-weight: 700;
  letter-spacing: var(--track-label);
  color: var(--signal);
}

.prj-card__title { margin-top: var(--space-2xs); font-size: var(--text-h3); font-weight: 600; }

/* Absorbs the leftover height so every View project sits flush at the bottom
   of its card, whatever the title wraps to. */
.prj-card__out { margin-top: var(--space-2xs); flex: 1 0 auto; color: var(--muted-foreground); text-wrap: pretty; }

/* The lead figure, promoted out of .prj-detail onto the card face. The split is
   deliberate: the face carries the headline claim and its evidence grade, the
   modal carries the supporting figures, the proof frames and the narrative.

   Surfacing the .conf marker is the real point. Six cards in a grid put filled
   and half-filled markers next to each other, which is the only way the device
   can teach itself — inside the modal you never see more than one card's at a
   time, so a lone half-dot means nothing to a reader who has no filled one to
   compare it against.

   Rule above rather than a box around: the card is already a .hud panel, and a
   second border inside it would read as a nested card. */
.prj-card__lead {
  margin-top: var(--space-md);
  padding-top: var(--space-md);
  border-top: 1px solid var(--border);
}

.prj-card__lead .conf { margin-top: var(--space-xs); }

.prj-card__chips { margin-top: var(--space-md); }

.prj-card__open {
  display: none;
  align-items: center;
  gap: var(--space-2xs);
  align-self: flex-start;
  margin-top: var(--space-md);
  padding: var(--space-2xs) var(--space-sm);
  border: 1px solid var(--border);
  background: var(--secondary);
  color: var(--foreground);
  font-family: var(--font-mono);
  font-size: var(--text-label);
  font-weight: 700;
  letter-spacing: var(--track-widest);
  text-transform: uppercase;
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease-in-out),
              color var(--dur-fast) var(--ease-in-out);
}
.js .prj-card__open { display: inline-flex; }
.prj-card__open:hover { border-color: var(--signal); color: var(--signal); }
.prj-card__chev { transition: transform var(--dur-fast) var(--ease-in-out); }
.prj-card__open:hover .prj-card__chev { transform: translateX(2px); }

/* The detail lives in the card so it is readable with JS off. Scoped to
   `.prj-card >` deliberately: the copy cloned into the dialog is NOT inside a
   card, so it stays visible there without needing an override to win. */
.js .prj-card > .prj-detail { display: none; }

.prj-detail {
  margin-top: var(--space-md);
  padding-top: var(--space-md);
  border-top: 1px solid var(--border);
}

.prj-note + .prj-note { margin-top: var(--space-md); }
.prj-note__label { display: block; margin-bottom: var(--space-2xs); font-weight: 700; letter-spacing: var(--track-widest); color: var(--signal); }
.prj-note p { color: var(--muted-foreground); text-wrap: pretty; }
.prj-detail__notes { margin-top: var(--space-md); }

/* --- Gallery -------------------------------------------------------------
   A CSS scroll-snap strip: it scrolls, snaps and takes a native swipe with no
   JS at all, and every frame carries its own visible caption. The script only
   adds the buttons, the counter and arrow keys — so the bar stays hidden
   until .js lands and nobody is offered a dead button.
   ------------------------------------------------------------------------ */

.gal__track {
  display: flex;
  gap: var(--space-sm);
  overflow-x: auto;
  scroll-snap-type: x mandatory;
  overscroll-behavior-x: contain;
  scrollbar-width: thin;
  min-width: 0;
}
.gal__slide { flex: 0 0 100%; scroll-snap-align: start; margin: 0; min-width: 0; }

/* The frame is a fixed-height STAGE, not a drawn box, and the border sits on
   the picture instead. Ported from `site/case.css` (the `.cs-gallery__frame`
   block) for the reason it was written there: SYS.01's frames are portrait
   form steps - `intake-entity-team` is 565x1079 - sitting in a track sized for
   a 1600x562 canvas. Drawn as a box, the shot sat about 200px wide in the
   middle of a full-width lit panel and the empty half read as a loading state.
   Framing the picture rather than the space means a 6:1 canvas and a portrait
   form step both look deliberate.

   The height is a named value because three rules need the SAME number. A
   percentage max-height would be the obvious cap, but a percentage only
   resolves against a parent with a definite height, and everything inside the
   stage is auto-height by design. */
.gal { --gal-frame-h: clamp(260px, 46vh, 520px); }

.gal__frame {
  position: relative;
  display: flex; align-items: center; justify-content: center;
  min-width: 0;
}

/* The stage only RESERVES height on a gallery that actually mixes
   orientations - that is `.gal--tall`: SYS.01 and SYS.05 are the two. A
   fixed stage under a gallery of nothing but 6:1 canvases is the opposite
   mistake: `ghl-sms-reminder-workflow` draws 149px tall and sat in a 437px
   band with 145px of nothing above and below it. So the default stage hugs
   its picture, and `width: 100%` lets the width/height attributes reserve the
   box before a lazy image loads. */
.gal:not(.gal--tall) .gal__frame { height: auto; }
.gal:not(.gal--tall) .gal__frame img { width: 100%; height: auto; }

/* A mixed gallery gets ONE stage height for every slide, or paging between a
   246px dropdown and a 1079px form step walks the caption and the controls up
   and down the dialog. An unloaded lazy image has no intrinsic size and
   collapses to nothing - the reserved stage is what stops that showing. */
.gal--tall .gal__frame { height: var(--gal-frame-h); }
.gal--tall .gal__frame img { max-height: var(--gal-frame-h); }

/* The frame is a LINK to the full-size capture, and that is not a nicety.
   Fitting a 1079px-tall form step into a ~460px stage leaves its field labels
   too small to read, and an archive whose job is to show the work cannot also
   be the thing that hides it. A plain link to the PNG needs no lightbox, no JS
   and no trapped focus, and it still works with scripting off. The ring comes
   from the global :focus-visible rule in tokens.css - it needs nothing here. */
a.gal__frame { text-decoration: none; color: var(--muted-foreground); }

/* Shrink-wraps the image so the zoom badge sits on the PICTURE's own corner.
   Anchored to the frame instead, the badge ends up at the far right edge of a
   full-width stage while a portrait shot sits centred a long way from it - a
   small square floating in empty space, reading as a stray artifact rather
   than a control belonging to the image. */
.gal__pic {
  position: relative;
  display: flex; align-items: center; justify-content: center;
  max-width: 100%;
}
.gal--tall .gal__pic { max-height: var(--gal-frame-h); }

/* The <img> is wrapped in a <picture> for the webp source, which would
   otherwise be the flex item - leaving the image's max-height resolving
   against a <picture> that has no height of its own, so tall screenshots
   ignore the stage entirely and run to full size. display:contents takes the
   wrapper out of the box tree and hands the <img> the flex slot directly. */
.gal__frame picture { display: contents; }

.gal__frame img {
  display: block;
  width: auto; height: auto;
  max-width: 100%;
  object-fit: contain;
  border: 1px solid var(--border);
  background: var(--card);
}

/* Shown always, not on hover: hover does not exist on a touch screen, and an
   affordance only pointer users can find is not an affordance. */
.gal__zoom {
  position: absolute; right: var(--space-2xs); bottom: var(--space-2xs);
  display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 30px;
  background: color-mix(in oklab, var(--background) 78%, transparent);
  border: 1px solid var(--border);
  color: var(--muted-foreground);
  transition: color var(--dur-fast) var(--ease-in-out),
              border-color var(--dur-fast) var(--ease-in-out);
}
.gal__zoom svg { width: 15px; height: 15px; fill: none; stroke: currentColor; stroke-width: 2; }
.gal__frame:hover .gal__zoom { color: var(--signal); border-color: var(--signal); }

/* Body face, not the mono label: a caption is a whole sentence, and mono at
   12px is a poor place to read one. */
.gal__cap {
  display: block;
  margin-top: var(--space-2xs);
  font-size: var(--text-sm);
  color: var(--muted-foreground);
  text-wrap: pretty;
}

/* The phase marker, SYS.05 only.
 *
 * Numbering earns its place here for the reason case.css:129 gives for v1's
 * own frame numbers: a gallery genuinely is a sequence. This one is stronger
 * than that - it is a BUILD sequence the reader is meant to follow, account
 * setup then the funnel then the automations that run on it, which is the
 * argument that card is making. The other galleries are sets, not sequences,
 * and must stay unnumbered.
 *
 * Borrowed from `.seclabel`'s grammar in site.css - mono, uppercase, tracked,
 * --signal - so a phase reads as the same device the page already numbers its
 * sections with rather than as a second numbering system. It repeats on every
 * frame rather than only the first of each phase: the gallery pages one frame
 * at a time, so a reader on frame 5 can see nothing else to tell them which
 * phase they are in.
 *
 * Inline, not block: it sits at the head of the caption sentence the way v1's
 * number does, so it costs no vertical space in a fixed-height stage. */
.gal__phase {
  font-family: var(--font-mono);
  font-size: var(--text-label);
  font-weight: 700;
  letter-spacing: var(--track-widest);
  text-transform: uppercase;
  color: var(--signal);
  margin-right: var(--space-2xs);
}

.gal__bar { display: none; }
.js .gal__bar { display: flex; align-items: center; gap: var(--space-sm); margin-top: var(--space-sm); }

.gal__nav {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  padding: 0;
  border: 1px solid var(--border);
  background: var(--secondary);
  color: var(--foreground);
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease-in-out),
              color var(--dur-fast) var(--ease-in-out);
}
.gal__nav:hover:not([disabled]) { border-color: var(--signal); color: var(--signal); }
.gal__nav[disabled] { opacity: .35; cursor: default; }

.gal__count {
  font-family: var(--font-mono);
  font-size: var(--text-label);
  letter-spacing: var(--track-label);
  color: var(--muted-foreground);
}

/* --- Dialog ---------------------------------------------------------------
   Native <dialog>: focus trapping, Escape and the backdrop come free.
   display must only become flex while open, or the closed dialog paints.
   ------------------------------------------------------------------------ */

.prjmodal {
  width: min(58rem, calc(100vw - 2rem));
  max-height: calc(100dvh - 4rem);
  padding: 0;
  border: 1px solid var(--border);
  background: var(--panel-strong);
  color: var(--foreground);
}
.prjmodal[open] { display: flex; flex-direction: column; }

.prjmodal::backdrop {
  background: color-mix(in srgb, black 72%, transparent);
  backdrop-filter: blur(4px);
}

.prjmodal__head {
  flex: none;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--space-md);
  padding: var(--space-md);
  border-bottom: 1px solid var(--border);
}

.prjmodal__id { color: var(--signal); }
.prjmodal__title { margin-top: var(--space-3xs); font-size: var(--text-h3); font-weight: 600; }

.prjmodal__close {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  padding: 0;
  border: 1px solid var(--border);
  background: var(--secondary);
  color: var(--foreground);
  cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease-in-out),
              color var(--dur-fast) var(--ease-in-out);
}
.prjmodal__close:hover { border-color: var(--signal); color: var(--signal); }

.prjmodal__body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: var(--space-md); }
.prjmodal__body .prj-detail { margin-top: 0; padding-top: 0; border-top: 0; }

/* ==========================================================================
   Experience log — a timeline of elevated cards

   A career genuinely IS a sequence, which is what earns the spine and the
   nodes here. Each entry is a .hud card, the same treatment as the hero
   panel, so an entry reads as one object instead of a run of loose rows.
   ========================================================================== */

/* Promoted at component level, the same move .svcx__head makes. "Experience
   log" opens a five-entry timeline that is about a fifth of the page, and at
   --text-h3 it rendered at 20px — identical to a single project card title,
   with no visual step between a section and a major subsection inside one.

   The TOKEN is untouched: --text-h3 stays flat at 20px, which `_ref.mjs scale`
   confirms against the live reference at every width from 390 to 1920. This
   promotes one heading, it does not restate the scale.

   It takes --text-h2's base and NOT the @640 step, which is what gives it its
   own rung: section titles run 36 -> 48, this stays 36, card titles stay 20.
   A subsection that matched its parent section at every width would not be
   reading as a subsection. */
.exp__head {
  margin-top: var(--space-3xl);
  font-size: var(--text-h2);
  font-weight: 600;
}

.exp {
  /* One place to change the rail geometry. The node is centred on --spine,
     so the two can never drift apart. */
  --spine: 1.25rem;
  --exp-gutter: 2.5rem;

  position: relative;
  list-style: none;
  margin-top: var(--space-lg);
}

.exp__item { position: relative; }
.exp__item + .exp__item { margin-top: var(--space-md); }

.exp__node { display: none; }

/* Below 640 the rail is dropped entirely — a 40px gutter to carry a 1px line
   is a poor trade on a phone, and the cards read fine as a plain stack. */
@media (min-width: 640px) {
  .exp { padding-left: var(--exp-gutter); }

  .exp::before {
    content: "";
    position: absolute;
    left: var(--spine);
    top: var(--space-sm);
    bottom: var(--space-sm);
    width: 1px;
    /* --border is tuned for a card edge and disappears as a 1px run over this
       distance; --rule-strong is the same hue a step brighter. */
    background: var(--rule-strong);
  }

  /* The drawn line. Same geometry as the rail above it, one step brighter,
     scaled from the top as the reader descends — so the spine arrives with
     them rather than being fully drawn before they have read any of it.

     scaleY on a 1px line, not an animated height: height would relayout every
     frame. --exp-p defaults to 0 and is written by beatsStep; with JS off or
     under reduced motion it simply never draws and the static rail is the
     whole treatment, which is what it was before. */
  .exp::after {
    content: "";
    position: absolute;
    left: var(--spine);
    top: var(--space-sm);
    bottom: var(--space-sm);
    width: 1px;
    background: var(--signal);
    transform: scaleY(var(--exp-p, 0));
    transform-origin: top;
    will-change: transform;
  }

  .exp__node {
    display: block;
    position: absolute;
    left: calc(var(--spine) - var(--exp-gutter));
    top: var(--space-lg);
    width: 11px;
    height: 11px;
    margin-left: -5px;
    border: 2px solid var(--signal);
    border-radius: var(--radius-full);
    background: var(--background);
    /* Only the fill and the glow transition. The node must not move: it is
       centred on --spine and a transform here would walk it off the line. */
    transition:
      background-color var(--dur-base) var(--ease-out),
      box-shadow var(--dur-base) var(--ease-out);
  }

  /* Lit as the drawn line reaches it. The bloom is one of the page's three
     named strengths, so a lit node joins the hero panel's light family instead
     of introducing a second glow with its own colour. */
  .exp__node.is-lit {
    background: var(--signal);
    box-shadow: var(--glow-node);
  }
}

/* The node is a state, not an animation — it holds once lit. Under reduced
   motion it is never lit at all, because beatsStep does not run. */
@media (prefers-reduced-motion: reduce) {
  .exp::after { display: none; }
  .exp__node { transition: none; }
}

.exp__card { padding: var(--space-md); }

@media (min-width: 768px) { .exp__card { padding: var(--space-lg); } }

/* The current role, marked at EVERY width. The spine and its lit nodes do this
   job on desktop and are dropped below 640 on purpose, which left the phone
   layout as five identical boxes where an eight-year role and a four-month
   contract carried exactly the same weight — on the longest scroll of the page.

   An accented top edge rather than a whole accented border: the card is
   already a .hud panel, and recolouring all four sides makes it read as
   selected rather than as current. :first-child because .exp is a document-
   order timeline, newest first. */
.exp__item:first-child .exp__card { border-top: 2px solid var(--signal); }

.exp__org {
  font-size: var(--text-h3);
  font-weight: 700;
  color: var(--foreground);
}

.exp__role {
  margin-top: 2px;
  font-family: var(--font-display);
  font-weight: 600;
  color: var(--signal);
}

.exp__when {
  margin-top: var(--space-2xs);
  font-family: var(--font-mono);
  font-size: var(--text-label);
  letter-spacing: var(--track-label);
  color: var(--muted-foreground);
}

.exp__card .chips { margin-top: var(--space-sm); }

.exp__points {
  margin-top: var(--space-md);
  list-style: none;
  color: var(--muted-foreground);
}
.exp__points li {
  position: relative;
  padding-left: var(--space-md);
  text-wrap: pretty;
}
.exp__points li + li { margin-top: var(--space-2xs); }

/* A chevron rather than a disc: it points at the line it belongs to. */
.exp__points li::before {
  content: "";
  position: absolute;
  left: 2px;
  top: .55em;
  width: 5px;
  height: 5px;
  border-top: 1.5px solid var(--draft-soft);
  border-right: 1.5px solid var(--draft-soft);
  transform: rotate(45deg);
}

/* ==========================================================================
   Card depth — shared by the page's two card families

   Square corners are not negotiable here (--radius is the ceiling, see
   tokens.css), so the depth has to come from light rather than from a rounded,
   softened edge. Three devices, none of which touches the corner radius:

     the rim     one inset hairline of --foreground across the top, which is the
                 specular edge a real bevel would catch. It is the cheapest
                 thing that stops a square box reading as a drawn rectangle.
     the sheen   the same light travelling across the face at 155deg, gone by
                 46% — a raking highlight, not a gloss. background-IMAGE only,
                 so .hud's --panel and .deck__card's --card fills are both left
                 alone and the two families keep their own surfaces.
     the edge    a 2px gradient of --signal fading to nothing at both ends along
                 the top. It is the one coloured piece, and it is what ties the
                 card to the circuit ground behind it.

   Plus a layered, tinted resting shadow: black for weight, signal for the light
   the page is lit by.

   Two families, one rule. A new class would mean twelve markup edits that can
   drift; this is one declaration block and grep finds it. Scoped to these two
   deliberately and NOT put on .hud: .exp__card marks the current role with a
   2px signal top border and a second device on that same hairline would stop
   it reading as a marker, and .booking__frame holds a third-party iframe whose
   own white surface would turn the sheen into a rendering artifact.
   ========================================================================== */

.prj-card,
.deck__card {
  background-image: var(--sheen);
}

/* The top-edge light. ::before and ::after are both free on both families —
   that is precisely why .hud's corner marks are real elements rather than
   pseudo-elements, and this is the rule that spends the inheritance.

   It sits inside the PADDING box, so .deck__card's `overflow: clip` in the
   pinned deck cannot reach it, and that card's brightness() dims the edge along
   with the rest of it as it settles back — which is right: a card that has been
   ridden over should lose its edge light too. Nothing here is drawn outside the
   padding box. */
.prj-card::before,
.deck__card::before {
  content: "";
  position: absolute;
  inset-inline: 0;
  top: 0;
  height: 2px;
  pointer-events: none;
  background: linear-gradient(90deg, transparent, var(--edge), transparent);
}

/* The archive card keeps .hud's bloom, because the archive is where that light
   already lives. */
.prj-card {
  box-shadow: var(--glow), var(--rim), var(--shadow-e1);
}

/* The capability card deliberately does NOT take the bloom. .svcx__figure is
   the only signal-coloured light in the services section and it answers the
   reader's own scroll and pointer, which is what earns it; a second permanent
   glow on the card AROUND it is the same idea said twice, and it would make the
   lit figure stop meaning anything. No transition here either, ever — this card
   is scrubbed straight off the scroll, and a transition would make every frame
   chase the last by its own duration. */
.deck__card {
  box-shadow: var(--rim), var(--shadow-e1);
}

/* ==========================================================================
   Project card hover
   ========================================================================== */

.prj-card {
  transition:
    border-color var(--dur-base) var(--ease-in-out),
    transform var(--dur-base) var(--ease-out),
    box-shadow var(--dur-base) var(--ease-in-out);
}

/* :focus-within, not just :hover — the card's only control is the View
   project button, and a keyboard user should get the same state a mouse
   user does.

   The step up from rest is a change of KIND rather than of degree, which is
   what keeps it legible now that rest is no longer flat: hover adds a 1px
   signal ring and shifts the drop shadow's hue from black to signal. The rim
   rides through the lift, because the specular edge is a property of the
   surface and not of the state — dropping it would make the card look like it
   lost its top edge on the way up. */
.prj-card:hover,
.prj-card:focus-within {
  border-color: var(--signal);
  transform: translateY(-4px);
  box-shadow: var(--shadow-e2), var(--rim);
}

.prj-card__shot img { transition: transform var(--dur-slow) var(--ease-out); }
.prj-card:hover .prj-card__shot img,
.prj-card:focus-within .prj-card__shot img { transform: scale(1.03); }

.prj-card:hover .prj-card__chev,
.prj-card:focus-within .prj-card__chev { transform: translateX(2px); }

/* The glow still reads without the movement, so keep the affordance and
   drop only the travel. */
@media (prefers-reduced-motion: reduce) {
  .prj-card:hover,
  .prj-card:focus-within { transform: none; }
  .prj-card:hover .prj-card__shot img,
  .prj-card:focus-within .prj-card__shot img { transform: none; }
}

/* ==========================================================================
   Metrics strip (inside the project dialog)

   Same hairgrid device as the hero stat row, so it reads as part of the
   system rather than a component invented for this one place.
   ========================================================================== */

.metrics {
  grid-template-columns: 1fr;
  margin-bottom: var(--space-2xs);
}

/* auto-fit rather than a hard repeat(3): the lead figure is promoted onto the
   card face, so a project's detail carries TWO metrics here, not three. A fixed
   three-column track would leave an empty cell with a hairline drawn down the
   side of nothing. */
@media (min-width: 640px) {
  .metrics { grid-template-columns: repeat(auto-fit, minmax(12rem, 1fr)); }
}

.metric { padding: var(--space-sm); }

.metric__num {
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 1.3rem + .9vw, 1.875rem);
  font-weight: 600;
  line-height: 1.15;
  color: var(--foreground);
}

.metric__cap {
  margin-top: var(--space-2xs);
  font-size: var(--text-sm);
  color: var(--muted-foreground);
  text-wrap: pretty;
}

.metric .conf { margin-top: var(--space-xs); }

/* One instance now, in #work's section head rather than repeated inside every
   card. It sits under .sec__lede, which is why it takes a top margin and no
   longer needs a bottom one — .sec__head already spaces the grid below it. */
.metrics__key {
  margin-top: var(--space-sm);
  font-size: var(--text-sm);
  color: var(--muted-foreground);
}
