/* ── Typefaces ───────────────────────────────────────────────────────────────
   Self-hosted, not Google-hosted. The page runs a strict CSP
   (default-src 'none'), so a fonts.googleapis.com <link> would need a CSP
   exception plus a second DNS lookup and a third-party request on the
   customer-facing funnel. Two local files avoid all of it, and they cannot
   disappear when someone else's CDN changes.

   Both are the `latin` subset only — the site is English — which is why
   Fraunces is 67KB rather than the ~300KB of the full family.

   font-display: swap so text is readable immediately and reflows when the
   face lands; with `block` the headline would be invisible for up to 3s. */
@font-face {
  font-family: Fraunces;
  src: url("assets/fonts/fraunces.woff2") format("woff2");
  font-weight: 400 700;          /* variable: one file covers the range */
  font-style: normal;
  font-display: swap;
}
@font-face {
  font-family: "Public Sans";
  src: url("assets/fonts/publicsans.woff2") format("woff2");
  font-weight: 400 700;
  font-style: normal;
  font-display: swap;
}

/* ═══════════════════════════════════════════════════════════════════════════
   SilverClean — marketing site

   ── LIQUID GLASS ──────────────────────────────────────────────────────────
   Apple describes glass as three optical layers, and that is how the .glass
   surfaces here are built:

     1. REFRACTION  backdrop-filter blurs and saturates what is behind. On
                    Chrome an SVG feDisplacementMap (#glass-refract) is layered
                    on top, warping the backdrop the way a real lens does at
                    its edges. Everywhere else that line is simply ignored and
                    the frosted base remains — progressive enhancement, not a
                    broken effect.
     2. ILLUMINATION  a soft internal gradient, brighter at the top edge, so
                    the pane reads as lit rather than merely translucent.
     3. SPECULAR    a 1px inset highlight along the top border and a matching
                    dark inset along the bottom. This is what sells the effect;
                    without it a blurred box just looks foggy.

   ── MOTION ────────────────────────────────────────────────────────────────
   Native scroll-driven timelines (animation-timeline: view()), so reveals run
   on the compositor thread and stay smooth while JS is busy. Only transform
   and opacity are animated — anything touching layout (width, height, top)
   forces reflow every frame and is what makes most "smooth" sites stutter.

   Restraint is the point. Apple's discipline is a few deliberate reveals, not
   everything sliding in: if a visitor notices the animation, it is interfering
   rather than helping. Distances stay small (18–26px); large travel reads as
   sluggish no matter how good the easing.

   All motion sits inside prefers-reduced-motion: no-preference.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Theme tokens ─────────────────────────────────────────────────────────── */
:root {
  --accent:      #0e7490;
  --accent-lit:  #22d3ee;
  --accent-soft: #ecfeff;

  --bg:          #ffffff;
  --bg-2:        #f5f8fb;
  --bg-3:        #eef3f8;
  --text:        #101828;
  --text-2:      #52627a;
  --line:        #e2e8f0;
  --heading:     #0b1220;

  /* Glass — tuned separately per theme; the same alpha reads very differently
     against a light or dark backdrop. */
  --glass-bg:        rgba(255,255,255,.55);
  --glass-bg-strong: rgba(255,255,255,.72);
  /* Nav is its own surface, not a card. Card alphas (.55/.72) let large
     display headings read straight through the bar as they scroll past, so
     the heading looked clipped rather than occluded. Near-opaque, with the
     blur supplying the glass character instead of the transparency. */
  --nav-bg:          rgba(255,255,255,.92);
  --glass-border:    rgba(255,255,255,.85);
  --glass-hi:        rgba(255,255,255,.95);   /* specular top edge */
  --glass-lo:        rgba(14,20,32,.07);      /* shaded bottom edge */
  --glass-tint:      rgba(255,255,255,.28);
  --glass-sheen:     rgba(255,255,255,.62);   /* directional surface sweep */
  /* Rim gradient — bright where light lands, fading around the shape. */
  --glass-rim-hi:    rgba(255,255,255,.98);
  --glass-rim-mid:   rgba(255,255,255,.42);
  --glass-rim-lo:    rgba(150,170,200,.22);

  /* Primary button: dark fill, light label. */
  --btn-fill:  #0b1220;
  --btn-label: #ffffff;

  --shadow-sm: 0 1px 2px rgba(11,18,32,.05), 0 2px 8px rgba(11,18,32,.04);
  --shadow-md: 0 2px 8px rgba(11,18,32,.06), 0 14px 36px rgba(11,18,32,.08);
  --shadow-lg: 0 10px 30px rgba(11,18,32,.10), 0 36px 72px rgba(11,18,32,.12);

  --r-sm: 10px;
  --r-md: 16px;
  --r-lg: 24px;
  --r-xl: 32px;

  /* One easing curve everywhere. Consistency is what reads as "designed";
     mixing curves reads as "assembled". */
  --ease:     cubic-bezier(.22,.61,.36,1);
  --ease-out: cubic-bezier(.16,1,.3,1);

  /* Grows with the viewport instead of stopping dead at 1140. On a 1920
     display the old fixed cap left ~390px of empty margin each side, which
     reads as a page built for a laptop and never checked on anything
     larger. The clamp tops out before running text exceeds a comfortable
     measure. */
  --wrap: clamp(1060px, 78vw, 1320px);
  /* ── Accent line strengths ──────────────────────────────────────────
     Sixteen accent borders had grown eight different intensities — 22%,
     34%, 40%, 42%, 45%, 55% and solid at three widths — chosen per
     component. Nothing about that is visible as a bug, and it is most of
     why a row of rules reads as inconsistent: the eye compares neighbours,
     and neighbouring lines that differ by a few percent look like a
     mistake rather than a hierarchy.

     Three steps, by ROLE rather than by component:
       hair   a divider that happens to be tinted — barely there
       mark   "this one is first" — visible, still quiet
       solid  an active state the user caused — buttons, focus, selection */
  --accent-hair:  color-mix(in oklab, var(--accent) 22%, transparent);
  --accent-mark:  color-mix(in oklab, var(--accent) 45%, var(--line));
  --accent-solid: var(--accent);

  /* Hairline that keeps a screenshot or panel from bleeding into a dark
     ground. Written four times at .10/.12/.14/.18 before this — four edges
     that almost match, which the eye reads as sloppiness even though no
     single one is wrong. Light mode does not need it; --line is enough
     there. */
  --shot-edge: rgba(255,255,255,.12);



  /* ── Editorial type scale ───────────────────────────────────────────────
     A real ratio rather than three ad-hoc clamp() values. Every heading on
     the page picks a step from this scale, which is what makes a layout read
     as designed rather than assembled: sizes relate to each other instead of
     each being chosen locally.

     The jump from --t-2 to --t-1 is deliberately large. Uniformly-sized
     section headings are the single clearest tell of a template — hierarchy
     requires that some things be genuinely, uncomfortably bigger. */
  --t-1:  clamp(46px, 7.2vw, 104px);   /* hero display */
  --t-2:  clamp(34px, 4.4vw, 60px);    /* section openers */
  --t-3:  clamp(23px, 2.1vw, 30px);    /* subsection / feature titles */
  --t-4:  19px;                        /* card titles */
  --t-sm: 15px;
  --t-xs: 13px;

  /* Measure caps. Long lines are the other quiet tell — body copy running the
     full 1140px is unreadable and looks unconsidered. */
  --measure:      62ch;
  --measure-tight: 44ch;

  /* Vertical rhythm. Sections vary deliberately (see .band-* modifiers);
     these are the two poles, not a single uniform value. */
  --pad-loose: clamp(88px, 9vw, 132px);
  --pad-tight: clamp(64px, 7vw, 96px);

  /* Three faces, three jobs. Before this the page declared no font at all and
     fell through to the OS default, so the h1 and the body computed to the
     SAME family — a page with no typographic voice, which is why it leaned on
     glass, radii and shadows for personality instead.

     --display  Fraunces. Headings only. A serif is rare in this category
                (Jobber, Housecall Pro and Swept are all sans), so it
                differentiates while reading established rather than startup.
     --sans     Public Sans. Body and UI. Deliberately unremarkable: body type
                should be invisible.
     --mono     Data. Field-service software is a record system; numerals set
                in a UI face read as decorative, in mono they read as evidence. */
  --display: Fraunces, Georgia, "Times New Roman", serif;
  --sans: "Public Sans", -apple-system, BlinkMacSystemFont, "Segoe UI",
          Roboto, "Helvetica Neue", Arial, sans-serif;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
          "Liberation Mono", monospace;

  /* When the hero intro begins, measured from page load. Sits just after the
     loader's veil starts lifting (1.35s) so the two read as one motion. */
  --intro: 1.30s;

  color-scheme: light;
}

:root[data-theme="dark"] {
  --accent:      #22d3ee;
  --accent-lit:  #67e8f9;
  --accent-soft: rgba(34,211,238,.10);

  --bg:          #080c14;
  --bg-2:        #0d1320;
  --bg-3:        #121a2b;
  --text:        #e6edf7;
  --text-2:      #94a5bd;
  --line:        rgba(148,165,189,.16);
  --heading:     #f2f6fc;

  --glass-bg:        rgba(22,31,49,.52);
  --glass-bg-strong: rgba(22,31,49,.72);
  --nav-bg:          rgba(10,15,26,.94);
  --glass-border:    rgba(148,180,220,.18);
  --glass-hi:        rgba(190,220,255,.26);
  --glass-lo:        rgba(0,0,0,.34);
  --glass-tint:      rgba(90,140,200,.08);
  --glass-sheen:     rgba(150,200,255,.13);
  /* Dark glass catches a cooler, dimmer rim — a bright white edge here reads
     as a drawn outline rather than reflected light. */
  --glass-rim-hi:    rgba(190,225,255,.42);
  --glass-rim-mid:   rgba(140,175,220,.16);
  --glass-rim-lo:    rgba(90,120,160,.10);

  /* Inverted in dark mode: a dark fill would vanish into the background, so
     the primary button becomes bright accent with dark text. */
  --btn-fill:  #e8f6fb;
  --btn-label: #06202b;

  --shadow-sm: 0 1px 2px rgba(0,0,0,.35), 0 2px 10px rgba(0,0,0,.25);
  --shadow-md: 0 2px 10px rgba(0,0,0,.40), 0 16px 40px rgba(0,0,0,.35);
  --shadow-lg: 0 12px 34px rgba(0,0,0,.50), 0 40px 80px rgba(0,0,0,.45);

  color-scheme: dark;
}

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

html {
  scroll-behavior: smooth;
  scroll-padding-top: 90px;   /* anchors clear the fixed nav */
  -webkit-text-size-adjust: 100%;
}
@media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } }

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font: 400 17px/1.65 var(--sans);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  overflow-x: hidden;
  transition: background-color .45s var(--ease), color .45s var(--ease);
}

h1, h2, h3 {
  margin: 0;
  color: var(--heading);
  font-family: var(--display);
  /* Fraunces is a serif with real weight; 600 in a serif reads heavier than
     600 in a grotesque, so the display weight drops rather than rises. */
  font-weight: 600;
  /* Type is spaced for reading at 16px. At display sizes it looks loose, so
     headings track negative and small uppercase labels track positive. */
  letter-spacing: -.024em;
  line-height: 1.1;
  text-wrap: balance;
}
p  { margin: 0; }
ul { margin: 0; padding: 0; list-style: none; }
a  { color: inherit; text-decoration: none; }

.wrap { width: 100%; max-width: var(--wrap); margin-inline: auto;
        padding-inline: clamp(16px, 3.2vw, 40px); }
.wrap-narrow { max-width: 680px; }
/* Wider than --wrap: used where a panel should feel like it exceeds the text
   column, which is what stops every section sharing one silhouette. */
.wrap-wide { max-width: clamp(1200px, 88vw, 1560px); }

/* ═══ EDITORIAL LAYOUT ══════════════════════════════════════════════════════
   The previous layout was one shape repeated: centred title, centred lede,
   three equal cards. Four times. That symmetry is the actual reason the page
   read as generic — no amount of surface treatment fixes a uniform skeleton.

   These primitives exist to break it deliberately.
   ═════════════════════════════════════════════════════════════════════════ */

/* Section opener pinned left with a hanging index number, instead of a centred
   title/lede pair. The asymmetry is the point: the eye gets a fixed left edge
   to track down the page, and the number gives each section an identity. */
.opener {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: clamp(18px, 3vw, 44px);
  align-items: start;
  margin-bottom: clamp(34px, 4vw, 52px);
}
.opener-num {
  font: 500 var(--t-xs)/1 var(--mono);
  letter-spacing: .14em;
  color: var(--accent);
  padding-top: .9em;
  white-space: nowrap;
  /* Hairline trailing the number, tying it to the heading block. */
  display: flex; align-items: center; gap: 12px;
}
/* The number is now a quiet step marker and the LABEL carries the meaning.
   A bare "03" told a skimmer nothing about the section; "The crew app" tells
   them what they are looking at before they read the heading, which is the
   whole point of a label. */
.opener-step { opacity: .55; }
.opener-label {
  text-transform: uppercase;
  letter-spacing: .14em;
  color: var(--accent);
}
.opener-num::after {
  content: ""; width: clamp(18px, 4vw, 60px); height: 1px;
  background: linear-gradient(90deg,
    color-mix(in oklab, var(--accent) 60%, transparent), transparent);
}
.opener-title {
  font-size: var(--t-2);
  max-width: 18ch;              /* forces a deliberate 2–3 line break */
  text-wrap: balance;
}
.opener-lede {
  margin-top: 18px;
  max-width: var(--measure);
  color: var(--text-2);
  font-size: 17.5px;
}

/* Alternating feature rows — text one side, visual the other, reversing each
   time. This is the workhorse that replaces the three-card grid. */
.row {
  display: grid;
  grid-template-columns: minmax(0, .92fr) minmax(0, 1.08fr);
  gap: clamp(32px, 5vw, 76px);
  align-items: center;
  padding-block: clamp(40px, 4.6vw, 64px);
}
.row + .row { border-top: 1px solid var(--line); }
.row-flip > .row-visual { order: -1; }   /* visual leads on alternating rows */
.row-text  { max-width: 46ch; }
.row-kicker {
  font: 500 var(--t-xs)/1 var(--mono);
  letter-spacing: .14em; text-transform: uppercase;
  color: var(--accent);
  margin-bottom: 16px;
}
.row-title { font-size: var(--t-3); margin-bottom: 14px; text-wrap: balance; }
.row-body  { color: var(--text-2); max-width: var(--measure-tight); }
/* Supporting detail under a feature — small, mono-labelled facts that make a
   claim concrete rather than repeating it in different words. */
.row-facts { margin-top: 26px; display: grid; gap: 11px; }
.row-facts li {
  display: grid; grid-template-columns: auto minmax(0,1fr);
  gap: 12px; align-items: baseline;
  font-size: var(--t-sm); color: var(--text-2);
}
.row-facts b {
  font: 500 var(--t-xs)/1.4 var(--mono);
  color: var(--accent); white-space: nowrap;
}

@media (max-width: 860px) {
  .row { grid-template-columns: 1fr; gap: 34px; padding-block: 52px; }
  /* Once stacked, the visual always follows its text — alternating order in a
     single column just scrambles reading sequence. */
  .row-flip > .row-visual { order: 0; }
  .row-text { max-width: none; }
  .opener { grid-template-columns: 1fr; gap: 14px; }
  .opener-num { padding-top: 0; }
  .opener-title { max-width: none; }
}

.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

.skip {
  position: absolute; left: -9999px; top: 0; z-index: 100;
  background: var(--btn-fill); color: var(--btn-label);
  padding: 12px 20px; border-radius: 0 0 var(--r-sm) 0;
}
.skip:focus { left: 0; }

:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 6px; }

/* ═══ GLASS ════════════════════════════════════════════════════════════════
   Four stacked layers, matching how the effect is actually constructed:

     1. REFRACTION   backdrop-filter blur + saturate. On Chrome an SVG
                     displacement map is added, warping the backdrop only near
                     the edges — a lens is clear in the middle and bends light
                     where it curves. Uniform displacement smears everything
                     and reads as a rendering bug.
     2. TINT         a translucent fill so the pane has body.
     3. SPECULAR     ::before — a directional sheen sweeping across the
                     surface, plus a bright inner rim. This is the layer that
                     sells the effect. Without it a blurred box just looks
                     foggy.
     4. EDGE LIGHT   ::after — a 1px gradient border, bright where light would
                     hit (top-left) fading to dark (bottom-right). Painted as
                     a masked gradient rather than a flat border-color, so the
                     rim varies around the shape the way a real bevel does.

   The blur is the cheap part; the edges are what make it read as glass.
   ═════════════════════════════════════════════════════════════════════════ */
.glass {
  position: relative;
  isolation: isolate;              /* keeps blend modes local to this pane */
  background: var(--glass-bg);
  backdrop-filter: blur(var(--glass-blur, 20px)) saturate(180%);
  -webkit-backdrop-filter: blur(var(--glass-blur, 20px)) saturate(180%);
  box-shadow:
    inset 0 1px 0 0 var(--glass-hi),      /* top specular */
    inset 0 -1px 0 0 var(--glass-lo),     /* bottom shade */
    inset 0 0 24px -8px var(--glass-hi),  /* inner glow, softens the fill */
    var(--shadow-md);
}

/* Layer 3 — specular sheen. */
.glass::before {
  content: "";
  position: absolute; inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background:
    linear-gradient(140deg,
      var(--glass-sheen) 0%,
      transparent 34%,
      transparent 66%,
      var(--glass-tint) 100%);
  opacity: .9;
}

/* Layer 4 — gradient rim. mask-composite: exclude carves out the interior,
   leaving only a 1px border painted with the gradient. */
.glass::after {
  content: "";
  position: absolute; inset: 0;
  border-radius: inherit;
  padding: 1px;
  pointer-events: none;
  background: linear-gradient(140deg,
              var(--glass-rim-hi) 0%,
              var(--glass-rim-mid) 42%,
              var(--glass-rim-lo) 100%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
}

/* Chrome only: true refraction over the frosted base. Other engines skip the
   block entirely and keep the blur, so the effect degrades rather than breaks.

   contain: paint is applied HERE rather than on .glass — it bounds the
   filter's rasterisation, which matters once an SVG filter is in the pipeline,
   but on plain panes it would clip anything overflowing (badges, tooltips). */
@supports (backdrop-filter: url(#glass-refract)) {
  .glass-refract {
    backdrop-filter: blur(16px) saturate(180%) url(#glass-refract);
    contain: paint;
  }
  /* Chromatic aberration is the expensive variant — SVG backdrop-filters cost
     real frame time, so it stays on the single hero surface rather than every
     card on the page. */
  .glass-chromatic {
    backdrop-filter: blur(18px) saturate(175%) url(#glass-chromatic);
    contain: paint;
  }
}

/* ═══ LOAD SEQUENCE ════════════════════════════════════════════════════════
   The shield strokes itself on, the check lands, the wordmark resolves, a
   progress line sweeps, and the veil lifts. ~1.7s total.

   Two rules keep this from becoming an annoyance:
     * It is decorative — aria-hidden, pointer-events: none once lifting, and
       removed from the DOM by JS when finished.
     * The lift is an unconditional CSS animation, not JS-driven. If the script
       never runs, the veil still clears; a loading screen that can strand a
       visitor is worse than no loading screen.

   Skipped entirely under prefers-reduced-motion — the veil is hidden outright
   rather than animated away.
   ═════════════════════════════════════════════════════════════════════════ */
.loader {
  position: fixed; inset: 0; z-index: 999;
  display: grid; place-items: center;
  background: var(--bg);
  /* The lift: fade + a slight scale-up, as though the veil pulls toward the
     viewer rather than simply disappearing. */
  animation: veil-lift .85s var(--ease-out) 1.35s both;
}
.loader.is-done { display: none; }

.loader-inner { display: grid; justify-items: center; gap: 18px; }

.loader-mark { width: 62px; height: 62px; overflow: visible; }
.loader-shield,
.loader-check {
  fill: none;
  stroke-linecap: round;
  stroke-linejoin: round;
  /* Dash length must meet or exceed the path so animating the offset to 0
     draws the stroke on from one end. Measured with getTotalLength(): the
     shield path is 78.20 units. This was 120 against a 76.52-unit path, so
     the stroke finished drawing well before the animation ended and the
     last third of the duration showed nothing. */
  stroke-dasharray: 79;
  stroke-dashoffset: 79;
}
.loader-shield {
  stroke: var(--heading); stroke-width: 2;
  animation: draw .85s var(--ease-out) .05s forwards;
}
.loader-check {
  stroke: var(--accent); stroke-width: 2.5;
  /* Measured 16.03 units; was 24. */
  stroke-dasharray: 17; stroke-dashoffset: 17;
  animation: draw .38s var(--ease-out) .62s forwards;
}

.loader-word {
  font-size: 19px; font-weight: 600; letter-spacing: -.02em;
  color: var(--heading);
  opacity: 0;
  animation: word-in .55s var(--ease-out) .72s both;
}
.loader-word-accent { color: var(--accent); font-weight: 500; }

/* A hairline that fills left-to-right — reads as progress without pretending
   to measure anything, which would be a lie on a static page. */
.loader-bar {
  width: 108px; height: 1.5px;
  background: var(--line);
  border-radius: 2px; overflow: hidden;
}
.loader-bar i {
  display: block; height: 100%; width: 100%;
  background: linear-gradient(90deg, var(--accent), var(--accent-lit));
  transform: scaleX(0); transform-origin: left;
  animation: bar-fill 1.15s var(--ease-out) .18s forwards;
}

@keyframes draw      { to { stroke-dashoffset: 0; } }
@keyframes word-in   { from { opacity: 0; transform: translateY(7px); }
                       to   { opacity: 1; transform: none; } }
@keyframes bar-fill  { to { transform: scaleX(1); } }
@keyframes veil-lift {
  from { opacity: 1; transform: scale(1);    visibility: visible; }
  to   { opacity: 0; transform: scale(1.04); visibility: hidden; }
}

@media (prefers-reduced-motion: reduce) {
  .loader { display: none; }
}

/* ── Nav ─────────────────────────────────────────────────────────────────── */
.nav {
  position: fixed; inset: 0 0 auto 0; z-index: 50;
  background: transparent;
  border-bottom: 1px solid transparent;
  transition: background-color .4s var(--ease), border-color .4s var(--ease),
              backdrop-filter .4s var(--ease);
}
.nav.scrolled {
  /* -strong, not --glass-bg: large display headings scroll underneath this
     bar, and at .55 alpha they stayed legible through it — the heading read
     as clipped mid-word rather than as passing behind a surface. A denser
     fill plus a heavier blur is what makes the nav read as opaque material.
     A masked fade at the lower edge keeps it from looking like a hard band. */
  background: var(--nav-bg);
  border-bottom-color: var(--line);
  backdrop-filter: blur(30px) saturate(190%);
  -webkit-backdrop-filter: blur(30px) saturate(190%);
  box-shadow:
    inset 0 -1px 0 0 var(--glass-lo),
    0 8px 28px -18px rgba(12,22,40,.45);
}
:root[data-theme="dark"] .nav.scrolled {
  box-shadow:
    inset 0 -1px 0 0 var(--glass-lo),
    0 10px 34px -18px rgba(0,0,0,.7);
}
.nav-inner { display: flex; align-items: center; gap: 30px; height: 70px; }
.nav-links { display: flex; gap: 28px; margin-left: auto; }
.nav-links a {
  position: relative; font-size: 15px; color: var(--text-2);
  transition: color .22s var(--ease);
}
.nav-links a::after {
  content: ""; position: absolute; left: 0; bottom: -5px;
  width: 100%; height: 1.5px; background: var(--accent);
  transform: scaleX(0); transform-origin: right;
  transition: transform .34s var(--ease-out);
}
.nav-links a:hover { color: var(--heading); }
.nav-links a:hover::after { transform: scaleX(1); transform-origin: left; }
.nav-cta { display: flex; align-items: center; gap: 10px; }

/* ── Theme toggle ────────────────────────────────────────────────────────── */
.theme-toggle {
  display: grid; place-items: center;
  width: 38px; height: 38px; padding: 0;
  border: 1px solid var(--line);
  border-radius: 50%;
  background: var(--glass-bg);
  color: var(--text-2);
  cursor: pointer;
  transition: color .22s var(--ease), border-color .22s var(--ease),
              transform .28s var(--ease);
}
.theme-toggle:hover { color: var(--heading); transform: scale(1.06); }
.theme-toggle svg {
  grid-area: 1 / 1;
  width: 17px; height: 17px;
  fill: none; stroke: currentColor;
  stroke-width: 1.8; stroke-linecap: round;
  transition: opacity .3s var(--ease), transform .45s var(--ease-out);
}
/* Cross-fade with a slight rotation — the icons swap rather than blink. */
:root[data-theme="light"] .icon-moon { opacity: 0; transform: rotate(-70deg) scale(.6); }
:root[data-theme="light"] .icon-sun  { opacity: 1; transform: none; }
:root[data-theme="dark"]  .icon-sun  { opacity: 0; transform: rotate(70deg) scale(.6); }
:root[data-theme="dark"]  .icon-moon { opacity: 1; transform: none; }

/* ── Brand ───────────────────────────────────────────────────────────────── */
.brand { display: inline-flex; align-items: center; gap: 10px; }
.brand-mark { width: 30px; height: 30px; flex: none; }
.mark-shield { fill: none; stroke: var(--heading); stroke-width: 2; stroke-linejoin: round; }
.mark-check  { fill: none; stroke: var(--accent);  stroke-width: 2.5;
               stroke-linecap: round; stroke-linejoin: round; }
.brand-word { font-size: 19px; font-weight: 600; letter-spacing: -.02em; color: var(--heading); }
.brand-word-accent { color: var(--accent); font-weight: 500; }

/* ── Buttons ─────────────────────────────────────────────────────────────── */
.btn {
  position: relative;
  display: inline-flex; align-items: center; justify-content: center;
  padding: 10px 21px;
  border: 1px solid transparent; border-radius: 999px;
  font-size: 15px; font-weight: 500;
  cursor: pointer; white-space: nowrap;
  overflow: hidden;
  transition: transform .2s var(--ease), background-color .2s var(--ease),
              color .2s var(--ease), border-color .2s var(--ease),
              box-shadow .2s var(--ease);
}
/* Explicit fill/label tokens rather than reusing --heading/--bg: in dark mode
   that pairing produced near-black text on a near-black button. */
.btn-solid {
  background: var(--btn-fill); color: var(--btn-label);
  box-shadow: inset 0 1px 0 0 rgba(255,255,255,.16);
}
.btn-solid:hover { transform: translateY(-1.5px); box-shadow: var(--shadow-md); }
.btn-ghost {
  color: var(--text); border-color: var(--line);
  background: var(--glass-bg);
}
.btn-ghost:hover { border-color: var(--accent); color: var(--heading); transform: translateY(-1.5px); }
.btn-quiet { color: var(--accent); }
.btn-quiet:hover { transform: translateY(-1.5px); }
.btn-lg { padding: 14px 30px; font-size: 16px; }
.btn-block { width: 100%; }
.btn:active { transform: translateY(0); }
.btn[disabled] { opacity: .55; cursor: not-allowed; transform: none; }

/* Specular sweep on hover — a highlight travelling across the surface, the
   same cue a real glass button gives when light moves over it. */
.btn-solid::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(105deg, transparent 35%,
              rgba(255,255,255,.22) 50%, transparent 65%);
  transform: translateX(-120%);
  transition: transform .7s var(--ease-out);
}
.btn-solid:hover::after { transform: translateX(120%); }

/* ── Hero ──────────────────────────────────────────────────────────────────
   Two columns, not a centred stack: type holds the left edge, the product
   panel occupies the right and runs past the container's right boundary.

   The bleed matters. A visual that sits politely inside the content column
   reads as a screenshot pasted into a page; one that continues past the edge
   reads as a window onto something larger. It is the cheapest way to make a
   layout feel intentional, and it is why the hero is `overflow: hidden` at
   the section level rather than clipping the panel itself. */
.hero {
  position: relative;
  padding: clamp(130px, 15vh, 190px) 0 clamp(80px, 10vw, 130px);
  overflow: hidden;
}
.hero-grid {
  display: grid;
  /* Type column is the wider one. The panel is a supporting artefact, not a
     co-equal half — an even 50/50 split reads as a stock two-column template. */
  grid-template-columns: minmax(0, 1.06fr) minmax(0, .94fr);
  gap: clamp(30px, 4vw, 64px);
  align-items: center;
}
@media (max-width: 940px) {
  .hero { padding-top: 116px; text-align: left; }
  .hero-grid { grid-template-columns: 1fr; gap: 52px; }
}

/* Two offset colour fields, slowly drifting. Gives the backdrop something for
   the glass panes to actually refract — glass over flat white reads as grey. */
.hero-glow {
  position: absolute; top: -320px; left: 50%;
  width: 1100px; height: 820px;
  transform: translateX(-50%);
  pointer-events: none;
  background:
    radial-gradient(closest-side at 35% 40%, color-mix(in oklab, var(--accent) 26%, transparent), transparent),
    radial-gradient(closest-side at 68% 55%, color-mix(in oklab, var(--accent-lit) 20%, transparent), transparent);
  filter: blur(18px);
  opacity: .75;
}
:root[data-theme="dark"] .hero-glow { opacity: .5; }

/* ── Hero atmosphere ───────────────────────────────────────────────────────
   A second, much larger and softer field sitting lower than .hero-glow, which
   ends around y=500 and leaves the middle of the hero visibly empty. This
   fills the space between the buttons and the device so the section reads as
   one lit volume rather than two content blocks separated by a void.

   Deliberately very low contrast: it should register as depth, not as a
   visible shape. If you can point at where it ends, it is too strong. */
.hero-aura {
  position: absolute; top: 120px; left: 50%;
  width: min(1500px, 130vw); height: 1000px;
  transform: translateX(-50%);
  pointer-events: none;
  background:
    radial-gradient(closest-side at 50% 42%,
      color-mix(in oklab, var(--accent-lit) 13%, transparent), transparent 72%),
    radial-gradient(closest-side at 24% 68%,
      color-mix(in oklab, var(--accent) 10%, transparent), transparent 70%);
  filter: blur(46px);
  opacity: .55;
}
:root[data-theme="dark"] .hero-aura { opacity: .42; }

/* Cursor-tracked specular. --mx/--my are written by app.js (rAF-throttled,
   falling back to a static centre if JS never runs). This is the detail that
   makes the glass read as a real lit material: the highlight moves with the
   viewer rather than being painted on. Pointer-coarse devices skip it. */
.hero-spot {
  position: absolute; inset: 0;
  pointer-events: none;
  background: radial-gradient(
    620px circle at var(--mx, 50%) var(--my, 38%),
    color-mix(in oklab, var(--accent-lit) 16%, transparent),
    transparent 62%);
  opacity: 0;
  transition: opacity .7s var(--ease);
}
.pointer-fine .hero-spot { opacity: 1; }
@media (pointer: coarse) { .hero-spot { display: none; } }

.hero-inner { position: relative; }

/* Eyebrow as a bare mono label with a rule, not a pill. The pill was doing the
   same job as the glass cards below it and made the first thing on the page
   look like a button. A label + hairline reads as editorial masthead. */
.eyebrow {
  display: flex; align-items: center; gap: 13px;
  font: 500 var(--t-xs)/1 var(--mono);
  letter-spacing: .16em; text-transform: uppercase;
  color: var(--accent);
  margin-bottom: 26px;
}
.eyebrow::after {
  content: ""; flex: 0 1 84px; height: 1px;
  background: linear-gradient(90deg,
    color-mix(in oklab, var(--accent) 55%, transparent), transparent);
}

.hero-title {
  /* Capped tighter than --t-1's ceiling: beside a panel the headline has ~half
     the page to work with, and at 104px it broke to four ragged lines. Three
     controlled lines at a slightly smaller size reads bigger than four
     uncontrolled ones. */
  font-size: clamp(42px, 5.4vw, 76px);
  letter-spacing: -.045em;
  line-height: .98;              /* tight — display type needs less leading */
  margin-bottom: 26px;
  /* No text-wrap: balance — the <br> in the markup sets the break deliberately
     and balance would fight it. */
}
/* The clause the whole product hangs on. Setting it apart is the difference
   between a headline that states something and one that argues it. */
.hero-title em {
  font-style: normal;
  color: var(--accent);
}
.hero-sub {
  max-width: 42ch; margin: 0 0 38px;
  font-size: clamp(17px, 1.5vw, 19.5px); color: var(--text-2); line-height: 1.62;
}
.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; align-items: center; }
.hero-note {
  margin-top: 26px;
  font: 400 var(--t-xs)/1.5 var(--mono);
  letter-spacing: .04em;
  color: var(--text-2);
}

/* ── Hero product panel ────────────────────────────────────────────────────
   Bleeds past the right edge of the grid column and tilts slightly, so it
   reads as a live surface angled toward the viewer rather than a flat mock
   centred under the copy. */
.hero-device {
  position: relative;
  perspective: 1500px;
  /* Bleed past the container's right edge, clipped by .hero's overflow:hidden.
     Modest on purpose: enough that the panel reads as continuing off-screen,
     not so much that the status column is amputated. The status pills are the
     most meaningful thing in the mock and must stay fully visible. */
  margin-right: clamp(-96px, -5vw, -34px);
}
.device {
  --glass-blur: 18px;
  border-radius: var(--r-lg);
  text-align: left;
  /* Rotation is what makes it a surface in space rather than a pasted image.
     Kept small — past ~8deg the table text starts to smear at this width. */
  transform: perspective(1500px) rotateY(-7deg) rotateX(2.5deg);
  transform-origin: right center;
  /* No overflow:hidden — it would clip the ::after rim. Child rows are inset
     well within the radius, so nothing escapes anyway. */
  box-shadow:
    inset 0 1px 0 0 var(--glass-hi),
    inset 0 -1px 0 0 var(--glass-lo),
    inset 0 0 40px -12px var(--glass-hi),
    var(--shadow-lg);
}
@media (max-width: 940px) {
  /* Flat and fully in-frame once stacked: the tilt only reads correctly when
     the panel sits beside the type, and off-screen bleed on a phone is just
     lost content. !important is needed because the scroll-driven
     device-settle animation would otherwise re-apply the rotation here. */
  .hero-device { margin-right: 0; }
  .device { transform: none !important; }
}
@media (max-width: 560px) {
  /* Four columns do not fit a phone: site names wrapped mid-name and the
     status pills broke across two lines, which made the mock look broken
     rather than dense. The timestamp is the least load-bearing column — the
     claim is "who, where, what state", and the time supports it rather than
     carrying it — so it is the one that goes. */
  .dv-row {
    grid-template-columns: 1.5fr .85fr auto;
    gap: 10px; padding: 12px 14px;
    font-size: 13px;
  }
  .dv-time { display: none; }   /* hides both the data cells and the "In" header */
  .device-title { display: none; }
  .pill { padding: 3px 9px; font-size: 11.5px; white-space: nowrap; }
}
.device-bar {
  display: flex; gap: 7px; align-items: center;
  padding: 14px 20px;
  border-bottom: 1px solid var(--line);
  background: var(--glass-tint);
}
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--text-2); opacity: .3; }
/* Window chrome carries a title, like a real app pane. An unlabelled traffic
   light row is the most generic possible mock. */
.device-title {
  margin-left: 8px;
  font: 500 var(--t-xs)/1 var(--mono);
  letter-spacing: .06em; color: var(--text-2);
}
.device-live {
  margin-left: auto; display: flex; align-items: center; gap: 7px;
  font: 500 var(--t-xs)/1 var(--mono);
  letter-spacing: .08em; color: var(--text-2);
}
.device-live::before {
  content: ""; width: 6px; height: 6px; border-radius: 50%;
  background: #16a34a;
  box-shadow: 0 0 0 3px color-mix(in oklab, #16a34a 22%, transparent);
}
.device-body { padding: 6px 0 12px; }
.dv-row {
  display: grid; grid-template-columns: 1.45fr .95fr auto .85fr;
  gap: 14px; padding: 13px 22px;
  font-size: 14px;
  align-items: center;
  border-bottom: 1px solid var(--line);
}
.dv-row:last-child { border-bottom: 0; }
.dv-head {
  font: 500 11.5px/1 var(--mono);
  text-transform: uppercase; letter-spacing: .1em;
  color: var(--text-2);
}
.dv-dim { color: var(--text-2); }
/* Timestamps in mono and tabular so the column aligns digit-for-digit. This
   single detail does more to make the panel read as real software than any
   amount of glass on the container. */
.dv-time {
  font: 400 12.5px/1 var(--mono);
  font-variant-numeric: tabular-nums;
  color: var(--text-2);
  letter-spacing: -.01em;
}

/* ── Live row ──────────────────────────────────────────────────────────────
   The last device row swaps Scheduled → On site. Both states are stacked in
   the same grid cell so the row never reflows during the swap. Base state
   shows the RESOLVED value: with motion disabled, or if the animation never
   runs, the mock reads as a normal finished table rather than a half-state. */
.dv-swap { display: inline-grid; }
.dv-swap > * { grid-area: 1 / 1; }
.dv-was { opacity: 0; }
.dv-now { opacity: 1; }
.pill {
  display: inline-block; font-style: normal;
  padding: 3px 11px; border-radius: 999px;
  font-size: 12.5px; font-weight: 600;
  background: var(--bg-3); color: var(--text-2);
}
.pill-ok   { background: color-mix(in oklab, #16a34a 16%, transparent); color: #16a34a; }
.pill-done { background: var(--accent-soft); color: var(--accent); }
:root[data-theme="dark"] .pill-ok { color: #4ade80; }

/* ── Signup split ──────────────────────────────────────────────────────────
   The form sits beside a narrative column rather than under a centred title.
   The numbered steps do real work here: the main hesitation at a signup form
   is not knowing what happens after submitting, so the answer sits next to
   the button rather than somewhere further down the page. */
.signup-grid {
  display: grid;
  grid-template-columns: minmax(0, .85fr) minmax(0, 1fr);
  gap: clamp(36px, 6vw, 88px);
  align-items: start;
}
.signup-aside { position: sticky; top: 118px; }
.signup-title {
  font-size: var(--t-2); margin: 14px 0 18px;
  max-width: 14ch; text-wrap: balance;
}
.signup-lede {
  color: var(--text-2); max-width: var(--measure-tight);
  font-size: 17px; margin-bottom: 32px;
}
.signup-points { display: grid; gap: 16px; }
.signup-points li {
  display: grid; grid-template-columns: auto minmax(0,1fr);
  gap: 14px; align-items: baseline;
  font-size: var(--t-sm); color: var(--text-2);
  padding-top: 15px; border-top: 1px solid var(--line);
}
.signup-points b {
  font: 500 var(--t-xs)/1 var(--mono);
  color: var(--accent); letter-spacing: .08em;
}
@media (max-width: 900px) {
  .signup-grid { grid-template-columns: 1fr; gap: 40px; }
  .signup-aside { position: static; }
  .signup-title { max-width: none; }
}

/* ── Feature visuals ───────────────────────────────────────────────────────
   Each row's right-hand panel. These are small, specific depictions of the
   actual thing being described — a map fix, a checklist, a payroll run — not
   generic icons. An icon says "we have a feature"; a rendered fragment of the
   product says what the feature does. That difference is most of what makes a
   marketing page read as professional rather than templated. */
/* Surface, not glass — see the .ind/.tier note above. One hairline and one
   resting shadow; the inset highlight/shade pair went with the glass. */
.viz {
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: 22px;
  min-height: 236px;
  display: grid;
  box-shadow: var(--shadow-sm);
}
:root[data-theme="dark"] .viz {
  background: color-mix(in oklab, var(--bg-2) 70%, transparent);
  border-color: var(--shot-edge);
}

/* Map fix: a coordinate grid, a pin, and an accuracy ring. */
.viz-map {
  position: relative; border-radius: var(--r-md);
  background:
    linear-gradient(color-mix(in oklab, var(--line) 70%, transparent) 1px, transparent 1px) 0 0 / 100% 34px,
    linear-gradient(90deg, color-mix(in oklab, var(--line) 70%, transparent) 1px, transparent 1px) 0 0 / 34px 100%,
    color-mix(in oklab, var(--accent) 5%, transparent);
  overflow: hidden;
}
.viz-pin {
  position: absolute; left: 38%; top: 44%;
  width: 13px; height: 13px; border-radius: 50%;
  background: var(--accent-lit);
  border: 2.5px solid var(--bg);
  box-shadow: 0 2px 8px rgba(0,0,0,.28);
  z-index: 2;
}
.viz-ring {
  position: absolute; left: 38%; top: 44%;
  width: 96px; height: 96px; margin: -48px 0 0 -48px;
  border-radius: 50%;
  background: color-mix(in oklab, var(--accent-lit) 15%, transparent);
  border: 1px solid color-mix(in oklab, var(--accent-lit) 42%, transparent);
}
.viz-readout {
  position: absolute; left: 14px; right: 14px; bottom: 13px;
  display: grid; gap: 3px;
  padding: 11px 13px; border-radius: var(--r-sm);
  background: var(--glass-bg-strong);
  border: 1px solid var(--glass-border);
}
.viz-k { font-size: 13.5px; font-weight: 600; color: var(--heading); }
.viz-v {
  font: 400 11.5px/1.3 var(--mono);
  font-variant-numeric: tabular-nums;
  color: var(--text-2); letter-spacing: -.01em;
}

/* Checklist: completed items with times, one still open and blocking. */
.viz-check { display: grid; gap: 2px; align-content: center; }
.viz-check li {
  display: grid; grid-template-columns: auto minmax(0,1fr) auto;
  gap: 12px; align-items: center;
  padding: 12px 4px;
  font-size: 14px;
  border-bottom: 1px solid var(--line);
}
.viz-check li:last-child { border-bottom: 0; }
.viz-check i {
  width: 17px; height: 17px; border-radius: 50%;
  display: grid; place-items: center;
  background: color-mix(in oklab, #16a34a 18%, transparent);
}
.viz-check i::after {
  content: ""; width: 7px; height: 4px;
  border-left: 1.9px solid #16a34a; border-bottom: 1.9px solid #16a34a;
  transform: rotate(-45deg) translate(.5px, -1px);
}
.viz-check .is-open i { background: color-mix(in oklab, var(--accent) 14%, transparent); }
/* Open item shows a hollow ring, not a tick — the visual has to actually say
   "not done yet" or the whole point of the row is lost. */
.viz-check .is-open i::after {
  content: ""; width: 7px; height: 7px; border-radius: 50%;
  border: 1.9px solid var(--accent); transform: none;
}
.viz-check .is-open { color: var(--text-2); }
.viz-check b {
  font: 400 11.5px/1 var(--mono);
  font-variant-numeric: tabular-nums;
  color: var(--text-2); font-weight: 400;
}

/* Payroll run: names, hours, state. */
.viz-pay { display: grid; gap: 2px; align-content: center; }
.viz-pay li {
  display: grid; grid-template-columns: minmax(0,1fr) auto auto;
  gap: 14px; align-items: center;
  padding: 13px 4px;
  font-size: 14px;
  border-bottom: 1px solid var(--line);
}
.viz-pay li:last-child { border-bottom: 0; }
.viz-pay b {
  font: 500 13px/1 var(--mono);
  font-variant-numeric: tabular-nums;
  color: var(--heading);
}
.pill-warn {
  background: color-mix(in oklab, #d97706 17%, transparent);
  color: #b45309;
}
:root[data-theme="dark"] .pill-warn { color: #fbbf24; }

/* ── Bands ─────────────────────────────────────────────────────────────────
   Density varies per section rather than every band sharing one padding
   value. Uniform vertical rhythm is what makes a long page feel like a
   scrolling list of identical slabs. */
.band { padding: var(--pad-loose) 0; position: relative; }
.band-tight { padding-block: var(--pad-tight); }

/* ── Stat strip ────────────────────────────────────────────────────────────
   A dense, dark, full-bleed band. Its job is contrast: it sits between two
   airy sections and resets the eye, and it is the one place on the page where
   information is packed tightly rather than spaced generously.

   Always dark in both themes — this is a deliberate inversion, not a themed
   surface, so it reads as a distinct material. */
.stats {
  background: #0b1220;
  border-block: 1px solid rgba(148,180,220,.14);
  padding-block: clamp(46px, 5.5vw, 78px);
  position: relative; overflow: hidden;
}
.stats::before {
  content: ""; position: absolute; inset: 0;
  pointer-events: none;
  background:
    radial-gradient(70% 120% at 15% 0%,
      color-mix(in oklab, var(--accent-lit) 16%, transparent), transparent 70%),
    radial-gradient(60% 120% at 88% 100%,
      color-mix(in oklab, var(--accent) 18%, transparent), transparent 72%);
  opacity: .55;
}
.stats > * { position: relative; z-index: 1; }
.stat-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0,1fr));
  gap: clamp(20px, 3vw, 44px);
}
.stat-lead {
  grid-column: 1 / -1;
  font: 500 var(--t-xs)/1 var(--mono);
  letter-spacing: .16em; text-transform: uppercase;
  /* Was a hardcoded #7fd4e8, the last off-token colour on the site. */
  color: var(--accent-lit);
  margin-bottom: 6px;
}
.stat n {
  display: block;
  font-size: clamp(30px, 3.4vw, 46px);
  font-weight: 600; letter-spacing: -.035em; line-height: 1;
  font-variant-numeric: tabular-nums;
  color: #f2f6fc;
  margin-bottom: 9px;
}
.stat p {
  font-size: var(--t-sm); line-height: 1.5;
  color: rgba(190,210,235,.72);
  max-width: 26ch;
}
/* The claim leads, the explanation follows. Three of these four figures are
   "0", so the number alone said nothing — a row reading 0 / 0 / 3 / 0 gives a
   skimmer no reason to stop. The bold clause is what they actually read. */
.stat p b {
  display: block;
  color: rgba(236,246,255,.96);
  font-weight: 600;
  margin-bottom: 4px;
}
@media (max-width: 760px) {
  .stat-grid { grid-template-columns: repeat(2, minmax(0,1fr)); gap: 30px 22px; }
}
.band-alt {
  background: var(--bg-2);
  border-block: 1px solid var(--line);
  /* Clips the parallax layer, which is inset -40px so it has room to drift
     without exposing an edge at either end of the band. */
  overflow: hidden;
  /* No isolation/z-index here on purpose: the parallax ::before sits at
     z-index 0 and the band's own children are lifted above it, rather than
     pushing the layer to -1 where this background would paint over it. */
}
.band-alt > * { position: relative; z-index: 1; }

/* .section-title / .section-lede were the centred title+lede pair every band
   shared. Replaced by .opener (left-aligned, numbered, asymmetric) — the
   uniformity of the old pair was the main reason sections read as templated. */

.grid-3 { display: grid; grid-template-columns: repeat(auto-fit, minmax(272px, 1fr)); gap: 24px; }

/* ── Cards ───────────────────────────────────────────────────────────────── */
/* Cards reuse the .glass layer stack (they carry the class in markup) and add
   only geometry and hover behaviour here. */
.card, .ind, .tier {
  --glass-blur: 18px;
  border-radius: var(--r-md);
  padding: 34px 30px;
  box-shadow:
    inset 0 1px 0 0 var(--glass-hi),
    inset 0 -1px 0 0 var(--glass-lo),
    inset 0 0 24px -8px var(--glass-hi),
    var(--shadow-sm);
  transition: transform .38s var(--ease-out), box-shadow .38s var(--ease-out);
}
.card:hover, .ind:hover, .tier:hover {
  transform: translateY(-4px);
  box-shadow:
    inset 0 1px 0 0 var(--glass-hi),
    inset 0 -1px 0 0 var(--glass-lo),
    inset 0 0 28px -6px var(--glass-hi),
    var(--shadow-md);
}
/* The rim brightens toward the accent on hover — light catching an edge as the
   surface tilts, rather than a border-colour swap. */
.card:hover::after, .ind:hover::after, .tier:hover::after {
  background: linear-gradient(140deg,
              var(--glass-rim-hi) 0%,
              color-mix(in oklab, var(--accent) 45%, var(--glass-rim-mid)) 50%,
              var(--glass-rim-lo) 100%);
}

.card-icon {
  position: relative;
  width: 46px; height: 46px; margin-bottom: 22px;
  display: grid; place-items: center;
  border-radius: 13px;
  background: var(--accent-soft);
  border: 1px solid var(--accent-hair);
  box-shadow: inset 0 1px 0 0 var(--glass-hi);
}
.card-icon svg {
  width: 22px; height: 22px;
  fill: none; stroke: var(--accent);
  stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round;
}
.card h3 { font-size: 19px; margin-bottom: 9px; }
.card p  { color: var(--text-2); font-size: 15.5px; }

/* ── Content cards ────────────────────────────────────────────────────────
   These carried no surface of their own: they leaned on .glass, which was
   applied to eleven elements across the page. Glass is a strong material —
   once it is everywhere it stops reading as depth and becomes wallpaper, so
   it now appears twice (the hero device and the signup card) where depth
   actually means something.

   A hairline replaces it. It separates two things more cheaply and more
   crisply than a shadow, and unlike a shadow it survives dark mode. */
.ind, .tier {
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: 26px 24px;
}
:root[data-theme="dark"] .ind,
:root[data-theme="dark"] .tier {
  background: color-mix(in oklab, var(--bg-2) 70%, transparent);
  border-color: var(--shot-edge);
}

.ind h3 { font-size: 20px; margin-bottom: 6px; }
.ind-terms {
  font-size: 13px; color: var(--accent); font-weight: 500;
  margin-bottom: 20px; padding-bottom: 18px;
  border-bottom: 1px solid var(--line);
}
.ind ul li, .tier ul li {
  position: relative; padding-left: 25px; margin-bottom: 11px;
  font-size: 15px; color: var(--text-2); line-height: 1.5;
}
.ind ul li::before, .tier ul li::before {
  content: ""; position: absolute; left: 4px; top: 7px;
  width: 5px; height: 9px;
  border-right: 2px solid var(--accent); border-bottom: 2px solid var(--accent);
  transform: rotate(42deg);
}

/* ── Tiers ───────────────────────────────────────────────────────────────── */
.tier-featured {
  /* contain: paint on .tier clips anything drawn outside the box — which
     cropped the badge sitting above the top edge. Visible overflow is safe
     here; the blur is still bounded by the backdrop-filter itself. */
  contain: none;
  overflow: visible;
  border-color: var(--accent-mark);
  background: var(--glass-bg-strong);
  /* Physically raised above its neighbours rather than only outlined: a wider,
     softer, accent-tinted shadow plus a small lift. Outline-only emphasis is
     what makes pricing tables look flat and generic. */
  transform: translateY(-10px);
  z-index: 2;
  box-shadow:
    inset 0 1px 0 0 var(--glass-hi),
    inset 0 -1px 0 0 var(--glass-lo),
    0 0 0 1px color-mix(in oklab, var(--accent) 18%, transparent),
    0 28px 60px -28px color-mix(in oklab, var(--accent) 42%, transparent),
    var(--shadow-md);
}
/* The lift is a layout emphasis, not motion — but it must compose with the
   reveal transform rather than fight it, so it is dropped while the card is
   animating in and applied only at rest. */
@media (prefers-reduced-motion: no-preference) {
  @supports (animation-timeline: view()) {
    .tier-featured { transform: none; }
  }
}
@media (max-width: 600px) {
  .tier-featured { transform: none; }
}
.tier-badge {
  position: absolute; top: -12px; left: 30px;
  /* Above the glass sheen ::before (inset 0, opacity .9), which otherwise
     sweeps its highlight band straight across the badge and reads as a
     strikethrough through the text. The badge is opaque chrome sitting on the
     card edge, not part of the glass surface, so it belongs above that layer. */
  z-index: 1;
  background: var(--accent); color: #fff;
  font-size: 11.5px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase;
  padding: 4px 13px; border-radius: 999px;
  box-shadow: 0 3px 10px color-mix(in oklab, var(--accent) 40%, transparent);
}
:root[data-theme="dark"] .tier-badge { color: #06202b; }
.tier h3 { font-size: 21px; margin-bottom: 5px; }
.tier-for {
  font-size: 14px; color: var(--text-2);
  margin-bottom: 22px; padding-bottom: 20px; border-bottom: 1px solid var(--line);
}
.tier strong { color: var(--text); font-weight: 600; }

/* ── Form ────────────────────────────────────────────────────────────────── */
.form-card {
  position: relative;
  background: var(--glass-bg-strong);
  border: 1px solid var(--glass-border);
  border-radius: var(--r-lg);
  padding: 40px 36px;
  box-shadow:
    inset 0 1px 0 0 var(--glass-hi),
    inset 0 -1px 0 0 var(--glass-lo),
    var(--shadow-lg);
}
.field { margin-bottom: 20px; }
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 560px) { .field-row { grid-template-columns: 1fr; } }

label { display: block; margin-bottom: 7px; font-size: 14.5px; font-weight: 500; color: var(--text); }
label span { color: var(--accent); }

input {
  width: 100%; padding: 12px 15px;
  border: 1px solid var(--line); border-radius: var(--r-sm);
  background: var(--bg);
  font: inherit; font-size: 16px;   /* 16px stops iOS zooming on focus */
  color: var(--text);
  transition: border-color .2s var(--ease), box-shadow .2s var(--ease),
              background-color .2s var(--ease);
}
input:focus {
  outline: none;
  border-color: var(--accent);
  box-shadow: 0 0 0 3.5px color-mix(in oklab, var(--accent) 18%, transparent);
}
input[aria-invalid="true"] { border-color: #dc2626; }
.hint { margin-top: 7px; font-size: 13.5px; color: var(--text-2); }

/* Off-screen rather than display:none — some bots skip hidden inputs. */
.hp { position: absolute !important; left: -9999px !important; width: 1px; height: 1px; overflow: hidden; }

.form-status { margin-top: 16px; font-size: 15px; min-height: 22px; text-align: center; }
.form-status.is-error { color: #dc2626; }
.form-status.is-ok    { color: #16a34a; }

/* ── Footer ──────────────────────────────────────────────────────────────── */
.footer {
  padding: 58px 0;
  background: var(--bg-2);
  border-top: 1px solid var(--line);
  color: var(--text-2);
}
.footer-inner { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
.footer-note { font-size: 14.5px; margin-left: 8px; }
.footer-links { display: flex; gap: 22px; margin-left: auto; }
.footer-links a { font-size: 14.5px; transition: color .2s var(--ease); }
.footer-links a:hover { color: var(--heading); }

@media (max-width: 720px) {
  .nav-links { display: none; }
  .hero { padding-top: 120px; }
  .band { padding: 84px 0; }
  .footer-inner { flex-direction: column; align-items: flex-start; }
  .footer-links { margin-left: 0; }
}

/* With .nav-links hidden the bar still held a theme toggle and two full-size
   buttons, which overflowed the viewport below ~420px — "Get started" was
   clipped off the right edge. Tighten the row and drop the secondary action:
   Sign in still lives in the footer, and the primary CTA is what matters
   here. */
@media (max-width: 460px) {
  .nav-inner { gap: 12px; }
  .nav-cta .btn-ghost { display: none; }
  .nav-cta .btn { padding-inline: 16px; font-size: 14px; }
  .brand-word { font-size: 18px; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   MOTION — all inside prefers-reduced-motion: no-preference, so a user who
   asks for less motion gets the finished layout immediately and never risks
   content stuck at opacity:0.
   ═══════════════════════════════════════════════════════════════════════════ */
@media (prefers-reduced-motion: no-preference) {

  @keyframes reveal-up {
    from { opacity: 0; transform: translate3d(0, 22px, 0); }
    to   { opacity: 1; transform: none; }
  }
  @keyframes reveal-scale {
    from { opacity: 0; transform: translate3d(0, 30px, 0) scale(.975); }
    to   { opacity: 1; transform: none; }
  }
  /* Cards lift and settle — a slight rotation gives the reveal weight. */
  @keyframes reveal-card {
    from { opacity: 0; transform: translate3d(0, 26px, 0) rotateX(5deg); }
    to   { opacity: 1; transform: none; }
  }
  @keyframes drift {
    from { transform: translateX(-50%) translateY(0)     scale(1); }
    to   { transform: translateX(-50%) translateY(-26px) scale(1.06); }
  }

  /* The hero glow drifts against the scroll, so the glass panes above it have
     moving colour to refract rather than a static wash. */
  /* scroll(root), not scroll(): `body { overflow-x: hidden }` makes body a
     scroll container, so a bare scroll() binds to body — which barely scrolls
     — and this drift never actually advanced. */
  .hero-glow {
    animation: drift linear both;
    animation-timeline: scroll(root);
    animation-range: 0 120vh;
  }

  /* ── Scroll-linked glass ───────────────────────────────────────────────────
     The hero device settles as you scroll into it: tilt flattens, scale
     resolves, and the pane brightens. Scroll POSITION drives the animation
     rather than a clock, so it tracks the finger exactly and reverses when
     you scroll back — that reversibility is what separates scroll-driven
     from a triggered animation.

     transform and opacity only, so the whole thing stays on the compositor. */
  /* Resolves TO the panel's tilted rest pose, not to flat. `animation` with
     `both` holds the final frame forever, so a `to` of rotateY(0) would
     silently cancel the .device transform and kill the tilt permanently.
     Keep these two in sync: the `to` here must match .device's transform. */
  @keyframes device-settle {
    from {
      transform: perspective(1500px) rotateY(-13deg) rotateX(7deg) scale(.95) translateY(22px);
      opacity: .82;
    }
    to {
      transform: perspective(1500px) rotateY(-7deg) rotateX(2.5deg) scale(1) translateY(0);
      opacity: 1;
    }
  }
  @supports (animation-timeline: view()) {
    .hero-device .device {
      animation: device-settle linear both;
      animation-timeline: view();
      animation-range: entry 12% cover 46%;
    }

    /* The shadow deepens as the device rises. Kept on a separate element-free
       keyframe (box-shadow is not compositor-accelerated) but it animates over
       a short range and only on one element, which is affordable — the payoff
       is that the device reads as lifting off the page rather than just
       scaling. */
    /* NOTE: .hero-device's full animation list is NOT declared here. It also
       matches `.hero .reveal-scale` (~L1533), which is HIGHER specificity
       (0-2-0) than a bare `.hero-device` and replaces whatever this rule sets.
       Declaring the shadow here and the recede there would mean neither
       survives. All three are set together in the hero-intro block below,
       under a `.hero .hero-device` selector that matches that specificity. */
  }
  @keyframes device-lift {
    from { filter: drop-shadow(0 18px 30px rgba(12,22,40,.10)); }
    to   { filter: drop-shadow(0 42px 72px rgba(12,22,40,.22)); }
  }
  /* The dark-mode shadow swap lives with the rest of .hero-device's animation
     list (~L1550), since animation-name is a list and must name all three in
     order — overriding it here would silently drop the intro and the exit. */
  @keyframes device-lift-dark {
    from { filter: drop-shadow(0 18px 30px rgba(0,0,0,.34)); }
    to   { filter: drop-shadow(0 46px 80px rgba(0,0,0,.58)); }
  }

  /* ── Progress rail ─────────────────────────────────────────────────────────
     A hairline under the nav tracking read position. scroll() maps the whole
     document to 0–100%, so this is genuinely one declaration — the kind of
     thing that used to need a scroll listener and layout reads every frame. */
  @keyframes rail { to { transform: scaleX(1); } }
  .nav::after {
    content: "";
    position: absolute; left: 0; right: 0; bottom: -1px;
    height: 1.5px;
    /* Tapered rather than a flat bar: transparent at the far left, brightest
       at the leading edge, with a soft glow under it. A hard 2px line across
       the full viewport is the single most template-looking element a page
       can have — the taper is what makes it read as considered. */
    background:
      linear-gradient(90deg,
        transparent 0%,
        color-mix(in oklab, var(--accent) 55%, transparent) 18%,
        var(--accent) 62%,
        var(--accent-lit) 100%);
    box-shadow: 0 0 12px -2px color-mix(in oklab, var(--accent-lit) 70%, transparent);
    transform: scaleX(0); transform-origin: left;
    /* Fades in over the first stretch, so it is absent at the top of the page
       where there is no progress to report. */
    opacity: 0;
    animation: rail linear both, rail-fade linear both;
    /* scroll(root) — see .hero-glow: body is a scroll container here, so a
       bare scroll() tracks body rather than the document and the rail never
       fills. */
    animation-timeline: scroll(root), scroll(root);
    animation-range: normal, 0 8%;
  }
  @keyframes rail-fade { to { opacity: 1; } }

  /* ── Glass sheen tracking scroll ───────────────────────────────────────────
     The specular highlight sweeps across each pane as it crosses the viewport,
     the way a reflection travels over real glass when you move past it. The
     surface reads as reflective rather than merely translucent — and it costs
     one background-position animation, which the compositor handles.

     Cards only. On every glass element it would be noise.

     These elements are `class="card glass"`, so this does NOT create a new
     pseudo-element — it targets the same ::before that .glass::before defines
     (~L211) and overrides its background. content/position/inset are inherited
     from there. Equal specificity, so this only wins by coming later in the
     file: if the .glass block is ever moved below this one, the sheen dies
     silently. background-repeat is set here because the base rule omits it and
     a 220% background-size would otherwise tile. */
  @keyframes sheen-travel {
    from { background-position: -140% 0; }
    to   { background-position:  240% 0; }
  }
  @supports (animation-timeline: view()) {
    .card::before,
    .ind::before,
    .tier::before {
      background:
        linear-gradient(115deg,
          transparent 38%,
          var(--glass-sheen) 50%,
          transparent 62%),
        linear-gradient(140deg, var(--glass-sheen) 0%, transparent 34%,
                        transparent 66%, var(--glass-tint) 100%);
      background-size: 220% 100%, 100% 100%;
      background-repeat: no-repeat;
      animation: sheen-travel linear both;
      animation-timeline: view();
      animation-range: entry 0% exit 100%;
    }
  }

  /* ── Section headings ──────────────────────────────────────────────────────
     Headings clip upward into view — the text is revealed by a moving mask
     rather than faded, which reads as more deliberate than opacity alone.

     Every .opener-title also carries .reveal, so the generic reveal-up rule
     above matches these too. `animation` is a shorthand: whichever rule comes
     last replaces the other outright, it does not merge them. Rather than let
     source order decide silently, this keyframe absorbs the fade itself and
     the selector is scoped so only headings take it. */
  @keyframes heading-wipe {
    from {
      opacity: 0;
      clip-path: inset(0 0 100% 0);
      transform: translate3d(0, 14px, 0);
    }
    to {
      opacity: 1;
      clip-path: inset(0 0 0 0);
      transform: none;
    }
  }
  @supports (animation-timeline: view()) {
    .opener-title.reveal:not(.hero *),
    .signup-title.reveal {
      animation: heading-wipe .9s var(--ease-out) both;
      animation-timeline: view();
      animation-range: entry 8% cover 34%;
    }
  }

  /* ── Parallax depth ────────────────────────────────────────────────────────
     Band backgrounds drift slower than the content above them. Small offsets
     only (24px): large parallax reads as a broken layout, and it is the most
     commonly overdone effect on the web. */
  @keyframes band-drift {
    from { transform: translateY(-16px); }
    to   { transform: translateY(16px); }
  }
  @supports (animation-timeline: view()) {
    .band-alt::before {
      content: "";
      position: absolute; inset: -40px 0;
      z-index: 0;
      pointer-events: none;
      background:
        radial-gradient(60% 50% at 18% 22%,
          color-mix(in oklab, var(--accent) 12%, transparent), transparent 70%),
        radial-gradient(55% 45% at 82% 72%,
          color-mix(in oklab, var(--accent-lit) 10%, transparent), transparent 70%);
      animation: band-drift linear both;
      animation-timeline: view();
    }
  }

  @supports (animation-timeline: view()) {
    .reveal:not(.hero *):not(.hero-device),
    .reveal-scale:not(.hero *):not(.hero-device) {
      animation: reveal-up .8s var(--ease-out) both;
      animation-timeline: view();
      animation-range: entry 4% cover 32%;
    }
    .reveal-scale:not(.hero *):not(.hero-device) {
      animation-name: reveal-scale; animation-duration: .9s;
    }

    /* Cards reveal on their own timeline and stagger by column, so a row
       arrives as a sequence rather than a single block. */
    .grid-3 > * {
      animation: reveal-card .85s var(--ease-out) both;
      animation-timeline: view();
      animation-range: entry 2% cover 30%;
    }
    .grid-3 > *:nth-child(2) { animation-range: entry 6%  cover 34%; }
    .grid-3 > *:nth-child(3) { animation-range: entry 10% cover 38%; }

    /* .grid-3 is auto-fit/minmax(272px), so below ~600px it collapses to a
       single column. The stagger above is a horizontal idea — offsetting a
       card that is now BELOW its sibling makes it late relative to its own
       scroll position, which reads as lag rather than sequence. Once stacked,
       every card runs the same range and the layout provides the stagger. */
    @media (max-width: 600px) {
      .grid-3 > *,
      .grid-3 > *:nth-child(2),
      .grid-3 > *:nth-child(3) { animation-range: entry 2% cover 30%; }
    }

    /* ── Featured tier ──────────────────────────────────────────────────────
       The highlighted plan lifts slightly further and holds a touch longer, so
       the eye lands on it last in the row. Same keyframe, later range only. */
    .tiers > .tier-featured { animation-range: entry 8% cover 40%; }
  }

  /* The hero sits above the fold, so a view() timeline would never advance —
     its elements would stay pinned at 0% and the headline would never appear.
     Plain clock-driven intro instead, staggered. */
  /* Delays are absolute from page load and offset past the veil lift (which
     begins at 1.35s), so the hero resolves as the veil clears.

     Sequenced purely in CSS on purpose. An earlier version paused these with a
     class that JS removed on a timer set to the same moment the lift began —
     the two raced, and the hero could stay stuck at opacity:0 with the veil
     already gone. One clock, no coordination needed. --intro is on :root. */
  .hero .reveal,
  .hero .reveal-scale { animation: reveal-up .8s var(--ease-out) both; }
  .hero .reveal-scale { animation-name: reveal-scale; animation-duration: .95s; }
  .hero .eyebrow      { animation-delay: calc(var(--intro) + .04s); }
  .hero .hero-title   { animation-delay: calc(var(--intro) + .12s); }
  .hero .hero-sub     { animation-delay: calc(var(--intro) + .24s); }
  .hero .hero-actions { animation-delay: calc(var(--intro) + .36s); }
  .hero .hero-note    { animation-delay: calc(var(--intro) + .46s); }
  /* .hero-device carries three animations at once, and they must all be
     declared HERE — the `.hero .reveal-scale` shorthand two rules up matches
     this element too and would otherwise replace anything set earlier in the
     file (including the device-lift shadow, which is why that rule ~L1349
     deliberately declares nothing).

       reveal-scale  the clock-driven intro, delayed past the veil lift
       device-lift   shadow deepening as the panel arrives (filter only)
       hero-recede   the scroll-driven exit

     Safe to combine: reveal-scale and hero-recede both touch transform and
     opacity, but their ranges never overlap — the intro is a fixed .95s clock
     that has long finished before 34vh of scroll — and device-lift animates
     only `filter`. The pane's own tilt lives on .device inside this element,
     which is what keeps device-settle from composing with hero-recede.

     scroll(root), not scroll(): `body { overflow-x: hidden }` makes body a
     scroll container, so a bare scroll() would bind to an element that never
     scrolls and the exit would silently never advance. */
  /* `.hero .hero-device`, not `.hero-device`: `.hero .reveal-scale` above is
     specificity 0-2-0 and this element carries that class, so a bare
     `.hero-device` (0-1-0) loses on specificity no matter how far down the
     file it sits — the whole list here would silently collapse to just the
     intro. The descendant selector matches 0-2-0 and wins on source order. */
  .hero .hero-device {
    animation: reveal-scale .95s var(--ease-out) both,
               device-lift linear both,
               hero-recede linear forwards;
    animation-delay: calc(var(--intro) + .54s), 0s, 0s;
    animation-timeline: auto, view(), scroll(root);
    animation-range: normal, entry 12% cover 46%, 34vh 100vh;
  }
  :root[data-theme="dark"] .hero-device {
    animation-name: reveal-scale, device-lift-dark, hero-recede;
  }

  /* ── Live row swap ────────────────────────────────────────────────────────
     Fires once, ~1.1s after the device lands, so it reads as an update
     arriving rather than part of the intro. Clock-driven for the same reason
     the hero intro is: the device is above the fold, so a view() timeline
     would never advance. Row dims back to normal weight as it activates.

     Base state is the resolved value, so `both` holding the final frame is
     also the correct resting state — nothing to clean up. */
  @keyframes swap-out { from { opacity: 1; } to { opacity: 0; } }
  @keyframes swap-in  { from { opacity: 0; } to { opacity: 1; } }
  @keyframes row-wake {
    from { color: var(--text-2); }
    to   { color: var(--text); }
  }
  .dv-live .dv-was {
    opacity: 1;
    animation: swap-out .45s var(--ease-out) both;
    animation-delay: calc(var(--intro) + 1.65s);
  }
  .dv-live .dv-now {
    opacity: 0;
    animation: swap-in .45s var(--ease-out) both;
    animation-delay: calc(var(--intro) + 1.78s);
  }
  .dv-live {
    animation: row-wake .6s var(--ease-out) both;
    animation-delay: calc(var(--intro) + 1.65s);
  }

  /* ── Hero exit ─────────────────────────────────────────────────────────────
     device-settle above resolves the panel as you scroll INTO it and then
     holds that pose forever, so scrolling past left the hero sitting flat and
     fully lit while the stat strip took over the screen. The hero now recedes
     on its way out: it tilts back toward its rest angle, drops slightly, and
     dims — the mirror of its arrival.

     Driven by scroll(), NOT view(). The obvious choice here is a view()
     timeline over the `exit` range, and it silently does nothing: the hero is
     shorter than the viewport (~713px against 900), so it is never fully
     scrolled past and its exit range barely advances — the animations attach,
     report a NEGATIVE currentTime, and hold their first frame forever. Found
     by rendering and reading getAnimations(), not by reading the CSS.

     scroll(root) maps document scroll position instead, which is what "the
     hero is leaving" actually means on a page whose first section is short.

     It must be scroll(ROOT), not bare scroll(). Bare scroll() resolves to the
     nearest scrollable ancestor, and `body { overflow-x: hidden }` (~L182)
     makes BODY a scroll container — so the timeline attaches to an element
     that barely scrolls and currentTime sits pinned at a negative value. The
     animation binds, reports a real ScrollTimeline, and still never advances.

     Two elements rather than one keyframe with four stops, because the ranges
     differ: the device recedes over a shorter window than the text column. */
  /* Applied to .hero-device (the outer column), which has no transform of its
     own — so this starts from `none` rather than restating .device's tilt. The
     pane's own rotation lives on .device inside and composes as a child. */
  @keyframes hero-recede {
    from { transform: none;                              opacity: 1;   }
    to   { transform: scale(.94) translateY(-30px);      opacity: .55; }
  }
  /* The text column drifts up faster than the device, opening a little
     parallax between the two halves of the hero as they leave. */
  @keyframes hero-text-exit {
    from { transform: translateY(0);     opacity: 1; }
    to   { transform: translateY(-52px); opacity: .3; }
  }
  @supports (animation-timeline: view()) {
    /* device-settle and hero-recede must NOT sit on the same element. Both
       animate transform+opacity; the settle is `both`-filled and finishes
       early, and a finished-but-filling animation still composes with the
       running one. The device then reported hero-recede at 9% progress while
       painting ~70% of its effect — computed style and currentTime openly
       disagreed, and the hero rendered pre-dimmed at scroll top.

       Isolating hero-recede on its own element made it behave exactly as
       written (opacity 1 → .55 across the range), which is what identified
       the composition as the fault rather than the timeline or the fill.

       So: the recede goes on .hero-device (the outer column) and the settle
       stays on .device (the inner pane). Different elements, no composition,
       and the two transforms nest correctly — the pane's tilt is applied
       inside the column's recede, which is also the right visual model.

       hero-recede is declared up with .hero-device's existing device-lift rule
       (~L1341), not here, because that element already has an `animation`
       shorthand and a second rule would replace it rather than extend it.

       `forwards`, not `both`: these keyframes describe the hero LEAVING, so a
       backward fill would paint the receded pose before the range is entered. */
    .hero-inner {
      animation: hero-text-exit linear forwards;
      animation-timeline: scroll(root);
      animation-range: 22vh 92vh;
    }
  }

  /* ── Directional row entry ─────────────────────────────────────────────────
     The alternating rows had the generic reveal-up, which meant a layout built
     entirely on left/right alternation announced itself with pure vertical
     motion — the structure and the animation were saying different things.
     Each half now enters from the side it occupies, so the row assembles
     toward its centre and the flip is legible in the motion itself.

     Small offsets (30px). Full-width slide-ins are the most overused entrance
     on the web and they read as a template immediately. */
  @keyframes enter-left {
    from { opacity: 0; transform: translate3d(-30px, 12px, 0); }
    to   { opacity: 1; transform: none; }
  }
  @keyframes enter-right {
    from { opacity: 0; transform: translate3d(30px, 12px, 0); }
    to   { opacity: 1; transform: none; }
  }
  @supports (animation-timeline: view()) {
    /* Default row: text left, visual right. */
    .row > .row-text.reveal {
      animation: enter-left .85s var(--ease-out) both;
      animation-timeline: view();
      animation-range: entry 4% cover 34%;
    }
    .row > .row-visual.reveal-scale {
      animation: enter-right .9s var(--ease-out) both;
      animation-timeline: view();
      animation-range: entry 8% cover 38%;
    }
    /* Flipped row: the visual leads on the left, so the directions swap with
       it. Without this the motion would contradict the layout on every other
       row, which is worse than no directional motion at all. */
    .row-flip > .row-text.reveal    { animation-name: enter-right; }
    .row-flip > .row-visual.reveal-scale { animation-name: enter-left; }

    /* Below 860px .row collapses to one column and .row-flip's order is reset
       (see the layout media query), so horizontal entry no longer corresponds
       to anything on screen. Back to vertical. */
    @media (max-width: 860px) {
      .row > .row-text.reveal,
      .row > .row-visual.reveal-scale,
      .row-flip > .row-text.reveal,
      .row-flip > .row-visual.reveal-scale {
        animation-name: reveal-up;
      }
    }
  }

  /* ── Living visuals ────────────────────────────────────────────────────────
     The three .viz panels were static once revealed. Each now has exactly one
     small ongoing signal — the thing that panel is actually about. One idea
     per panel; a visual where everything moves is a screensaver.

     All three are infinite, so they are deliberately low-contrast and slow.
     Motion that repeats forever in peripheral vision has to sit below the
     threshold of distraction or it competes with the copy beside it. */

  /* GPS accuracy ring pulses outward, the way a fix refreshes on a map. */
  @keyframes gps-pulse {
    0%   { transform: scale(.82); opacity: .85; }
    70%  { transform: scale(1.18); opacity: 0; }
    100% { transform: scale(1.18); opacity: 0; }
  }
  .viz-ring {
    transform-origin: center;
    animation: gps-pulse 3.2s var(--ease-out) infinite;
  }
  /* A second ring, offset half a cycle, so the pulse reads as continuous
     rather than as a single ping with a long dead gap after it. */
  .viz-map::after {
    content: "";
    position: absolute; left: 38%; top: 44%;
    width: 96px; height: 96px; margin: -48px 0 0 -48px;
    border-radius: 50%;
    border: 1px solid color-mix(in oklab, var(--accent-lit) 34%, transparent);
    transform-origin: center;
    animation: gps-pulse 3.2s var(--ease-out) infinite 1.6s;
    pointer-events: none;
  }
  /* The pin itself holds a soft halo that breathes on the same clock. */
  @keyframes pin-breathe {
    0%, 100% { box-shadow: 0 2px 8px rgba(0,0,0,.28),
                           0 0 0 0 color-mix(in oklab, var(--accent-lit) 55%, transparent); }
    50%      { box-shadow: 0 2px 8px rgba(0,0,0,.28),
                           0 0 0 5px color-mix(in oklab, var(--accent-lit) 0%, transparent); }
  }
  .viz-pin { animation: pin-breathe 3.2s var(--ease) infinite; }

  /* Checklist: the one open item is what the panel is about, so it is the only
     thing that moves — its hollow ring waits. */
  @keyframes await-pulse {
    0%, 100% { opacity: 1; }
    50%      { opacity: .45; }
  }
  .viz-check .is-open i { animation: await-pulse 2.4s var(--ease) infinite; }

  /* Payroll: the overtime flag is the exception the copy promises will
     "surface on its own", so it is the row that draws the eye. */
  @keyframes flag-glow {
    0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, #d97706 40%, transparent); }
    50%      { box-shadow: 0 0 0 4px color-mix(in oklab, #d97706 0%, transparent); }
  }
  .viz-pay .pill-warn { animation: flag-glow 2.8s var(--ease) infinite; }

  /* ── Stat strip ────────────────────────────────────────────────────────────
     The figures count up in JS (see app.js); the strip itself gets a slow
     wash so the dark band is not the one inert surface on a page where
     everything else responds to scroll. */
  @keyframes stat-wash {
    from { transform: translate3d(-3%, 0, 0) scale(1.04); }
    to   { transform: translate3d(3%, 0, 0)  scale(1.04); }
  }
  @supports (animation-timeline: view()) {
    .stats::before {
      animation: stat-wash linear both;
      animation-timeline: view();
    }
  }
  /* Each figure lifts on its own slight offset, so the four arrive as a run
     rather than a block. The strip is 4-up on desktop and 2-up under 760px;
     the offsets are small enough to still read correctly when it reflows. */
  @supports (animation-timeline: view()) {
    .stat.reveal:nth-of-type(2) { animation-range: entry 6%  cover 34%; }
    .stat.reveal:nth-of-type(3) { animation-range: entry 9%  cover 37%; }
    .stat.reveal:nth-of-type(4) { animation-range: entry 12% cover 40%; }
  }

  /* ── Nav brand ─────────────────────────────────────────────────────────────
     The mark's check draws itself once the loader is gone, tying the nav to
     the entrance sequence instead of the wordmark simply appearing. */
  @keyframes brand-draw { to { stroke-dashoffset: 0; } }
  .nav .mark-check {
    /* Measured 16.03 units for the check path; was 22. */
    stroke-dasharray: 17;
    stroke-dashoffset: 17;
    animation: brand-draw .7s var(--ease-out) both;
    animation-delay: calc(var(--intro) + .2s);
  }

  /* Fallback for browsers without scroll-driven timelines. JS adds .js-motion
     only after confirming IntersectionObserver exists, so a JS failure leaves
     content visible rather than permanently transparent. */
  @supports not (animation-timeline: view()) {
    .js-motion .reveal,
    .js-motion .reveal-scale,
    .js-motion .grid-3 > * {
      opacity: 0; transform: translate3d(0, 20px, 0);
      transition: opacity .8s var(--ease-out), transform .8s var(--ease-out);
    }
    .js-motion .reveal.in-view,
    .js-motion .reveal-scale.in-view,
    .js-motion .grid-3 > *.in-view { opacity: 1; transform: none; }
    .js-motion .grid-3 > *:nth-child(2) { transition-delay: .09s; }
    .js-motion .grid-3 > *:nth-child(3) { transition-delay: .18s; }
  }
}

/* ═══════════════════════════════════════════════════════════════════════════
   PRODUCT SHOWCASE
   ───────────────────────────────────────────────────────────────────────────
   Two scenes: a pinned dashboard walkthrough and a fan of phone screens.

   Both follow the same rule from the motion-design skill — the motion has to
   EXPLAIN something. The pin exists so the viewer reads one feature at a time
   instead of scanning a screenshot; the phone arc exists so three screens read
   as a sequence (arrive → verify → get paid) rather than three rectangles.

   Everything is CSS/SVG rather than screenshots: sharp at any size, themed with
   the page, and zero bytes downloaded.
   ═══════════════════════════════════════════════════════════════════════════ */

/* ── Pinned dashboard scene ────────────────────────────────────────────────
   The scene is 320vh tall; the sticky child holds the viewport while the page
   scrolls through it. 320vh gives each of three steps a little over a full
   screen of scroll — enough dwell time to actually read a caption, which is
   the most common failure of pinned sections. */
.pin-scene { height: 320vh; position: relative; }
.pin-sticky {
  position: sticky; top: 0;
  height: 100vh;
  display: grid; align-items: center;
  overflow: hidden;
}
.pin-grid {
  display: grid;
  grid-template-columns: minmax(0, .82fr) minmax(0, 1.18fr);
  gap: clamp(28px, 5vw, 72px);
  align-items: center;
}

/* Captions stack in one cell and cross-fade — no layout shift between steps. */
.pin-captions { display: grid; }
/* Resting state is VISIBLE. The first caption and the first panel are marked
   .is-on in the markup, and nothing is hidden until JS has proved it can drive
   the steps by adding .js-steps — the same discipline .reveal already uses via
   .js-motion.

   This was inverted: every caption sat at opacity:0 waiting for a class, so
   with JS off (or in the window before it ran) the whole caption column was
   blank, and a screenshot taken in that window showed three captions stacked
   on one another. A page must read correctly at rest. */
.pin-cap {
  grid-area: 1 / 1;
  max-width: 42ch;
}
.js-steps .pin-cap {
  opacity: 0;
  transform: translateY(14px);
}
.js-steps .pin-cap.is-on { opacity: 1; transform: none; }

/* Without the step machinery the three captions stack as ordinary blocks
   rather than overlapping in one grid cell. */
html:not(.js-steps) .pin-captions { display: grid; gap: 30px; }
html:not(.js-steps) .pin-cap { grid-area: auto; }
.pin-kicker {
  font: 500 var(--t-xs)/1 var(--mono);
  letter-spacing: .14em; text-transform: uppercase;
  color: var(--accent); margin-bottom: 14px;
}
.pin-cap h3 {
  font-size: var(--t-3); margin-bottom: 12px;
  letter-spacing: -.028em; text-wrap: balance;
}
.pin-cap p { color: var(--text-2); font-size: 16px; }

/* ── The fake dashboard ──────────────────────────────────────────────────── */
.pin-device { position: relative; }
.ui {
  border-radius: var(--r-lg);
  overflow: hidden;
  box-shadow: var(--shadow-lg);
  border: 1px solid var(--glass-border);
}
.ui-bar {
  display: flex; align-items: center; gap: 7px;
  padding: 11px 15px;
  background: var(--bg-2);
  border-bottom: 1px solid var(--line);
}
.ui-dot { width: 9px; height: 9px; border-radius: 50%; background: var(--line); flex: none; }
.ui-title {
  margin-left: 8px; font: 500 12.5px/1 var(--mono);
  color: var(--text-2); letter-spacing: .04em;
}
.ui-live {
  margin-left: auto;
  font: 600 10px/1 var(--mono); letter-spacing: .14em;
  color: #16a34a;
  display: inline-flex; align-items: center; gap: 6px;
}
.ui-live::before {
  content: ""; width: 6px; height: 6px; border-radius: 50%;
  background: #16a34a;
}
.ui-body { display: grid; grid-template-columns: 132px minmax(0, 1fr); min-height: 316px; }

.ui-nav {
  border-right: 1px solid var(--line);
  padding: 12px 0;
  background: color-mix(in oklab, var(--bg-2) 60%, transparent);
}
.ui-group {
  font: 600 9.5px/1 var(--mono); letter-spacing: .12em; text-transform: uppercase;
  color: var(--text-2); opacity: .7;
  padding: 11px 14px 5px;
}
.ui-item {
  display: block; padding: 6px 14px;
  font-size: 12.5px; color: var(--text-2);
  border-left: 2px solid transparent;
}
.ui-item.is-on {
  color: var(--accent); border-left-color: var(--accent);
  background: color-mix(in oklab, var(--accent) 8%, transparent);
  font-weight: 500;
}

.ui-main { position: relative; padding: 14px 16px; display: grid; }
/* Panels share a cell and cross-fade, so the device never resizes mid-scene. */
.ui-panel { grid-area: 1 / 1; opacity: 0; align-content: start; }
.ui-panel.is-on { opacity: 1; }

.ui-row {
  display: grid;
  grid-template-columns: minmax(0,1.3fr) minmax(0,1fr) 62px 92px;
  gap: 10px; align-items: center;
  padding: 9px 2px;
  font-size: 12.5px;
  border-bottom: 1px solid var(--line);
}
.ui-row:last-child { border-bottom: 0; }
.ui-row .r { text-align: right; }
.ui-head {
  font: 600 9.5px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase;
  color: var(--text-2); border-bottom-color: var(--line);
}
.ui .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; font-size: 11.5px; }

.ui-stats { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 14px; }
.ui-stat {
  border: 1px solid var(--line); border-radius: var(--r-sm);
  padding: 11px 13px;
}
.ui-stat p {
  font: 600 9.5px/1 var(--mono); letter-spacing: .1em; text-transform: uppercase;
  color: var(--text-2); margin-bottom: 7px;
}
.ui-stat n { font-size: 21px; font-weight: 600; letter-spacing: -.02em; color: var(--heading); }

.pin-dots { display: flex; gap: 7px; justify-content: center; margin-top: 20px; }
.pin-dots i {
  width: 22px; height: 3px; border-radius: 2px;
  background: var(--line);
}
.pin-dots i.is-on { background: var(--accent); }

/* ── Phone fan ─────────────────────────────────────────────────────────────
   Three phones, each rotated slightly. The rotation is what makes this read as
   a physical group rather than three flat panels — "arcs" from the classic
   principles. Kept under 6deg; more looks like a rendering bug. */
.showcase-phones .phones {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: clamp(16px, 3.2vw, 42px);
  align-items: start;
  margin-top: clamp(30px, 4vw, 52px);
  perspective: 1400px;
}
.phone { margin: 0; display: grid; justify-items: center; gap: 16px; }
.phone-screen {
  width: 100%; max-width: 252px;
  /* min-height rather than a fixed aspect-ratio: the three screens hold
     different amounts of content, and a locked ratio left the shortest one
     with a block of dead space below its last row. */
  min-height: 380px;
  border-radius: 26px;
  background: var(--bg-2);
  border: 1px solid var(--glass-border);
  box-shadow: var(--shadow-lg);
  padding: 16px 13px;
  overflow: hidden;
  display: grid; align-content: start; gap: 11px;
}
.phone figcaption {
  font: 500 var(--t-xs)/1.4 var(--mono);
  letter-spacing: .1em; text-transform: uppercase;
  color: var(--text-2); text-align: center;
}
/* Resting tilt — the outer two lean toward the centre one. */
.phone-1 .phone-screen { transform: rotateY(5deg)  rotateZ(-1.2deg); }
.phone-2 .phone-screen { transform: translateY(-14px); }
.phone-3 .phone-screen { transform: rotateY(-5deg) rotateZ(1.2deg); }

/* ── Real screenshots in the phone fan ─────────────────────────────────────
   .phone-shot replaces the hand-built .phone-screen markup with a photograph
   of the running app. It takes the same radius, shadow and tilt so the two
   can sit side by side during a swap, and so the fan still reads as one
   physical group.

   No device bezel drawn around it: the screenshot already carries the app's
   own status bar and tab bar, and adding a phone outline around that reads as
   a picture of a picture. Same reasoning as the dashboard shots. */
.phone-shot {
  display: block;
  width: 100%; max-width: 252px;
  height: auto;
  border-radius: 26px;
  border: 1px solid var(--glass-border);
  box-shadow: var(--shadow-lg);
  background: var(--bg-2);
}
.phone-1 .phone-shot { transform: rotateY(5deg)  rotateZ(-1.2deg); }
.phone-2 .phone-shot { transform: translateY(-14px); }
.phone-3 .phone-shot { transform: rotateY(-5deg) rotateZ(1.2deg); }

/* The captures are dark-UI. On the light theme a hairline keeps the dark
   screen from bleeding into the pale band; on dark it would disappear, so
   lift it to something that still separates. */
:root[data-theme="dark"] .phone-shot {
  border-color: var(--shot-edge);
}

/* ── Dashboard screenshots in the pinned scene ─────────────────────────────
   The steps cross-fade in place, so all three images occupy the same grid
   cell. Height comes from the image itself (they are all the same 16:10
   capture), not from a fixed aspect-ratio box. */
.shot-frame {
  border-radius: var(--r-lg);
  overflow: hidden;
  box-shadow: var(--shadow-lg);
  border: 1px solid var(--glass-border);
  background: var(--bg-2);
}
.shot-stack { display: grid; }
.shot-stack .ui-panel {
  grid-area: 1 / 1;
  display: block;
  width: 100%; height: auto;
}
/* Same rule as .pin-cap: nothing is hidden until JS can drive it. Without
   .js-steps only the first panel renders, because the others are stacked in
   the same cell — so the section shows one real screenshot rather than three
   overlapping ones. */
.js-steps .shot-stack .ui-panel {
  opacity: 0;
  transition: opacity .5s var(--ease);
}
.js-steps .shot-stack .ui-panel.is-on { opacity: 1; }
html:not(.js-steps) .shot-stack .ui-panel:not(.is-on) { display: none; }

:root[data-theme="dark"] .shot-frame {
  border-color: var(--shot-edge);
}

/* ── Dashboard explorer ────────────────────────────────────────────────────
   One surface, six tabs. The frame is the card; the tab strip sits on it
   rather than floating separately, so the whole thing reads as a single
   object being operated rather than a row of buttons above a picture. */
.explorer {
  border: 1px solid var(--glass-border);
  border-radius: var(--r-lg);
  overflow: hidden;
  box-shadow: var(--shadow-lg);
  background: var(--bg-2);
  margin-top: clamp(28px, 4vw, 48px);
}
.tabs {
  display: flex; flex-wrap: wrap; gap: 2px;
  padding: 10px 10px 0;
  border-bottom: 1px solid var(--line);
  background: var(--bg-3);
}
.tab {
  appearance: none; border: 0; cursor: pointer;
  background: transparent; color: var(--text-2);
  font: 500 14px/1 inherit;
  padding: 11px 15px;
  border-radius: var(--r-sm) var(--r-sm) 0 0;
  transition: color .2s var(--ease), background .2s var(--ease);
}
.tab:hover { color: var(--text); }
.tab.is-on {
  background: var(--bg-2);
  color: var(--heading);
  /* Lifts the active tab onto the panel by covering the divider under it. */
  box-shadow: 0 1px 0 0 var(--bg-2);
}
.tab-panels { padding: clamp(14px, 2vw, 22px); }
/* [hidden] must win. A bare `.tab-panel { display: grid }` overrides the
   hidden attribute — the panels were all laid out, stacked, making the
   section five times taller than one panel and leaving the tabs scrolling
   past dead space. Only opacity was masking it. */
.tab-panel:not([hidden]) { display: grid; gap: 12px; }
.tab-panel[hidden] { display: none; }
.tab-say {
  color: var(--text-2); font-size: 15px;
  margin: 0 0 2px;
}
.tab-panel img {
  display: block; width: 100%; height: auto;
  border-radius: var(--r-md);
  border: 1px solid var(--line);
}
:root[data-theme="dark"] .explorer { border-color: var(--shot-edge); }
:root[data-theme="dark"] .tab-panel img { border-color: var(--shot-edge); }

@media (max-width: 560px) {
  /* Six tabs will not fit; scroll them horizontally rather than wrapping to
     three ragged rows that push the image off the first screen. */
  .tabs { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
  .tabs::-webkit-scrollbar { display: none; }
  .tab { flex: none; }
}

/* ── Pillars ───────────────────────────────────────────────────────────────
   Three columns under the hero. Deliberately NOT cards: no fill, no shadow,
   no radius — just a rule above each and generous space between. The page
   already spends card treatment on the industries grid and the pricing tiers,
   and stamping it here too would flatten the hierarchy into one texture.
   A hairline is enough to say "three parallel things". */
.pillars-band { padding-block: clamp(34px, 5vw, 64px); }
.pillars {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: clamp(26px, 4vw, 56px);
}
.pillar { padding-top: 20px; border-top: 2px solid var(--line); }
/* The accent lands on the owner column only — they are who decides. */
/* The first rule in a row marks where a sequence starts. It has to be
   QUIETER than the accent: --line is a 16% grey and --accent is a solid
   #22d3ee at 10.8:1, so setting one rule to the full accent put a tenfold
   brightness step between neighbouring columns — which reads as a mistake,
   not as a marker. A 45% mix is visible as "this one is first" without
   breaking the row. */
.pillar:first-child    { border-top-color: var(--accent-mark); }
.pillar-who {
  font: 500 var(--t-xs)/1 var(--mono);
  letter-spacing: .13em; text-transform: uppercase;
  color: var(--accent); margin-bottom: 12px;
}
.pillar h3 {
  font-size: clamp(19px, 2.1vw, 23px); line-height: 1.22;
  letter-spacing: -.026em; margin-bottom: 10px; text-wrap: balance;
}
.pillar p { color: var(--text-2); font-size: 15px; line-height: 1.6; }
.pillar-link {
  display: inline-block; margin-top: 14px;
  font-size: 14px; font-weight: 500; color: var(--accent);
  text-decoration: none;
}
.pillar-link span { display: inline-block; transition: transform .25s var(--ease); }
.pillar-link:hover span { transform: translateX(4px); }

/* ── Manager's view, inside the crew-app section ───────────────────────────
   Text left, two phones right. Smaller than the main fan above it on purpose:
   this is a coda to that section, and matching the fan's scale would read as
   a second equal claim rather than an extension of the first. */
.aside-split {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
  gap: clamp(24px, 4vw, 64px);
  align-items: center;
  margin-top: clamp(48px, 7vw, 96px);
  padding-top: clamp(32px, 4.5vw, 56px);
  border-top: 1px solid var(--line);
}
.aside-title {
  font-size: var(--t-3);
  letter-spacing: -.028em;
  margin-bottom: 12px;
  text-wrap: balance;
}
.aside-body { color: var(--text-2); font-size: 16px; line-height: 1.6; max-width: 46ch; }

.aside-shots {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(14px, 2.4vw, 28px);
  justify-items: center;
  perspective: 1200px;
}
.aside-shot {
  display: block;
  width: 100%; max-width: 208px;
  height: auto;
  border-radius: 22px;
  border: 1px solid var(--glass-border);
  box-shadow: var(--shadow-lg);
  background: var(--bg-2);
}
/* A gentler lean than the main fan (which uses 5deg) — a coda should not
   out-gesture the thing it follows. */
.aside-shots .aside-shot:first-child { transform: rotateY(3.5deg)  rotateZ(-.9deg); }
.aside-shots .aside-shot:last-child  { transform: rotateY(-3.5deg) rotateZ(.9deg) translateY(-10px); }

:root[data-theme="dark"] .aside-shot { border-color: var(--shot-edge); }

@media (max-width: 820px) {
  .aside-split { grid-template-columns: minmax(0, 1fr); }
  .aside-shot { max-width: 180px; }
}

/* ── The loop closes ───────────────────────────────────────────────────────
   Four timestamped steps, then the two artefacts they produce placed side by
   side. The chain is a numbered list semantically (it IS ordered) but the
   markers are suppressed — the times carry the sequence, and a second set of
   numbers next to "09:00" reads as clutter. */
.loop-chain {
  list-style: none;
  display: grid;
  /* auto-fit rather than a fixed 4, so adding a step to the chain does not
     orphan it onto a row of its own. The 200px floor is the narrowest a step
     can be before its heading starts breaking mid-word. */
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: clamp(18px, 2.4vw, 32px);
  margin: clamp(30px, 4vw, 52px) 0 clamp(34px, 4.5vw, 60px);
  padding: 0;
  counter-reset: none;
}
.loop-step {
  padding-top: 18px;
  border-top: 2px solid var(--line);
}
/* The first step's rule is the accent — it marks where the chain starts,
   which is the one thing a row of equal columns cannot say on its own. */
.loop-step:first-child { border-top-color: var(--accent-mark); }
.loop-when {
  font: 500 var(--t-xs)/1 var(--mono);
  letter-spacing: .1em; text-transform: uppercase;
  color: var(--accent);
  margin-bottom: 10px;
}
.loop-step h3 {
  font-size: 18px; line-height: 1.3;
  letter-spacing: -.02em;
  margin-bottom: 8px;
  text-wrap: balance;
}
.loop-step p { color: var(--text-2); font-size: 14.5px; line-height: 1.55; }

/* The pair: phone overlapping the dashboard shot, because the overlap is the
   argument — these are not two separate features sitting near each other. */
.loop-pair { margin: 0; }
/* The phone sits in the left gutter and overlaps only the OUTER edge of the
   dashboard shot. Pushing it further right buried the dashboard's sidebar
   nav, which is one of the more legible parts of that screenshot. */
.loop-pair-shots {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  padding-left: clamp(0px, 10vw, 150px);
}
.loop-screen {
  display: block;
  width: 100%; height: auto;
  border-radius: var(--r-lg);
  border: 1px solid var(--glass-border);
  box-shadow: var(--shadow-lg);
  background: var(--bg-2);
}
.loop-phone {
  position: absolute;
  left: 0; top: 50%;
  transform: translateY(-50%) rotateZ(-2deg);
  width: clamp(112px, 13vw, 188px);
  height: auto;
  border-radius: 18px;
  border: 1px solid var(--glass-border);
  box-shadow: var(--shadow-lg);
  background: var(--bg-2);
  z-index: 2;
}
.loop-pair figcaption {
  margin-top: 18px;
  color: var(--text-2);
  font-size: 14.5px;
  max-width: 54ch;
}
:root[data-theme="dark"] .loop-screen,
:root[data-theme="dark"] .loop-phone { border-color: var(--shot-edge); }

@media (max-width: 900px) {
  .loop-chain { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 560px) {
  .loop-chain { grid-template-columns: minmax(0, 1fr); }
  /* Below this the phone is wider than the gutter it was sitting in, so it
     stops being an overlay and becomes the first of two stacked images. */
  .loop-pair-shots { padding-left: 0; gap: 16px; justify-items: center; }
  .loop-phone { position: static; transform: none; width: 58%; }
}

.ph-top { display: grid; gap: 2px; padding: 2px 3px 6px; }
.ph-greet { font-size: 10.5px; color: var(--text-2); }
.ph-top b { font-size: 14px; color: var(--heading); letter-spacing: -.01em; }

.ph-sum { display: grid; grid-template-columns: repeat(3,1fr); gap: 7px; }
.ph-sum > div {
  background: var(--bg); border: 1px solid var(--line);
  border-radius: 9px; padding: 8px 4px; text-align: center;
}
.ph-sum n { display: block; font-size: 17px; font-weight: 600; color: var(--heading); }
.ph-sum p { font-size: 8.5px; color: var(--text-2); margin-top: 2px; }

.ph-card {
  background: var(--bg); border: 1px solid var(--line);
  border-radius: 11px; padding: 10px 11px;
  display: grid; gap: 5px; position: relative;
}
.ph-card.ph-dim { opacity: .5; }
.ph-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.ph-row b { font-size: 12px; color: var(--heading); }
.ph-card p { font-size: 9.5px; color: var(--text-2); }
.ph-gps {
  position: relative; height: 42px; margin-top: 3px;
  border-radius: 7px; overflow: hidden;
  background:
    linear-gradient(color-mix(in oklab, var(--line) 60%, transparent) 1px, transparent 1px) 0 0/100% 14px,
    linear-gradient(90deg, color-mix(in oklab, var(--line) 60%, transparent) 1px, transparent 1px) 0 0/14px 100%,
    color-mix(in oklab, var(--accent) 5%, transparent);
}
.ph-pin {
  position: absolute; left: 50%; top: 50%;
  width: 9px; height: 9px; margin: -4.5px 0 0 -4.5px;
  border-radius: 50%; background: var(--accent-lit);
  border: 2px solid var(--bg-2); z-index: 2;
}
.ph-ring {
  position: absolute; left: 50%; top: 50%;
  width: 34px; height: 34px; margin: -17px 0 0 -17px;
  border-radius: 50%;
  border: 1px solid color-mix(in oklab, var(--accent-lit) 45%, transparent);
}

.ph-check { display: grid; gap: 1px; }
.ph-check li {
  display: grid; grid-template-columns: 14px minmax(0,1fr) auto;
  gap: 8px; align-items: center;
  padding: 8px 2px; font-size: 10.5px; color: var(--text-2);
  border-bottom: 1px solid var(--line);
}
.ph-check li:last-child { border-bottom: 0; }
.ph-check i {
  width: 14px; height: 14px; border-radius: 50%;
  display: grid; place-items: center;
  background: color-mix(in oklab, #16a34a 18%, transparent);
}
.ph-check li.on i::after {
  content: ""; width: 5px; height: 3px;
  border-left: 1.6px solid #16a34a; border-bottom: 1.6px solid #16a34a;
  transform: rotate(-45deg) translate(.4px, -.8px);
}
.ph-check li.off i { background: color-mix(in oklab, var(--accent) 14%, transparent); }
.ph-check li.off i::after {
  content: ""; width: 5px; height: 5px; border-radius: 50%;
  border: 1.6px solid var(--accent);
}
.ph-check b { font-size: 9px; color: var(--text-2); font-weight: 400; }

.ph-shot {
  border: 1px dashed color-mix(in oklab, var(--accent) 40%, transparent);
  border-radius: 10px; padding: 12px; text-align: center;
  display: grid; gap: 7px; justify-items: center;
}
.ph-shutter {
  width: 26px; height: 26px; border-radius: 50%;
  border: 2px solid var(--accent);
}
.ph-shot p { font-size: 8.5px; color: var(--accent); letter-spacing: .08em; text-transform: uppercase; }

.ph-pay { display: grid; gap: 1px; }
.ph-payrow {
  display: flex; justify-content: space-between; align-items: baseline;
  padding: 9px 2px; font-size: 11px; color: var(--text-2);
  border-bottom: 1px solid var(--line);
}
.ph-payrow b { font-size: 12.5px; color: var(--heading); }
.ph-total { border-bottom: 0; padding-top: 12px; }
.ph-total b { font-size: 17px; color: var(--accent); }
.ph-note {
  font-size: 8.5px; color: var(--text-2);
  text-align: center; letter-spacing: .08em; text-transform: uppercase;
}

/* ── Narrow screens ────────────────────────────────────────────────────────
   Pinning on a phone is hostile: the viewport is short, so a sticky scene eats
   the whole screen and the user cannot scroll past it comfortably. Below 900px
   the scene un-pins and becomes three ordinary stacked blocks. */
@media (max-width: 900px) {
  .pin-scene { height: auto; }
  .pin-sticky { position: static; height: auto; overflow: visible; padding: 8px 0 4px; }
  .pin-grid { grid-template-columns: 1fr; gap: 26px; }
  .pin-captions { gap: 26px; }
  .pin-cap { grid-area: auto; opacity: 1; transform: none; }
  /* Must be `.shot-stack .ui-panel` (0-2-0), not `.ui-panel` (0-1-0): a media
     query adds NO specificity, so the unconditional `.shot-stack .ui-panel
     { opacity: 0 }` above outranked the bare selector and two of the three
     screenshots stayed invisible on every phone. The panels still occupied
     layout space, so the section looked like a rendering glitch rather than
     an obviously missing image. Verified at 380px. */
  .shot-stack .ui-panel { grid-area: auto; opacity: 1; }
  .shot-stack .ui-panel + .ui-panel { margin-top: 18px; padding-top: 18px; border-top: 1px solid var(--line); }
  .ui-body { grid-template-columns: 1fr; }
  .ui-nav { display: none; }
  .pin-dots { display: none; }
  .showcase-phones .phones { grid-template-columns: 1fr; justify-items: center; }
  .phone-1 .phone-screen, .phone-2 .phone-screen, .phone-3 .phone-screen { transform: none; }
}
@media (max-width: 620px) {
  .ui-row { grid-template-columns: minmax(0,1.2fr) 58px 84px; }
  .ui-row > :nth-child(2) { display: none; }
}

/* ═══════════════════════════════════════════════════════════════════════════
   SHOWCASE MOTION
   ───────────────────────────────────────────────────────────────────────────
   Wrapped in prefers-reduced-motion: no-preference, so the STATIC layout above
   is the default and motion is the enhancement. Everything below animates only
   transform and opacity.
   ═══════════════════════════════════════════════════════════════════════════ */
@media (prefers-reduced-motion: no-preference) {

  /* Cross-fades for the pinned scene. JS toggles .is-on; CSS owns the timing,
     so the step logic stays a class switch rather than a style calculation. */
  .pin-cap {
    transition: opacity .45s var(--ease-out), transform .45s var(--ease-out);
  }
  .ui-panel { transition: opacity .4s var(--ease-out); }
  .ui-item  { transition: color .3s var(--ease), border-color .3s var(--ease),
                          background-color .3s var(--ease); }
  .pin-dots i { transition: background-color .35s var(--ease); }

  /* The device itself settles as the scene arrives and recedes as it leaves.
     Two separate elements — .pin-device carries the arrival, .ui carries the
     tilt — because two transform animations on ONE element compose against
     each other and neither resolves correctly. */
  @supports (animation-timeline: view()) {
    .pin-device {
      animation: showcase-arrive .9s var(--ease-out) both;
      animation-timeline: view();
      animation-range: entry 2% cover 26%;
    }
  }
  @keyframes showcase-arrive {
    from { opacity: 0; transform: translate3d(0, 34px, 0) scale(.965); }
    to   { opacity: 1; transform: none; }
  }

  /* ── Phone fan ───────────────────────────────────────────────────────────
     Each phone travels in from the right and rotates into its rest tilt. The
     stagger (via animation-range, not delay) is what makes the three read as a
     sequence — arrive, verify, get paid — rather than one block sliding.

     Ranges rather than delays because these are scroll-linked: a delay would
     desynchronise from the finger, a staggered range stays glued to it. */
  @supports (animation-timeline: view()) {
    .phone-1 .phone-screen,
    .phone-2 .phone-screen,
    .phone-3 .phone-screen {
      animation: phone-in 1s var(--ease-out) both;
      animation-timeline: view();
    }
    .phone-1 .phone-screen { animation-name: phone-in-1; animation-range: entry 4%  cover 34%; }
    .phone-2 .phone-screen { animation-name: phone-in-2; animation-range: entry 9%  cover 39%; }
    .phone-3 .phone-screen { animation-name: phone-in-3; animation-range: entry 14% cover 44%; }

    .phone figcaption {
      animation: cap-in .7s var(--ease-out) both;
      animation-timeline: view();
      animation-range: entry 16% cover 46%;
    }
  }
  /* Each resolves TO its resting tilt, not to none — `both` holds the final
     frame forever, so a `to` of `none` would silently cancel the base
     transform and flatten the fan. */
  @keyframes phone-in-1 {
    from { opacity: 0; transform: translate3d(46px, 26px, 0) rotateY(16deg) rotateZ(-4deg); }
    to   { opacity: 1; transform: rotateY(5deg) rotateZ(-1.2deg); }
  }
  @keyframes phone-in-2 {
    from { opacity: 0; transform: translate3d(46px, 30px, 0) rotateY(12deg); }
    to   { opacity: 1; transform: translateY(-14px); }
  }
  @keyframes phone-in-3 {
    from { opacity: 0; transform: translate3d(46px, 26px, 0) rotateY(-16deg) rotateZ(4deg); }
    to   { opacity: 1; transform: rotateY(-5deg) rotateZ(1.2deg); }
  }
  @keyframes cap-in {
    from { opacity: 0; transform: translateY(9px); }
    to   { opacity: 1; transform: none; }
  }

  /* Ambient: the GPS ring pulses on the clock-in phone. One looping signal on
     the screen that is ABOUT location — slow and low-contrast, because motion
     that repeats forever has to sit below the threshold of distraction. */
  @keyframes ph-ping {
    0%   { transform: scale(.75); opacity: .9; }
    70%  { transform: scale(1.25); opacity: 0; }
    100% { transform: scale(1.25); opacity: 0; }
  }
  .ph-ring { animation: ph-ping 3s var(--ease-out) infinite; }

  /* The shutter ring on the photo-gate phone breathes, so the one blocking
     task on the checklist is what the eye lands on. */
  @keyframes shutter-wait {
    0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklab, var(--accent) 40%, transparent); }
    50%      { box-shadow: 0 0 0 6px color-mix(in oklab, var(--accent) 0%, transparent); }
  }
  .ph-shutter { animation: shutter-wait 2.6s var(--ease) infinite; }

  /* Un-pinned (narrow) layout: panels are stacked and always visible, so the
     scroll-step machinery must not hide them. */
  @media (max-width: 900px) {
    /* .shot-stack .ui-panel for the same specificity reason as the un-pin
       block above — a bare .ui-panel here loses to it and leaves a
       reduced-motion visitor on a phone with two blank panels. */
    .pin-cap, .shot-stack .ui-panel { transition: none; opacity: 1; }
  }
}

/* ── Capability rail ────────────────────────────────────────────────────────
   Pinned for two viewport heights while five cards travel sideways, then
   released back into vertical flow.

   WHY HORIZONTAL AT ALL
   Not novelty. A rail says "these are peers, and there are more of them" in
   a way a stacked column cannot — the viewer knows to keep going. The pin
   buys dwell time: a scene scrolled past in 200ms conveys nothing, so the
   section is 300vh tall and the cards consume the middle of it.

   THE DEFAULT IS VERTICAL
   Everything below lives inside @supports (animation-timeline) and
   prefers-reduced-motion: no-preference. Without either, .rail-track is an
   ordinary wrapping grid and the section is a normal band — which is also
   exactly what it must be on a phone, where a horizontal rail inside a
   vertical scroll is a gesture conflict, not a flourish. */

.rail-band {
  padding-block: 0;
  /* .band sets overflow: hidden, and an ancestor with overflow != visible
     silently disables position: sticky in a descendant — the pin simply
     scrolls away with no error. Verified: .rail-pin sat at top -1292px
     instead of 0. Clipping is restored on the inner wrapper instead, which
     is where the overflowing rail actually needs it. */
  overflow: visible;
}

.rail-pin {
  padding-block: var(--space-band);
}

.rail-head { margin-bottom: clamp(28px, 4vw, 52px); }

.rail-title {
  font: 400 var(--t-2)/1.04 var(--display);
  letter-spacing: -0.028em;
  color: var(--heading);
  text-wrap: balance;
  margin: 10px 0 0;
}

/* The track is a wrapping grid by default: correct with no JS, no
   scroll-timeline support, on a phone, and under reduced motion. */
.rail-track-wrap { overflow: hidden; }

.rail-track {
  list-style: none;
  margin: 0;
  padding: 0 var(--gutter);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: var(--space-md, 18px);
}

.rail-card {
  position: relative;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--r-lg);
  padding: 28px 26px 24px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.rail-k {
  font: 400 12px/1 var(--mono);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.12em;
  color: var(--accent);
}

.rail-card h3 {
  font: 400 var(--t-3)/1.16 var(--display);
  letter-spacing: -0.016em;
  color: var(--heading);
  margin: 2px 0 0;
  text-wrap: balance;
}

.rail-card p {
  font-size: 15px;
  line-height: 1.55;
  color: var(--text-2);
  margin: 0;
  max-width: 34ch;
}

.rail-tag {
  margin-top: 12px !important;
  padding-top: 14px;
  font: 400 11px/1 var(--mono) !important;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--text-3) !important;
}

/* ── The horizontal pass ──────────────────────────────────────────────────
   Only where the browser can drive it off the main thread, only where the
   viewport is wide enough for a rail to make sense, and only when motion is
   welcome. */
/* 1024, not 900: at exactly 900 the rail switched to horizontal in a
   viewport with no room for it, and the track overflowed to 1738px.
   A sideways rail needs laptop width to be worth the pin. */
@media (prefers-reduced-motion: no-preference) and (min-width: 1024px) {
  @supports (animation-timeline: scroll(root)) {

    .rail-band  {
      height: 300vh;
      /* Named timeline, scoped to this section's own scroll progress. */
      view-timeline-name: --rail;
      view-timeline-axis: block;
    }

    /* Pinned: the section scrolls past while this stays put. */
    .rail-pin {
      position: sticky;
      top: 0;
      height: 100vh;
      display: flex;
      flex-direction: column;
      justify-content: center;
      /* Clipped here rather than on .rail-band: the band must stay
         overflow: visible or sticky silently stops working on its child. */
      overflow: hidden;
      /* Belt and braces against the track widening the page. */
      max-width: 100vw;
    }

    .rail-track {
      /* A single row wider than the screen — the thing that travels. */
      display: flex;
      flex-wrap: nowrap;
      gap: 22px;
      width: max-content;
      /* The pin is viewport-width, so without this the first card sits
         flush against the edge of the screen — measured card1Left: 0 at
         1680px. Matches the page gutter so the rail lines up with every
         other section rather than starting further out. */
      padding-inline: clamp(16px, 3.2vw, 40px);
      will-change: transform;

      animation: rail-slide linear both;
      /* A NAMED scroll timeline on the section, not view() on the track.
         view() measures an element's trip through the viewport — but this
         element is position: sticky, so once pinned it stops travelling and
         the timeline sits at 100% for the whole section. Verified: the
         animation attached, reported a ViewTimeline, and translateX was
         frozen at -749px from first frame to last. A silent failure of
         exactly the kind scroll-driven motion specialises in.

         The SECTION scrolls even while its child is pinned, so the section
         is the clock. */
      animation-timeline: --rail;
      /* Nothing moves until the section is actually pinned, and it finishes
         before the release — so the first and last cards each get a beat of
         stillness to be read in. Starting at contain 0% meant the track had
         already travelled ~80px by the time the pin engaged, clipping card
         01 off the left edge on arrival. */
      /* Widened from contain 12%-88% to 4%-96%. Measured on the live page:
         682px of sideways travel over 2500px of scroll, arriving in 9-16px
         jumps per 50px scrolled — about 26px of slide per wheel notch, so
         the row visibly lurches sideways as you come down the page. The
         same distance spread over the full pinned range is a third gentler
         per notch, and the two beats of stillness at each end are not worth
         the cost: the cards are already readable while they drift. */
      animation-range: contain 4% contain 96%;
    }

    .rail-card {
      flex: 0 0 min(330px, 72vw);
      /* Cards are TALLER than they are wide here, so the rail reads as a
         deliberate row rather than a squashed grid. */
      min-height: 310px;
    }

    /* Secondary motion, deliberately quieter than the slide: each card lifts
       and settles slightly out of phase with its neighbours, so the row reads
       as five objects rather than one bitmap being dragged. The transform
       lives on an INNER pseudo-element layer — never two transform
       animations on one element. */
    .rail-card::before {
      content: '';
      position: absolute;
      inset: 0;
      border-radius: inherit;
      background: linear-gradient(180deg,
        color-mix(in oklab, var(--accent) 5%, transparent), transparent 55%);
      opacity: 0;
      animation: rail-tint linear both;
      animation-timeline: view(inline);
      animation-range: entry 0% cover 50%;
      pointer-events: none;
    }
  }
}

@keyframes rail-slide {
  /* Starts flush left so card 01 is fully readable before anything moves —
     the first frame was clipping it mid-word, which made the section look
     broken rather than in-progress.

     The distance was min(-52vw, -620px), which pushed the last card hard
     against the right edge and made the row move faster than the page.
     -40vw / -470px stops with the final card still fully in frame, which
     is where it should rest anyway, and takes roughly a quarter off the
     speed per wheel notch. */
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(min(-40vw, -470px), 0, 0); }
}

@keyframes rail-tint {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* ── Mobile: swipe instead of scroll ────────────────────────────────────────
   The page was 18,853px on a 390px phone — 22 screens, roughly double what
   a landing page should ask for. Almost none of that is content: it is
   multi-column rows stacking into single columns, so three 240px cards
   become 740px of scrolling and the five-card rail becomes 1,458px.

   Those groups are PEERS. Reading them in sequence is the point, and a
   horizontal swipe says "there are more of these, they are alternatives"
   in a way a vertical stack cannot — a stack reads as "keep going down,
   this is a list of unrelated things".

   So on phones they become snap rails: the same markup, swiped sideways,
   one card per screen. It removes about seven screens of scrolling and
   adds the horizontal movement the page otherwise only has on desktop.

   scroll-snap, not a JS carousel: the finger drives it, it is interruptible,
   it works with a screen reader, and it costs nothing when JS fails. */
@media (max-width: 760px) {
  .rail-track,
  .loop-chain,
  .showcase-phones .phones,
  .pillars {
    display: flex;
    flex-wrap: nowrap;
    gap: 14px;
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: x mandatory;
    /* Full-bleed: the rail runs edge to edge so a half-visible next card
       is the affordance, rather than a scrollbar nobody sees on iOS. */
    margin-inline: calc(var(--gutter, 16px) * -1);
    padding-inline: var(--gutter, 16px);
    padding-block-end: 6px;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }
  .rail-track::-webkit-scrollbar,
  .loop-chain::-webkit-scrollbar,
  .showcase-phones .phones::-webkit-scrollbar,
  .pillars::-webkit-scrollbar { display: none; }

  .rail-track > *,
  .loop-chain > *,
  .showcase-phones .phones > *,
  .pillars > * {
    scroll-snap-align: start;
    /* 82% leaves a slice of the next card visible, which is what tells a
       thumb there is more to the right. A full 100% reads as a dead end. */
    flex: 0 0 82%;
    min-width: 0;
  }

  /* The phone shots are images, not cards — they need their own width or
     they squash. */
  .showcase-phones .phones > * { flex-basis: 70%; }

  /* A card has to fit ONE phone screen, or the swipe is pointless: you are
     scrolling vertically inside a card while also being asked to swipe
     sideways between them, which is the worst of both. On a real handset
     browser chrome takes ~180px off the 844px viewport, so the card gets a
     ceiling and its parts are tightened to meet it. */
  .rail-card {
    padding: 20px 18px 16px;
    gap: 6px;
  }
  .rail-card h3 { font-size: 21px; line-height: 1.15; }
  .rail-card p  { font-size: 14px; line-height: 1.5; }
  .rail-shot    { margin-top: 12px; }

  /* The capability rail advances as the page scrolls, like it does on
     desktop — a thumb is already scrolling down, so asking for a sideways
     swipe on top of that is a second gesture to discover. The section pins
     and the cards travel; swiping still works as a manual override because
     the track remains a real scroll container.

     Only this one rail gets the treatment. Pinning four sections would make
     the page feel like it is fighting the thumb, and the shorter rails
     (pillars, loop chain, phone shots) read fine as a plain swipe. */
  @media (prefers-reduced-motion: no-preference) {
    @supports (animation-timeline: view()) {
      .rail-band {
        /* 200vh, not 260: at 260 the travel finished with 40% of the
           section still to scroll, so the rail sat frozen while the thumb
           kept going — which reads as the page having stalled. 200vh is one
           screen of pinned travel plus one of lead-in, and the measured
           dead zone drops to almost nothing. */
        /* 150vh. A pinned section still OCCUPIES its full height in the
           document even though it feels like one screen while scrolling,
           so 200vh quietly made the page 25.4 screens — longer than the
           stack it replaced, which defeats the point. 150vh is half a
           screen of lead-in and one screen of travel. */
        height: 180vh;
        view-timeline-name: --mrail;
        view-timeline-axis: block;
        overflow: visible;
      }
      .rail-pin {
        position: sticky;
        top: 0;
        height: 100vh;
        display: flex;
        flex-direction: column;
        justify-content: center;
        overflow: hidden;
        max-width: 100vw;
      }
      .rail-track {
        /* Driven, so the finger is not required. scroll-snap would fight
           the animation for control of scrollLeft, so it is dropped here.
           overflow must go back to VISIBLE and the track must size to its
           content: with overflow-x: hidden the track stayed a 425px scroll
           container that clipped its own cards, and the transform slid
           that narrow box off-screen instead of moving the row through it.
           Clipping belongs to .rail-pin, which is viewport-width. */
        /* Driven, so the finger is not required. scroll-snap would fight
           the animation for control of scrollLeft, so it is dropped.

           overflow VISIBLE and width max-content: with overflow-x hidden
           the track stayed a 425px scroll container clipping its own
           cards, and the transform slid that narrow box away instead of
           moving the row through it. Clipping belongs to .rail-pin, which
           is viewport-width. */
        scroll-snap-type: none;
        overflow: visible;
        width: max-content;
        /* 5 cards at 82vw + 4 gaps, less the one viewport on screen. */
        --mrail-travel: calc(82vw * 5 + 56px - 100vw);
        animation: mrail-slide linear both;
        animation-timeline: --mrail;
        /* entry 100% is the moment the section's top edge reaches the
           viewport top — when the pin engages. exit 0% is when its bottom
           edge arrives — when the pin releases. `contain` was measured
           doing neither: travel started while the pin was still 71px down
           and finished with 40% of the section left to scroll. */
        animation-range: entry 100% exit 0%;
      }
      /* flex-basis: 82% resolves against the TRACK, which is now
         max-content, so the percentage fed on itself and each card came
         out 3254px. vw is the frame the cards actually move through. */
      .rail-track > .rail-card {
        flex: 0 0 82vw;
      }
    }
  }

  /* Fallback when neither holds: an ordinary swipeable rail. */
  @supports not (animation-timeline: view()) {
    .rail-band { height: auto; }
    .rail-pin  { position: static; height: auto; overflow: visible; }
  }
  @media (prefers-reduced-motion: reduce) {
    .rail-band { height: auto; }
    .rail-pin  { position: static; height: auto; overflow: visible; }
  }
}

/* Product screenshot inside a capability card. These are the first real
   screens on the page — everything above is a hand-built mock — so they
   carry the "this exists" weight the hero cannot. */
.rail-shot {
  /* Pushed to the bottom of the card so all five shots line up on one
     baseline regardless of how long the paragraph above them runs. Without
     this the images sat at five different heights and the row read as
     ragged. */
  margin: 16px 0 0;
  margin-top: auto;
  border-radius: var(--r-md);
  overflow: hidden;
  border: 1px solid var(--line);
  background: var(--bg-2, var(--surface));
  /* Fixed ratio so five cards stay the same height whatever the shot is. */
  aspect-ratio: 16 / 10;
}
:root[data-theme="dark"] .rail-shot { border-color: var(--shot-edge); }
.rail-shot img {
  /* Nothing clever here on purpose. The zoom is baked into the asset by
     scripts/optimize_marketing_shots.py, which crops the 3200x2000 master to
     the content area at exactly this frame's 16:10 ratio, so the image simply
     fills the frame 1:1.

     Both CSS attempts at zooming failed, and the reason is worth keeping:
     the captures are 16:10 and so is this frame, so no CSS crop can magnify
     without pushing part of the shot outside. object-fit: cover plus
     transform: scale(1.6) cropped twice and sliced rows mid-element; a
     190% width scaled the height with it and left 46% of the image hanging
     below the frame with the last row cut in half (imgBottomOverhang: 153px).
     Crop the source, not the frame. */
  width: 100%;
  height: 100%;
  display: block;
  object-fit: cover;
}

@keyframes mrail-slide {
  /* Travel = the track's own width minus one viewport, so the last card
     lands flush at the right edge.

     NOT a percentage: a % in translate resolves against the ELEMENT, and
     the track is ~4.3 screens wide, so -82%*4 came to -1450px on a 1699px
     track — past the end, with the first card already -169px off-screen
     before any scrolling. 100vw is the viewport, which is the frame the
     cards move through. */
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(calc(-1 * (var(--mrail-travel, 1300px))), 0, 0); }
}


/* ═══════════════════════════════════════════════════════════════════════════
   RESTYLE LAYER  (merged 2026-10-16)
   ───────────────────────────────────────────────────────────────────────────
   Everything below was developed as a separate refine.css so it could be
   reviewed against the live site before shipping. It is appended rather than
   folded into the rules above on purpose: each block states the measurement
   or the report that produced it, and splicing those into the original
   declarations would lose that reasoning.

   It overrides by cascade order, so it must stay LAST in this file.
   ═══════════════════════════════════════════════════════════════════════════ */
/* ───────────────────────────────────────────────────────────────────────────
   0. DISPLAY FACE — Space Grotesk
   Fraunces is out. A high-contrast serif with the WONK axis on has a strong
   flavour, and the owner did not want it.

   Space Grotesk instead: a grotesque with actual character — a single-storey
   a, an unusual g, squared-off terminals and distinctive numerals — without
   any of it being decorative. It reads as engineered rather than editorial,
   which suits a product whose argument is records and proof.

   Self-hosted, not linked from Google: the page runs a strict CSP with
   default-src 'none', so a fonts.googleapis.com <link> would need an
   exception. Variable 300-700 in one 22KB file, a third the size of the
   Fraunces it replaces.
   ─────────────────────────────────────────────────────────────────────── */
@font-face {
  font-family: "Space Grotesk";
  src: url("assets/fonts/spacegrotesk.woff2") format("woff2");
  font-weight: 300 700;
  font-style: normal;
  font-display: swap;
}

:root {
  --display: "Space Grotesk", "Helvetica Neue", Arial, sans-serif;
}

/* ═══════════════════════════════════════════════════════════════════════════
   SilverClean — refinement layer
   ───────────────────────────────────────────────────────────────────────────
   Loaded AFTER styles.css and deliberately kept separate while it is being
   reviewed. Same theme — cool ground, teal accent, Fraunces display — but
   executed with type and space instead of effects.

   THE DIAGNOSIS THIS ANSWERS
   The page audited at 51 border-radii, 36 box-shadows, 12 backdrop-filters
   and 26 gradients. That specific combination — soft rounded cards floating
   on shadows behind frosted glass — is the default every generator reaches
   for, which is exactly why it reads as generated. Every one of those
   effects is a substitute for a decision that type and space should be
   making.

   So this layer does four things, in order of how much they matter:

     1. TYPOGRAPHY DOES THE WORK. Fraunces ships variable SOFT and WONK axes
        that were never switched on. Turning them down (SOFT 0, WONK 0) and
        raising the optical size gives the display face a drawn, editorial
        sharpness rather than the soft default. Negative tracking at display
        sizes, tighter leading as size rises.

     2. HIERARCHY, NOT RHYTHM. Eleven sections all used one heading size, so
        the page had a beat but no shape. The hero now steps far above its
        neighbours and section openers sit well below it — one deliberate
        jump, so the eye knows what matters.

     3. SURFACES BECOME EDGES. Cards lose their shadow, glass and radius and
        become a hairline and a column of space. A rule costs one pixel and
        says the same thing a shadow says, without the haze.

     4. DENSITY VARIES. Alternating identical bands is the stacked-band tell.
        Sections now differ in measure, alignment and leading, so the page
        has texture down its length instead of eleven equal slabs.

   Nothing here changes copy, structure, or any behaviour. It is purely the
   visual layer, and it is additive so it can be reverted by removing one
   <link>.
   ══════════════════════════════════════════════════════════════════════════ */


/* ───────────────────────────────────────────────────────────────────────────
   1. GROUND
   A neutral with no hue reads as unconsidered. These are the same cool
   greys, pulled a few percent toward the teal so the page sits in one
   temperature rather than being white with a coloured accent bolted on.
   Pure #fff is kept nowhere for large areas: on a backlit screen it buzzes
   against near-black type.
   ─────────────────────────────────────────────────────────────────────── */
:root {
  /* GROUND: warm off-white, not cool blue-grey.
     vercer.ai — the closest competitor of these references, and the one
     selling to the same trades — sits on a warm paper tone rather than the
     blue-tinted near-white every SaaS template ships with. Measured, their
     page background is a warm neutral; ours was #fbfcfd, which is white
     pulled TOWARD blue, i.e. the default.

     A warm ground also suits the argument. The page is about trusting the
     people who hold your keys; paper is a friendlier surface for that than
     a cool screen-grey, and it makes the teal read as deliberate rather
     than as the one colour on a colourless page. */
  --bg:    #fcfbf8;
  --bg-2:  #f6f4ef;
  --bg-3:  #efece4;

  /* Near-black carrying the same bias as the ground, never #000. */
  --text:    #16130e;
  --heading: #100e09;
  /* Muted text is where palettes fail AA. This is 7.1:1 on --bg. */
  --text-2:  #5d574c;

  --line:       #e4e0d6;
  /* A second, lighter rule for internal divisions, so not every edge shouts
     at the same volume. Rules carry the structure now, so they need a
     hierarchy of their own. */
  --line-soft:  #eeebe3;
  --line-firm:  #cdc7b8;

  /* The accent stays, but is spent once per view rather than on every
     border, icon and label. */
  --accent: #0e6d82;

  /* RADII: the original scale, restored.
     An earlier pass took these to 2-4px on the theory that soft corners are
     an "AI tell". That was wrong twice over. The radii were never what made
     the page generic -- a page is generic when nothing about it could only
     belong to THIS subject -- and flattening them stripped out warmth the
     page needs, because the thing being sold is trust between an owner and
     the people who hold their keys. Sharp corners argue the opposite.
     Keep the curves; find the character elsewhere. */
  --r-sm: 10px;
  --r-md: 16px;
  --r-lg: 24px;
  --r-xl: 32px;

  /* TYPE SCALE, SET AGAINST MEASURED REFERENCES
     Read off the live sites with a headless browser (WebFetch strips CSS,
     so this is getComputedStyle, not recollection):

       vercer.ai   h1 60px, ls -1.5px   (-0.025em), body Inter 18px
       framer.com  h1 54px, ls -2.16px  (-0.040em), h2 44px, h3 18px
       ours (was)  h1 91.5px

     Both references sit near 55-60px and let white space do the work. Ours
     was half again larger, which is not more confident — it is shoutier,
     and it is what pushed the page to 16672px when vercer.ai says the same
     kind of thing in 4198px. Coming down to 60px is the single change that
     makes the page feel composed rather than inflated.

     Framer's three-step hierarchy (54 / 44 / 18) is tighter than the five
     steps we had. Fewer sizes, more clearly separated. */
  --t-1:  clamp(40px, 4.6vw, 62px);   /* hero */
  --t-2:  clamp(29px, 3.0vw, 44px);   /* section openers */
  --t-3:  19px;                       /* card and feature titles */
  --t-4:  17px;
  --t-sm: 15px;
  --t-xs: 12.5px;

  --measure:       60ch;
  --measure-tight: 42ch;
}

:root[data-theme="dark"] {
  /* Dark carries the same warm bias, a few percent only. A page that is
     warm in light and cold in dark is two palettes, not one. */
  --bg:    #0c0b09;
  --bg-2:  #131210;
  --bg-3:  #1a1816;
  --text:    #f2efe9;
  --heading: #faf8f4;
  --text-2:  #a39d92;
  --line:       #272421;
  --line-soft:  #1d1b18;
  --line-firm:  #38342f;
  --accent: #35b8d4;
  --shadow-lg: 0 1px 2px rgba(0,0,0,.4), 0 18px 50px -20px rgba(0,0,0,.7);
}


/* ───────────────────────────────────────────────────────────────────────────
   2. SURFACES: SOFT, BUT NOT HAZY
   An earlier pass switched the whole glass system off. That was the wrong
   call — it took the warmth out with the haze, and warmth is load-bearing
   on a page whose argument is trust between an owner and the people who
   hold their keys.

   What stays: the radii, the shadows, the sense that a card is an object.
   What goes: only the SATURATION boost, which is the part that reads as
   generic. backdrop-filter: saturate(180%) pushes everything behind the
   glass toward neon and is why the frosted look is instantly recognisable
   as a template. Blur without the saturation still gives depth and stops
   looking like every other landing page.
   ─────────────────────────────────────────────────────────────────────── */
.glass,
.card,
.panel,
.price-card,
.tile,
.spec {
  backdrop-filter: blur(16px);
  -webkit-backdrop-filter: blur(16px);
}

/* The hero stacked three radial washes (glow, aura, spot) on top of each
   other. Two is one too many — they muddy into a single grey haze rather
   than reading as light. Keep the glow and the spot, drop the middle one. */
.hero-aura { display: none !important; }
.hero-glow { opacity: .62 !important; }

/* ───────────────────────────────────────────────────────────────────────────
   3. TYPOGRAPHY
   Where the identity actually comes from.
   ─────────────────────────────────────────────────────────────────────── */

/* Fraunces is variable on SOFT (softness of the terminals), WONK (the
   quirky alternate shapes) and opsize. The defaults are soft and friendly;
   turning them down gives a drawn, high-contrast editorial face that reads
   as considered rather than decorative. This is free character that was
   already in the font file and switched off. */
h1, h2, h3,
.hero-title,
.rail-title,
.opener-title,
.signup-title {
  /* Fraunces ships three axes nobody had switched on. This is where the
     page gets a voice that is not transferable to another site:

       SOFT 28  — rounds the terminals slightly. At 0 (the previous value)
                  the face is hard and conventional; the softness is what
                  keeps a serif from reading as a law firm.
       WONK  1  — enables the alternate single-storey g and splayed leg
                  forms the designer drew as the family's quirk. This is
                  the single most recognisable thing on the page.
       opsz 144 — display optical size: thinner hairlines, tighter fit,
                  which is what large type is supposed to do. */
  /* Space Grotesk has only a weight axis — the SOFT/WONK/opsz settings here
     were Fraunces' and would be ignored (or worse, inherited oddly). */
  font-variation-settings: normal;
  font-synthesis: none;
  text-wrap: balance;
}

p { text-wrap: pretty; }

/* Line height falls as size rises. A 104px heading at 1.5 is a paragraph
   with holes in it. */
.hero-title {
  font-size: var(--t-1);
  line-height: 1.0;        /* a grotesque's flat terminals need a touch more
                              room than a serif's; .92 made lines touch */
  letter-spacing: -.035em; /* measured against framer.com: -2.16px on 54px
                              is -.040em, so this sits just inside theirs */
  font-weight: 600;
}

/* "Keep the receipts." — the emphasis is a TRUE ITALIC, not a colour.
   Straight from vercer.ai, which sets "Built for every *trade.*" with the
   last word in italic and no colour change at all. It is quieter and more
   confident than a second colour, and a serif's italic is a genuinely
   different set of letterforms, so the emphasis is unmistakable without
   shouting. Fraunces' italic with WONK on is the most characterful thing
   in the whole font file.

   The accent is then free to be spent where it actually converts — the
   primary button — instead of being used up in the headline. */
.hero-title em {
  /* A grotesque's synthesised oblique is weak emphasis, so the accent does
     the work here instead — which is what it is reserved for. */
  font-style: normal;
  color: var(--accent);
  /* Only the UPRIGHT Fraunces is self-hosted — there is no italic file — and
     the font-synthesis: none above (right for the rest of the page, which
     must not fake bolds) was suppressing the slant entirely, so this
     rendered upright and the emphasis vanished. Allowing synthesis here,
     and only here, slants the real letterforms. If the Fraunces italic is
     ever added to assets/fonts, drop this line and it upgrades itself. */
  font-synthesis: style;
}

/* The <br> in the markup sets the break deliberately, so balance must not
   fight it. (The h1 rule above would otherwise apply here.) */
.hero-title { text-wrap: initial; }

.opener-title,
.rail-title,
.signup-title {
  font-size: var(--t-2);
  line-height: 1.1;
  letter-spacing: -.026em;
  font-weight: 600;
}

/* styles.css caps these at 18ch to force a 2–3 line break while they sat in
   a centred stack. In the two-column index layout the heading has its own
   column and that cap makes it break far too early. */
.band .opener .opener-title { max-width: 24ch; }

/* Small caps labels get POSITIVE tracking — the opposite correction from
   display type, and the one most often missed. */
.eyebrow,
.rail-tag,
.kicker,
.sec-num,
.label {
  font-family: var(--mono);
  font-size: var(--t-xs);
  letter-spacing: .14em;
  text-transform: uppercase;
  font-weight: 500;
  color: var(--text-2);
}

/* Running text: a real measure, and leading that suits the size. */
.hero-sub,
.opener-sub,
.band p,
.rail-card p {
  max-width: var(--measure);
  line-height: 1.62;
}

.hero-sub {
  font-size: clamp(17px, 1.35vw, 20px);
  line-height: 1.55;
  max-width: 46ch;
  color: var(--text-2);
}

/* Any figure that stacks gets tabular numerals, or the column wobbles. */
.price, .stat, .num, .metric, td, .mono,
[class*="price"], [class*="stat"] {
  font-variant-numeric: tabular-nums;
}


/* ───────────────────────────────────────────────────────────────────────────
   4. CARDS STAY CARDS
   The previous pass turned every card into a hairline and a column of
   space. It was consistent and it was colder than this page should be, so
   the cards are objects again — rounded, filled, sitting slightly above the
   ground. The distinctiveness is not supposed to come from removing them.
   ─────────────────────────────────────────────────────────────────────── */
.card,
.tile,
.spec,
.price-card,
.panel,
.rail-card {
  border-radius: var(--r-md);
  border: 1px solid var(--line);
}

.rail-shot {
  border-radius: var(--r-sm);
  border: 1px solid var(--line);
}

/* ── The one structural idea that IS particular to this product ──────────
   Every card in the rail is a record of something that happened at a
   building, so each one is tagged like a record: a mono index down the
   left edge, the way a filed document carries a reference in the margin.

   This is the part that could not be lifted onto another landing page
   without looking out of place, which is the whole test. It costs one
   pseudo-element and no extra markup. */
.rail-card {
  position: relative;
  padding-left: clamp(30px, 2.4vw, 44px);
}

.rail-card::before {
  content: "";
  position: absolute;
  left: clamp(13px, 1.1vw, 19px);
  top: clamp(26px, 2vw, 34px);
  bottom: clamp(26px, 2vw, 34px);
  width: 1px;
  /* Fades out at both ends, so it reads as a margin rule rather than a
     border that failed to reach the corners. */
  /* 22% on a 1px line is invisible — measured, not guessed. At full accent
     strength it reads as an intentional margin rule instead of a smudge. */
  background: linear-gradient(180deg,
              transparent,
              var(--accent) 14%,
              var(--accent) 86%,
              transparent);
  opacity: .5;
}

/* The index number sits ON that rule, rotated into the margin. */
.rail-k {
  font-family: var(--mono);
  font-size: var(--t-xs);
  letter-spacing: .16em;
  color: var(--accent);
  font-weight: 500;
}

.rail-tag {
  padding-top: 14px;
  border-top: 1px solid var(--line-soft);
}

@media (max-width: 820px) {
  /* The margin rule is a desktop affordance; at phone width the card is
     too narrow to give up the space. */
  .rail-card { padding-left: clamp(18px, 4vw, 24px); }
  .rail-card::before { display: none; }
}


/* ───────────────────────────────────────────────────────────────────────────
   5. DENSITY, SO IT STOPS READING AS STACKED BANDS
   Eleven identical slabs is the layout you get when no layout decision is
   made. These vary the measure and the alignment down the page.
   ─────────────────────────────────────────────────────────────────────── */

/* Section openers break the centre line: the number and label sit in a
   narrow left column, the heading and lede in the wide one. On a page about
   records, a left rail of numbered labels reads as an index — which is both
   more distinctive than a centred stack and truer to the subject.

   The markup is already <header class="opener"> > <p class="opener-num"> +
   <div>, so this needs no HTML change: two children, two columns. */
.band .opener {
  display: grid;
  grid-template-columns: minmax(0, 12rem) minmax(0, 1fr);
  gap: clamp(14px, 2.4vw, 56px);
  align-items: start;
}

.band .opener > .opener-num {
  display: flex;
  flex-direction: column;
  align-items: flex-start;   /* the flex default would centre the column */
  gap: .5em;
  padding-top: .5em;         /* optical: aligns the label to the heading cap */
  margin: 0;
  text-align: left;
  white-space: normal;
}

/* styles.css trails a hairline after the number to tie it to the heading
   sitting beside it. Stacked in a left rail there is nothing to tie it to,
   and it renders as a stray underline under the label. */
.band .opener > .opener-num::after { content: none; }

.opener-step {
  font-family: var(--mono);
  font-size: var(--t-xs);
  letter-spacing: .14em;
  color: var(--accent);
}

.opener-label {
  font-family: var(--mono);
  font-size: var(--t-xs);
  letter-spacing: .14em;
  text-transform: uppercase;
  color: var(--text-2);
}

.opener-lede {
  max-width: var(--measure);
  line-height: 1.62;
  color: var(--text-2);
}

@media (max-width: 820px) {
  .band .opener { grid-template-columns: 1fr; gap: 12px; }
  .band .opener > .opener-num {
    flex-direction: row;
    gap: .9em;
    padding-top: 0;
  }
}

/* The alternating fill is what makes the stack obvious. Replace most of it
   with a single hairline: the section change is still legible, and the page
   reads as one continuous document rather than eleven boxes. */
/* The alternating fill stays. Stripping it to bare hairlines made the page
   one long undifferentiated sheet — the bands are what give it a pulse as
   you scroll, and the complaint was never that the page had too much
   colour. A hairline is added ON TOP of the fill so the boundary is crisp
   where two filled sections would otherwise blur together. */
.band-alt {
  border-top: 1px solid var(--line-soft);
}


/* ───────────────────────────────────────────────────────────────────────────
   6. THE ACCENT, SPENT ONCE
   It appeared on every rule, label, icon and border, which means it said
   nothing. Reserved now for the primary action and live state.
   ─────────────────────────────────────────────────────────────────────── */
.accent-line,
.rule,
hr,
.divider { background: var(--line) !important; }

/* Buttons keep their pill shape and their lift. The only thing taken away
   is the gradient fill — a flat ink block reads as more confident than a
   subtly graded one, and the gradient was pure decoration. */
.btn,
.btn-primary,
.btn-ghost,
.btn-solid {
  background-image: none;
  letter-spacing: -.005em;
}


/* ───────────────────────────────────────────────────────────────────────────
   7. NAV
   The bar was showing a left-to-right gradient — warm cream at the logo,
   cold blue-green by the buttons. That is the hero's glow refracting
   through a translucent bar that also ran saturate(190%), which pushes
   whatever is behind it toward neon. Against the warm ground it read as a
   rendering fault rather than as glass.

   Fixed by making the bar its own surface: a near-opaque warm fill, blur
   without saturation, so what passes underneath softens instead of
   tinting the bar.
   ─────────────────────────────────────────────────────────────────────── */
.nav,
.nav-inner,
header.site-head {
  border-bottom: 1px solid var(--line-soft);
  backdrop-filter: blur(20px);
  -webkit-backdrop-filter: blur(20px);
}

.nav {
  /* color-mix keeps this in step with the ground token rather than
     hard-coding a second near-white that drifts when --bg changes. */
  background: color-mix(in srgb, var(--bg) 88%, transparent);
}

.nav.scrolled {
  background: color-mix(in srgb, var(--bg) 96%, transparent);
  backdrop-filter: blur(24px);
  -webkit-backdrop-filter: blur(24px);
  /* The inset white line read as a seam on a warm bar. A single hairline
     and a soft drop is enough to say "this floats". */
  box-shadow: 0 6px 24px -18px rgba(22,19,14,.4);
  border-bottom-color: var(--line);
}

:root[data-theme="dark"] .nav.scrolled {
  box-shadow: 0 8px 28px -18px rgba(0,0,0,.75);
}

.nav-links a {
  font-size: var(--t-sm);
}

/* The underline sat at -5px, close enough to the bar's own bottom border
   that the two read as one thick smudge on hover. -9px separates them. */
.nav-links a::after {
  bottom: -9px;
  height: 1px;
}


/* ───────────────────────────────────────────────────────────────────────────
   8. SCREENSHOT FRAMES
   One shadow survives on the page, and this is it: the dashboard shot is the
   only element that should read as lifted off the ground, because it is the
   product and everything else is description.
   ─────────────────────────────────────────────────────────────────────── */
.shot,
.showcase .ui-panel,
.shot-stack .ui-panel {
  border-radius: var(--r-md);
  border: 1px solid var(--line);
  box-shadow: var(--shadow-lg);
}


/* Respect the OS setting; none of the above depends on motion. */
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}


/* ───────────────────────────────────────────────────────────────────────────
   9. THE PHONE TRIO, LEVELLED
   The three crew-app shots each carried a different transform —
   rotateY(5deg) rotateZ(-1.2deg) on the first, a bare translateY(-14px) on
   the second, the mirrored rotation on the third. Measured, that put the
   screens at three different heights and three different angles while
   their captions sat on one line, so the group read as misaligned rather
   than as a deliberate fan.

   They are the same product on three screens, so they line up like three
   screens: one baseline, no rotation. The middle one keeps a small lift,
   which is enough to stop the row being flat without breaking the line.
   ─────────────────────────────────────────────────────────────────────── */
.phone-1 .phone-shot,
.phone-3 .phone-shot {
  transform: none;
}

.phone-2 .phone-shot {
  transform: translateY(-18px);
}

/* With the rotation gone the shots can share one width and one radius, so
   the row reads as a set. */
.phone-shot {
  max-width: 258px;
  border-radius: 22px;
}


/* ───────────────────────────────────────────────────────────────────────────
   10. THE POINTER EFFECTS AT THE TOP OF THE PAGE, REMOVED
   Three separate things fired as the cursor crossed the top of the page,
   and together they read as the page twitching rather than responding:

     1. .hero-spot — a 620px radial glow tracking the pointer across the
        whole hero, driven by --mx/--my written on every pointermove. A
        light that chases the mouse is the loudest "effect for its own
        sake" on the page; nothing about the product is being communicated
        by it, and it draws the eye away from the headline it sits behind.

     2. .btn-solid / .btn-ghost lift translateY(-1.5px) on hover, so both
        nav buttons jump as the cursor passes over them. Measured: top
        11.1 -> 9.6.

     3. .theme-toggle SCALES to 1.06 — 38px to 40.3px. A control that
        changes size under the cursor is the most jarring of the three,
        because its neighbours appear to shift to make room.

   Crossing the nav therefore set off three movements in sequence. All
   three are replaced with colour and border changes, which answer the
   pointer without moving anything. Hover should confirm "this is
   clickable", not animate.
   ─────────────────────────────────────────────────────────────────────── */
.hero-spot,
.pointer-fine .hero-spot {
  display: none !important;
}

.btn:hover,
.btn-solid:hover,
.btn-ghost:hover,
.btn-quiet:hover,
.theme-toggle:hover {
  transform: none !important;
}

/* The nav buttons answer with tone instead of travel. */
.btn-solid:hover {
  background-color: color-mix(in srgb, var(--btn-fill) 86%, var(--bg));
  box-shadow: none;
}

.btn-ghost:hover,
.theme-toggle:hover {
  border-color: var(--line-firm);
  color: var(--heading);
  background-color: color-mix(in srgb, var(--bg-3) 60%, transparent);
}

/* The sheen that sweeps across the solid button on hover travels too, so it
   goes with the rest. */
.btn-solid:hover::after { display: none; }


/* ───────────────────────────────────────────────────────────────────────────
   11. THE PHONE RAIL ON MOBILE
   At 393px the three crew-app shots were cut off at both edges: measured,
   app-job-detail lost 393px of its 253px-wide box (72% of it off-screen)
   and app-checklist lost 126px.

   The cause is a handover between two layouts. On mobile .phones stops
   being a 3-column grid and becomes a scroll-snap rail, and the rail gives
   each child flex: 0 0 82% — about 322px at this width. But the centring
   came from the GRID's justify-items, which no longer applies once the
   parent is a flex rail, so each 258px image sat hard against the left of
   its 322px slot and the group drifted progressively out of frame.

   Centring inside the slot puts every phone fully in view, and the rail
   still shows a slice of the next one, which is what tells a thumb to
   swipe.
   ─────────────────────────────────────────────────────────────────────── */
@media (max-width: 760px) {        /* the breakpoint where styles.css turns
                                      .phones into the scroll rail — matching
                                      it exactly is what makes these win */
  /* The rail is full-bleed: margin-inline pulls it out past the page gutter
     so a half-visible next card is the swipe affordance. For the dashboard
     cards that works, because they are wide and short. For the PHONES it
     does not — a phone shot is 2.2x taller than it is wide, so at 393px a
     full-width card runs well past the fold, and with the bleed there is no
     left gutter either: the first phone sits hard against the screen edge
     and reads as cut off rather than as the start of a row.

     So the phones keep the swipe but get their page gutter back, and each
     card is sized to fit the screen it is on rather than to fill it. */
  .showcase-phones .phones {
    margin-inline: 0;
    padding-inline: var(--gutter, 16px);
    gap: 14px;
  }

  .showcase-phones .phones > .phone {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-start;
    /* 62% of the viewport, so a whole phone and a clear slice of the next
       are both in frame at 393px. */
    flex: 0 0 62%;
  }

  /* Cap the height to the viewport so a card is never taller than the
     screen that has to show it. The shot keeps its own ratio. */
  .showcase-phones .phones .phone-shot {
    width: 100%;
    max-width: 100%;
    max-height: 64vh;
    height: auto;
    object-fit: contain;
    margin-inline: auto;
  }

  /* The lift on the middle phone is a desktop grace note. In a swipe rail
     each card is looked at on its own, so an offset one just looks wrong. */
  .phone-2 .phone-shot { transform: none; }
}



/* ───────────────────────────────────────────────────────────────────────────
   12. THE "ONE SHIFT" PAIR
   Two faults in one figure, both of them things a reader notices before
   they read anything:

     The phone carried rotateZ(-2deg) while the dashboard beside it sat
     straight. Two screens of the same moment, one tilted and one not, do
     not read as a composition — they read as one image having slipped.

     The dashboard used the rail's zoomed crop at ~1090px wide, where the
     missing Actions column and user menu are plainly cut rather than
     cropped. It now uses the uncropped variant, like the explorer.
   ─────────────────────────────────────────────────────────────────────── */
/* MUST stay scoped to the overlay layout. Written unscoped at first, and
   that is what put a phone screenshot on top of the shift timeline: below
   560px the phone is position: static and stacks normally, but an
   unconditional translateY(-50%) still lifted it half its own height —
   measured -225px at 393px wide — straight out of the flow and over "The
   cleaner arrives and taps in." A transform that only makes sense for an
   absolutely positioned element has to carry the same media query the
   positioning does. */
@media (min-width: 561px) {
  .loop-phone {
    transform: translateY(-50%);
  }
}


/* ───────────────────────────────────────────────────────────────────────────
   13. ONE RULE COLOUR PER ROW
   styles.css tinted the FIRST rule of the pillars row and of the loop
   timeline with --accent-mark, to mark where a sequence starts. The
   reasoning was sound and the result was not: in a row of three otherwise
   identical columns, one rule in a different colour reads as a rendering
   fault, not as a marker. The owner saw it and asked why there was only
   one teal line, which is the whole answer — a marker nobody reads as
   deliberate is just an inconsistency.

   The pillars are three AUDIENCES (owner, supervisor, cleaner), not a
   ranked sequence, so there is nothing to mark in the first place. The
   loop steps are genuinely sequential, but they are already numbered with
   times — 09:00, 09:14, SAME MORNING — which says "sequence" far better
   than a tinted hairline.
   ─────────────────────────────────────────────────────────────────────── */
.pillar:first-child,
.loop-step:first-child {
  border-top-color: var(--line);
}


/* ───────────────────────────────────────────────────────────────────────────
   14. HORIZONTAL RAILS MUST NOT EAT A VERTICAL SWIPE
   Measured on a real touch context at 393x852: dragging UP with the thumb
   starting on a rail moved the page

       .loop-chain    0px   — completely stuck
       .pillars       8px   — effectively stuck
       .phones      434px   — fine
       .tabs        279px   — fine

   So two of the four swipe rails swallowed the page scroll outright. A
   reader scrolling the page hits "One shift, end to end", swipes up, and
   nothing happens — which is the whole "scrolling isn't smooth" complaint.

   Cause: the rails set overflow-x:auto with overflow-y:hidden and no
   touch-action. With both axes claimed by the element, the browser has no
   rule for which direction belongs to the page, and a near-vertical drag
   that starts inside the box is resolved as a (clamped) horizontal one.

   touch-action: pan-x says explicitly: this element handles LEFT/RIGHT, the
   page keeps UP/DOWN. overscroll-behavior-x: contain stops a horizontal
   fling from chaining into browser back-navigation once a rail hits its end.
   ─────────────────────────────────────────────────────────────────────── */
@media (max-width: 760px) {
  .rail-track,
  .loop-chain,
  .showcase-phones .phones,
  .pillars,
  .tabs {
    /* NOT touch-action: pan-x. Measured with real touch events: pan-x on
       the container means the element claims horizontal and NOTHING
       handles vertical inside its box, so a vertical swipe that starts on
       the rail dies at 0px. Both #capabilities and #app were fully stuck
       that way. Leaving touch-action at its default lets the browser do
       what it already does well — decide from the angle of the first few
       pixels of movement whether the gesture is the rail's or the page's.

       overscroll-behavior-x: contain is the part worth keeping: it stops a
       horizontal fling, once the rail hits its end, from chaining into the
       browser's back-navigation. */
    overscroll-behavior-x: contain;
  }

  /* touch-action goes on the SCROLL CONTAINER ONLY.
     Putting pan-x on the cards too (tried, measured) is worse than the
     original bug: pan-x on a card means that card handles left/right and
     nothing handles up/down, so a vertical swipe landing on a card died
     completely — 0px, where before the fix it managed 9px. The container
     alone is what needs the hint; its children inherit the behaviour by
     being inside it, not by being told separately. */
}


/* ───────────────────────────────────────────────────────────────────────────
   15. NO PINNED SECTION ON A PHONE
   Measured by dispatching real touch events down the live page: the page
   scrolled 285px per swipe at 17 of 19 positions, and 9px and 47px at the
   two that land inside the capability rail. That section was the only
   place scrolling broke, and it broke badly — the thumb moves and the view
   does not.

   The cause is the mobile rail's own scroll-driven treatment: .rail-band
   is 180vh with .rail-pin sticky at height:100vh inside it, so 682px of
   thumb travel (0.8 of a screen) is spent sliding cards sideways while the
   page appears frozen. On a desktop, with a mouse wheel and a wide
   viewport, that reads as a considered transition. On a phone it reads as
   the page having stopped responding, and it is the single biggest
   obstacle to following the page top to bottom.

   So the phone gets the plain version instead: a normal-height section and
   an ordinary swipe rail. The cards are still all reachable, the page
   never stops moving under the thumb, and the horizontal motion is now
   something the reader chooses rather than something done to them.

   Desktop keeps the pinned treatment: the min-width:1024px block above is
   untouched.
   ─────────────────────────────────────────────────────────────────────── */
@media (max-width: 1023px) {
  .rail-band {
    height: auto !important;
    view-timeline-name: none !important;
  }

  .rail-pin {
    position: static !important;
    height: auto !important;
    overflow: visible !important;
    display: block !important;
  }

  .rail-track-wrap { overflow: visible; }

  .rail-track {
    /* Back to a finger-driven rail: no transform, no animation timeline. */
    animation: none !important;
    transform: none !important;
    display: flex;
    flex-wrap: nowrap;
    width: auto;
    max-width: 100%;
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: x mandatory;
    overscroll-behavior-x: contain;
    gap: 14px;
    margin-inline: calc(var(--gutter, 16px) * -1);
    padding-inline: var(--gutter, 16px);
    padding-block-end: 8px;
    scrollbar-width: none;
  }
  .rail-track::-webkit-scrollbar { display: none; }

  .rail-track > * {
    scroll-snap-align: start;
    flex: 0 0 82%;
    min-width: 0;
  }
}


/* ───────────────────────────────────────────────────────────────────────────
   16. TAP TARGETS ON TOUCH
   Thirteen controls measured under 44px tall at 393px wide — the
   pillar links at 23px, "Sign in" at 24px, the brand at 30px, the
   explorer tabs at 37px, the theme toggle at 38px. Apple's and Google's
   guidance is 44px, and below about 40px a thumb starts missing.

   Fixed with padding and min-height rather than by growing the type, so
   the layout and the visual weight are unchanged — only the area that
   responds to a thumb gets bigger. Negative margin keeps the extra
   padding from pushing neighbours apart.
   ─────────────────────────────────────────────────────────────────────── */
@media (hover: none) and (pointer: coarse) {
  .pillar-link,
  .row-link,
  .ui-link {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    /* The link sits at the end of a card, so the extra height goes
       downward into space that was padding anyway. */
    margin-block: -10px 0;
  }

  .nav-cta a,
  .nav-links a {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }

  .brand {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }

  .theme-toggle {
    width: 44px;
    height: 44px;
  }

  .tab {
    min-height: 44px;
    padding-block: 10px;
  }

  /* Footer links are the last thing a thumb reaches and were the smallest
     on the page at 24px. They stack, so extra height costs nothing. */
  .footer-links a,
  footer a {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
  }

  /* Stops the double-tap-to-zoom delay on anything tappable, which reads
     as the page being slow to respond. */
  a, button, [role="button"], .tab, summary {
    touch-action: manipulation;
  }
}


/* ───────────────────────────────────────────────────────────────────────────
   17. READING PROGRESS
   The page is 18.5 screens on a phone, and measurement showed that is
   content, not padding — section padding is only 11% of the height. So
   rather than cut the owner's copy, give the reader the one thing a long
   page is missing: a sense of where they are and how much is left.

   A 2px bar along the bottom edge of the fixed nav, driven by
   scroll(root). No JavaScript, no scroll listener, no layout read per
   frame — it runs on the compositor.

   scroll(root) and not scroll(): body has overflow-x: hidden, which makes
   BODY a scroll container, so a bare scroll() binds to body, which barely
   scrolls, and the bar would never advance. This is the same trap the hero
   drift hit; see the note at .hero-glow.
   ─────────────────────────────────────────────────────────────────────── */
.nav::after {
  content: "";
  position: absolute;
  left: 0;
  bottom: -1px;          /* sits on the nav's own border, not above it */
  height: 2px;
  width: 100%;
  transform-origin: 0 50%;
  transform: scaleX(0);
  background: var(--accent);
  opacity: .85;
  pointer-events: none;
}

@supports (animation-timeline: scroll(root)) {
  @media (prefers-reduced-motion: no-preference) {
    .nav::after {
      animation: nav-progress linear both;
      animation-timeline: scroll(root);
    }
  }
}

@keyframes nav-progress {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

/* Without scroll-timeline support, or with reduced motion on, the bar
   simply never appears rather than sitting at a misleading 0%. */
@supports not (animation-timeline: scroll(root)) {
  .nav::after { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .nav::after { display: none; }
}
