/* ==== assets/css/tokens.css ==== */
/* =============================================================================
   ToolsSync — design tokens
   Every colour, size, rhythm and timing constant lives here. Section and
   component stylesheets may only reference these; no raw hex or magic numbers
   downstream. Changing the brand should mean changing this file alone.
   ========================================================================== */

:root {
  /* ---------------------------------------------------------------- colour */
  /* Warm neutral ground. The warmth is deliberate: a cool grey reads as
     generic SaaS, a warm one reads as paper and printed work orders. */
  --c-canvas: #f5f4ee;
  --c-canvas-sunk: #efeee7; /* recessed bands within a light section */
  --c-panel: #e7e8e0;
  --c-ink: #101210;
  --c-ink-soft: #3a3d39;
  --c-muted: #6f736c; /* darkened from the brief's #757970 to clear AA on canvas */
  --c-line: #d6d5cc;
  --c-line-strong: #b9b8ad;

  /* Dark ground, used only for the two immersive sections so the shift means
     something when it arrives. */
  --c-dark: #101210;
  --c-dark-raised: #1a1d1a;
  --c-dark-ink: #f2f1ea;
  --c-dark-muted: #9a9d95;
  --c-dark-line: #2c302c;

  /* Primary accent: the "connected" colour. State, connective lines, the
     primary CTA, anything that represents work flowing properly. */
  --c-accent: #2e6b4f;
  --c-accent-deep: #22523c;
  --c-accent-soft: #dce9e0;
  --c-accent-on-dark: #6fbf90; /* lifted for contrast against --c-dark */

  /* Secondary accent: warm clay. The counterpart to the green — it marks the
     BEFORE side of every comparison (the scattered stack, the manual step, the
     delayed order). Giving the problem its own colour is what stops the page
     reading as one flat green wash, and it means colour carries meaning rather
     than decorating. Never used for a CTA. */
  --c-clay: #b8663a;
  --c-clay-deep: #8f4b28;
  --c-clay-soft: #f7e7dc;
  --c-clay-on-dark: #e0946a;

  /* Two further hues, kept deliberately small in scope. They exist only to
     tell the four workflow families apart in the catalogue and to tint the
     odd icon — never for a CTA, a state, or a surface. Four families need
     four colours; green and clay are already spoken for. */
  --c-slate: #3c5a72;
  --c-slate-soft: #dfe7ee;
  --c-ochre: #96702a;
  --c-ochre-soft: #f2e8d2;

  /* Tinted grounds. Sections alternate between these instead of plain canvas,
     which is most of what makes the page feel alive rather than beige. */
  --c-tint-green: #e6eee8;
  --c-tint-clay: #f6ece4;

  /* Status colours for the operational objects. Muted on purpose: a shop floor
     screen shouldn't look like a dashboard demo. */
  --c-warn: #9a6b1f;
  --c-warn-soft: #f4e9d4;
  --c-stop: #97402f;
  --c-stop-soft: #f6e2dc;

  --c-focus: #2e6b4f;

  /* ------------------------------------------------------------ typography */
  --font-sans: 'Inter Variable', 'Inter', -apple-system, BlinkMacSystemFont,
    'Segoe UI', system-ui, sans-serif;
  /* The mono is the voice of the machine: codes, quantities, states, labels.
     System stack only, so it costs nothing to load. */
  --font-mono: ui-monospace, 'SF Mono', SFMono-Regular, Menlo, 'Roboto Mono',
    monospace;

  /* Fluid scale. Display sizes are clamped so they never outgrow a small
     viewport, and never look timid on a large one. */
  --t-display: clamp(2.4rem, 4.6vw, 4.2rem);
  --t-title: clamp(1.95rem, 3.4vw, 2.7rem);
  --t-headline: clamp(1.5rem, 2.3vw, 2.1rem);
  --t-lead: clamp(1.125rem, 1.5vw, 1.4rem);
  --t-body: clamp(1.0625rem, 1.05vw, 1.125rem);
  --t-small: 1rem;
  /* Mono caps labels. Raised from 0.75rem: at that size, letter-spaced uppercase
     on a warm ground was legible but effortful, and these labels carry real
     information (codes, states, quantities) rather than decoration. */
  --t-micro: 0.8125rem;
  --t-nano: 0.6875rem; /* the genuinely incidental: units, legends, ratios */
  --t-numeral: clamp(3rem, 7vw, 7rem); /* the giant statistic treatment */

  --lh-display: 0.94;
  --lh-title: 1.02;
  --lh-heading: 1.12;
  --lh-body: 1.6;

  --ls-display: -0.035em;
  --ls-title: -0.028em;
  --ls-body: -0.011em;
  --ls-micro: 0.14em;

  /* ----------------------------------------------------------------- space */
  --s-1: 0.25rem;
  --s-2: 0.5rem;
  --s-3: 0.75rem;
  --s-4: 1rem;
  --s-5: 1.5rem;
  --s-6: 2rem;
  --s-7: 3rem;
  --s-8: 4rem;
  --s-9: 6rem;
  --s-10: 8rem;

  /* Vertical rhythm between narrative beats. Sections breathe more on desktop
     because the story depends on each beat arriving alone. */
  --section-y: clamp(2.75rem, 4.4vw, 4rem);
  --section-y-tight: clamp(2rem, 3vw, 2.75rem);

  /* Where a pinned section comes to rest: clear of the strip and the nav.
     Named rather than repeated, because JavaScript measures the used value to
     work out how long a pinned section has to be — so the stylesheet has to
     stay the one place the offset is decided. */
  --pin-top: calc(var(--strip-height, 44px) + 5rem);

  /* ------------------------------------------------------------ structure */
  --grid-max: 1520px;
  --grid-gutter: clamp(1.25rem, 4vw, 4.5rem);
  --grid-gap: clamp(1rem, 1.8vw, 2rem);
  --measure: 62ch; /* longest comfortable paragraph */
  --measure-tight: 46ch;

  /* Small radii. Big rounded cards are the SaaS tell the brief asks us to
     avoid; the device frames are the only place a larger radius appears. */
  --r-xs: 2px;
  --r-sm: 4px;
  --r-md: 8px;
  --r-lg: 14px;
  --r-pill: 999px;

  --border: 1px solid var(--c-line);
  --border-strong: 1px solid var(--c-line-strong);
  --border-ink: 1px solid var(--c-ink);
  --border-dark: 1px solid var(--c-dark-line);

  /* Shadows are rare and shallow — objects sit on the page, they don't hover
     above it. */
  --shadow-lift: 0 1px 2px rgb(16 18 16 / 6%), 0 8px 24px rgb(16 18 16 / 6%);
  --shadow-card: 0 1px 0 rgb(16 18 16 / 4%);

  /* ---------------------------------------------------------------- motion */
  /* One place for every duration and curve, so the whole page shares a feel. */
  --d-fast: 140ms;
  --d-base: 260ms;
  --d-slow: 520ms;
  --d-reveal: 720ms;

  --e-out: cubic-bezier(0.22, 1, 0.36, 1); /* settle: entrances, reveals */
  --e-in-out: cubic-bezier(0.65, 0, 0.35, 1); /* symmetric: state changes */
  --e-snap: cubic-bezier(0.34, 1.4, 0.64, 1); /* slight overshoot: selection */

  /* Stagger step for sequenced children; multiplied by index in CSS. */
  --stagger: 55ms;

  /* ------------------------------------------------------------------ misc */
  --z-strip: 60;
  --z-nav: 50;
  --z-menu: 55;
  --z-raised: 10;

  --scan-line: rgb(16 18 16 / 6%); /* faint column rules on light */
  --scan-line-dark: rgb(242 241 234 / 7%); /* and on dark */
}

/* A section can opt into the dark ground by setting one attribute; every token
   below is re-pointed so components inside need no dark-specific rules. */
[data-ground='dark'] {
  --c-canvas: var(--c-dark);
  --c-canvas-sunk: var(--c-dark-raised);
  --c-panel: var(--c-dark-raised);
  --c-ink: var(--c-dark-ink);
  --c-ink-soft: #ceccc4;
  --c-muted: var(--c-dark-muted);
  --c-line: var(--c-dark-line);
  --c-line-strong: #454a45;
  --c-accent: var(--c-accent-on-dark);
  --c-accent-soft: #1d3a2a;
  --c-clay: var(--c-clay-on-dark);
  --c-clay-deep: var(--c-clay-on-dark);
  --c-clay-soft: #3a2318;
  --c-tint-green: #17231b;
  --c-tint-clay: #241a14;
  --c-slate: #8fb0c9;
  --c-slate-soft: #1c2a34;
  --c-ochre: #d3ab63;
  --c-ochre-soft: #2c2415;
  --c-focus: var(--c-accent-on-dark);
  --scan-line: var(--scan-line-dark);
  color-scheme: dark;
}


/* ==== assets/css/base.css ==== */
/* =============================================================================
   Base — reset, typography defaults, focus, accessibility and motion policy.
   ========================================================================== */

@font-face {
  font-family: 'Inter Variable';
  src: url('../fonts/inter-latin-wght-normal.woff2') format('woff2');
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
    U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2192, U+2190,
    U+2212, U+2215;
}

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

/*
 * `hidden` means hidden, whatever else a component says.
 *
 * The attribute is only `display: none` in the USER-AGENT stylesheet, and any
 * author `display` beats the whole UA origin no matter how weak its selector.
 * So a component that sets `display: flex` on itself silently cancels its own
 * `hidden` attribute, and the JS that toggles it goes on working while nothing
 * happens on screen. That shipped once — the contact form's "Your enquiry has
 * reached us" panel stood permanently open at the bottom of the page — and
 * .catalogue__panel[hidden] is an earlier patch for the same collision.
 *
 * One rule, in the reset, so no component has to remember.
 */
[hidden] {
  display: none !important;
}

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  /*
   * Where an anchor jump comes to rest.
   *
   * Without this the browser puts the target's top edge at the very top of the
   * viewport — underneath the strip and the navigation, which together stand
   * 140px tall and are both fixed to it. Clicking "For warehousing" left 139px
   * of that section hidden behind the chrome, which reads as the page having
   * scrolled too far. The strip's real height is published by nav.js and
   * shrinks when it compacts, so this follows it rather than assuming.
   */
  scroll-padding-block-start: calc(var(--strip-rest-height, 49px) + 7rem);
}

/* Below 900px the strip scrolls away (see chrome.css), so an anchor only has
   the navigation to clear. Deriving this from the strip's height there would
   push every landing a further 130px down the page for a bar that is no longer
   on screen. */
@media (max-width: 900px) {
  html {
    scroll-padding-block-start: 6.5rem;
  }
  /* Belt and braces with the rule on body: a scroll container can be either
     element depending on which one the browser picks as the viewport, and only
     clipping one of them still left a horizontal scrollbar at phone widths. */
  overflow-x: clip;
}

body {
  margin: 0;
  background: var(--c-canvas);
  color: var(--c-ink);
  font-family: var(--font-sans);
  font-size: var(--t-body);
  line-height: var(--lh-body);
  letter-spacing: var(--ls-body);
  font-synthesis-weight: none;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  /* The hero animation and several diagrams extend past their box on purpose;
     clipping here is what guarantees the §48 "no horizontal overflow" rule. */
  overflow-x: clip;
}

h1,
h2,
h3,
h4,
p,
figure,
blockquote,
ul,
ol,
dl,
dd {
  margin: 0;
}

ul,
ol {
  padding: 0;
  list-style: none;
}

img,
svg,
video {
  display: block;
  max-width: 100%;
}

button,
input,
select,
textarea {
  font: inherit;
  color: inherit;
  letter-spacing: inherit;
}

button {
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}

a {
  color: inherit;
  text-decoration-thickness: 1px;
  text-underline-offset: 0.22em;
}

/* --------------------------------------------------------------- headings */

.display {
  font-size: var(--t-display);
  line-height: var(--lh-display);
  letter-spacing: var(--ls-display);
  font-weight: 560;
  text-wrap: balance;
}

.title {
  font-size: var(--t-title);
  line-height: var(--lh-title);
  letter-spacing: var(--ls-title);
  font-weight: 550;
  text-wrap: balance;
}

.headline {
  font-size: var(--t-headline);
  line-height: var(--lh-heading);
  letter-spacing: -0.02em;
  font-weight: 550;
  text-wrap: balance;
}

.lead {
  font-size: var(--t-lead);
  line-height: 1.5;
  color: var(--c-ink-soft);
  max-width: var(--measure);
  text-wrap: pretty;
}

.prose {
  max-width: var(--measure);
  color: var(--c-ink-soft);
  text-wrap: pretty;
}

.prose + .prose {
  margin-top: var(--s-4);
}

.muted {
  color: var(--c-muted);
}

.numeral {
  font-size: var(--t-numeral);
  line-height: 0.82;
  letter-spacing: -0.05em;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}

/* The mono caps label is the page's connective voice: section eyebrows, part
   codes, quantities, statuses. Consistency here is what makes the operational
   objects feel like one system. */
.mono {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  font-weight: 500;
  line-height: 1.4;
}

.eyebrow {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  font-weight: 500;
  color: var(--c-muted);
  display: flex;
  align-items: center;
  gap: var(--s-3);
}

/* Short accent rule that precedes an eyebrow, echoing a drawing's leader line. */
.eyebrow::before {
  content: '';
  inline-size: 1.75rem;
  block-size: 1px;
  background: var(--c-accent);
  flex: none;
}

/* ------------------------------------------------------- focus & a11y utils */

:focus-visible {
  outline: 2px solid var(--c-focus);
  outline-offset: 3px;
  border-radius: var(--r-xs);
}

/* Only remove the default ring where a component draws its own, never globally. */
:focus:not(:focus-visible) {
  outline: none;
}

.visually-hidden {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.skip-link {
  position: absolute;
  inset-inline-start: var(--s-4);
  inset-block-start: -100%;
  z-index: 100;
  background: var(--c-ink);
  color: var(--c-canvas);
  padding: var(--s-3) var(--s-5);
  border-radius: var(--r-sm);
  font-weight: 500;
}

.skip-link:focus {
  inset-block-start: var(--s-4);
}

/* --------------------------------------------------------- reveal utility */

/* The single entrance primitive. JS adds .is-revealed via one shared
   IntersectionObserver; CSS owns the actual movement. */
.reveal {
  opacity: 0;
  transform: translate3d(0, 18px, 0);
  transition:
    opacity var(--d-reveal) var(--e-out),
    transform var(--d-reveal) var(--e-out);
  transition-delay: calc(var(--reveal-index, 0) * var(--stagger));
}

.reveal.is-revealed {
  opacity: 1;
  transform: none;
}

/* ------------------------------------------------------------ print/motion */

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

  /* Content must still arrive — it just arrives without travel. Scroll-linked
     sequences are separately pinned to their end state in motion.js. */
  .reveal {
    opacity: 1;
    transform: none;
    transition: none;
  }

  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    scroll-behavior: auto !important;
  }
}


/* =============================================================================
   Touch targets.

   Scoped to `pointer: coarse` rather than to a width, because the thing that
   matters is a fingertip, not a screen size — a small tablet with a mouse does
   not need this and a large phone does.

   WCAG 2.2 requires 24x24 (SC 2.5.8); Apple and Material both ask for 44. The
   audit found footer links at 18px, tabs at 37px and the strip's link at 24px.
   Padding rather than height, so the hit area grows without the design moving:
   the text sits where it did and the tappable box around it gets bigger.
   ========================================================================== */

@media (pointer: coarse) {
  /* .strip__action is deliberately NOT here: it carries a rule under its text,
     and a 44px box would draw that rule 30px below the words. It gets the same
     hit area from an overlay instead -- see chrome.css. */
  .footer__link,
  .footer__social-link,
  .logo-slot {
    display: inline-flex;
    align-items: center;
    min-block-size: 44px;
  }

  /* The footer's links are a wrapped row; the extra height would double the
     gaps if the row kept its own. */
  .footer__links,
  .footer__legal-links {
    row-gap: 0;
  }

  .tab {
    min-block-size: 44px;
  }

  .accordion__trigger,
  .explorer__option {
    min-block-size: 48px;
  }
}


/*
 * Wording that depends on the input device.
 *
 * `hover` is meaningless on a touchscreen and `tap` is wrong with a mouse, so
 * both sentences are in the markup and the one that does not apply is removed.
 * `display: none` rather than visibility, so a screen reader is not read both.
 */
.on-touch { display: none; }

@media (pointer: coarse) {
  .on-hover { display: none; }
  .on-touch { display: inline; }
}


/* ==== assets/css/layout.css ==== */
/* =============================================================================
   Layout — editorial grid, section rhythm, and the shared structural devices
   (column rules, hairline dividers, full-bleed escapes).
   ========================================================================== */

.shell {
  inline-size: min(100% - (var(--grid-gutter) * 2), var(--grid-max));
  margin-inline: auto;
}

/* ------------------------------------------------------------- section box */

.section {
  position: relative;
  padding-block: var(--section-y);
  background: var(--c-canvas);
  color: var(--c-ink);
}

.section--tight {
  padding-block: var(--section-y-tight);
}

/* Full-bleed sections carry their own ground colour edge to edge. */
.section--bleed {
  overflow: clip;
}

/* Faint vertical column rules. Borrowed as a principle from editorial data
   pages: the grid becomes quiet decoration and gives dark sections depth
   without imagery. Pointer-events off so it never intercepts interaction. */
.section--ruled::before {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background-image: repeating-linear-gradient(
    to right,
    var(--scan-line) 0 1px,
    transparent 1px calc(100% / 8)
  );
  background-size: calc(100% / 8) 100%;
  opacity: 0.9;
}

@media (max-width: 700px) {
  .section--ruled::before {
    background-image: repeating-linear-gradient(
      to right,
      var(--scan-line) 0 1px,
      transparent 1px calc(100% / 4)
    );
    background-size: calc(100% / 4) 100%;
  }
}

/* ------------------------------------------------------------ section head */

/* Deliberately not a component: sections opt in, and several skip it entirely
   so the page doesn't fall into a rhythm of identical headers. */
.section-head {
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
  margin-block-end: var(--s-8);
}

.section-head--split {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 0.85fr);
  gap: var(--s-7) var(--grid-gap);
  align-items: end;
}

@media (max-width: 900px) {
  .section-head--split {
    grid-template-columns: minmax(0, 1fr);
    align-items: start;
  }
}

/* A row of mono metadata sitting on a hairline — used to date-stamp or label a
   composition the way a drawing sheet does. */
.sheet-line {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3) var(--s-6);
  justify-content: space-between;
  align-items: baseline;
  padding-block: var(--s-3);
  border-block-start: var(--border);
  color: var(--c-muted);
}

/* ------------------------------------------------------------------ stacks */

.stack {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

.cluster {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3);
  align-items: center;
}



/* ==== assets/css/components/button.css ==== */
/* =============================================================================
   Buttons — three weights only. The accent fill is reserved for the single
   primary action in any given view.
   ========================================================================== */

.btn {
  --btn-bg: transparent;
  --btn-fg: var(--c-ink);
  --btn-border: var(--c-line-strong);

  display: inline-flex;
  align-items: center;
  gap: var(--s-3);
  padding: 0.8em 1.35em;
  background: var(--btn-bg);
  color: var(--btn-fg);
  border: 1px solid var(--btn-border);
  border-radius: var(--r-pill);
  font-size: var(--t-small);
  font-weight: 500;
  letter-spacing: -0.005em;
  text-decoration: none;
  white-space: nowrap;
  transition:
    background var(--d-base) var(--e-in-out),
    color var(--d-base) var(--e-in-out),
    border-color var(--d-base) var(--e-in-out),
    transform var(--d-fast) var(--e-out);
}

/* The arrow leans forward on hover. One small idea, applied everywhere, rather
   than a different flourish per button. */
.btn__arrow {
  display: inline-block;
  transition: transform var(--d-base) var(--e-out);
}

.btn:hover .btn__arrow {
  transform: translateX(3px);
}

.btn:active {
  transform: translateY(1px);
}

/* ------------------------------------------------------------------ primary */

.btn--primary {
  --btn-bg: var(--c-accent);
  --btn-fg: #fbfbf7;
  --btn-border: var(--c-accent);
}

.btn--primary:hover {
  --btn-bg: var(--c-accent-deep);
  --btn-border: var(--c-accent-deep);
}

/* On the dark ground the accent is lightened, so the label must flip to dark to
   stay readable. */
[data-ground='dark'] .btn--primary {
  --btn-fg: var(--c-dark);
}

[data-ground='dark'] .btn--primary:hover {
  --btn-bg: #8ed3aa;
  --btn-border: #8ed3aa;
}

/* ---------------------------------------------------------------- secondary */

.btn--secondary:hover {
  --btn-border: var(--c-ink);
  --btn-bg: rgb(16 18 16 / 4%);
}

[data-ground='dark'] .btn--secondary:hover {
  --btn-bg: rgb(242 241 234 / 8%);
}

/* --------------------------------------------------------------------- text */

.btn--text {
  padding-inline: 0;
  border-color: transparent;
  border-radius: var(--r-xs);
}

.btn--text:hover {
  color: var(--c-accent);
}

.btn--lg {
  padding: 1em 1.7em;
  font-size: var(--t-body);
}



/* =============================================================================
   The specular edge.

   A bright highlight riding the button's border, tracking the cursor — light
   catching a bevelled edge. specular.js writes two numbers per button:

     --spec-angle   direction to the cursor, degrees clockwise from the top
     --spec-near    0 to 1, how close the cursor is to the button's edge

   Everything the effect LOOKS like is decided here. The reference ships a base
   colour, a tint and a blur; all three are dropped, because these buttons
   already have fills of their own and painting another underneath would only
   fight them. What is kept is the behaviour — and the colour of the light,
   which has to differ by ground and cannot be a constant.
   ========================================================================== */

.btn[data-specular='on'] {
  /* The ring is an absolutely positioned child, so the button has to be its
     containing block. `isolation` keeps the ring's blend inside the button
     rather than compositing against whatever sits behind it. */
  position: relative;
  isolation: isolate;
  --spec-near: 0;
  --spec-angle: 0;
}

.btn[data-specular='on']::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  /* The ring's thickness. One pixel, to match every other hairline here. */
  padding: 1px;

  /*
   * One lobe of light, 80 degrees wide, centred on the cursor's direction.
   * Offsetting the gradient's start by half the lobe is what centres it: a
   * conic gradient begins at its `from` angle, so starting 40 degrees early
   * puts the peak exactly on the cursor.
   */
  background: conic-gradient(
    from calc((var(--spec-angle) - 40) * 1deg) at 50% 50%,
    transparent 0deg,
    var(--spec-line) 40deg,
    transparent 80deg
  );

  /*
   * Mask the filled rectangle down to its own border. Two copies of the same
   * solid layer — one clipped to the padding box, one to the whole element —
   * composited so that only the difference survives, which is the ring. This
   * is what makes an animated border affordable: it is one composited layer,
   * not a border-image repainted every frame.
   */
  -webkit-mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask:
    linear-gradient(#000 0 0) content-box,
    linear-gradient(#000 0 0);
  mask-composite: exclude;

  opacity: var(--spec-near);
  pointer-events: none;
}

/* The light itself. On the filled green button it is the page's own paper
   white; on an outlined button standing on paper, white would be invisible, so
   the accent does the lighting instead. */
.btn--primary[data-specular='on'] {
  --spec-line: #ffffff;
}

.btn--secondary[data-specular='on'] {
  --spec-line: var(--c-accent);
}

/* On the dark ground the outlined button sits on near-black, where the lifted
   accent reads as light. */
[data-ground='dark'] .btn--secondary[data-specular='on'] {
  --spec-line: var(--c-accent-on-dark);
}

@media (prefers-reduced-motion: reduce) {
  .btn[data-specular='on']::after {
    display: none;
  }
}


/* ==== assets/css/components/object.css ==== */
/* =============================================================================
   The operational object — this site's signature component.

   Every real artifact the page shows (a purchase request, a batch inspection, a
   bin location, a scan confirmation, a stock line) is rendered as one of these:
   a hairline card with a monospace header carrying a code and a status. They are
   the artwork of the page, so they must feel manufactured and consistent —
   never like generic marketing cards.
   ========================================================================== */

.obj {
  --obj-accent: var(--c-line-strong);

  background: var(--c-canvas);
  border: 1px solid var(--c-line);
  border-radius: var(--r-md);
  overflow: hidden;
  transition:
    border-color var(--d-base) var(--e-in-out),
    transform var(--d-base) var(--e-out),
    box-shadow var(--d-base) var(--e-out);
}

/* The top edge carries the state colour — a thin strip, not a coloured card. */
.obj::before {
  content: '';
  display: block;
  block-size: 2px;
  background: var(--obj-accent);
}

.obj__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  padding: var(--s-3) var(--s-4);
  border-block-end: var(--border);
  background: var(--c-canvas-sunk);
}

/* Some codes lead with an icon; keep the two on one baseline. */
.obj__code {
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
}

.obj__code .icon {
  inline-size: 1.1em;
  block-size: 1.1em;
  vertical-align: 0;
  color: var(--obj-accent);
}

.obj__body {
  padding: var(--s-4);
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

.obj__title {
  font-size: 1.0625rem;
  font-weight: 550;
  letter-spacing: -0.015em;
  line-height: 1.3;
}

/* A label/value row — the workhorse for quantities, dates, locations. */
.obj__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-4);
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: 0.04em;
}

.obj__row dt {
  color: var(--c-muted);
  text-transform: uppercase;
  letter-spacing: var(--ls-micro);
}

.obj__row dd {
  margin: 0;
  font-variant-numeric: tabular-nums;
  font-size: var(--t-small);
}

.obj__row--emphasis dd {
  color: var(--obj-accent);
  font-weight: 600;
}

.obj__foot {
  padding: var(--s-3) var(--s-4);
  border-block-start: var(--border);
  background: var(--c-canvas-sunk);
}

/* ------------------------------------------------------------------ states */

.obj[data-state='ok'] {
  --obj-accent: var(--c-accent);
}

.obj[data-state='warn'] {
  --obj-accent: var(--c-warn);
}

.obj[data-state='stop'] {
  --obj-accent: var(--c-stop);
}

.obj[data-state='idle'] {
  --obj-accent: var(--c-line-strong);
}

/* ------------------------------------------------------------------- badge */

/* The status pill that sits in an object's header. Colour is inherited from the
   object's state so a card only declares its state once. */
.badge {
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
  padding: 0.25em 0.6em;
  border-radius: var(--r-pill);
  background: var(--c-canvas);
  border: 1px solid var(--obj-accent, var(--c-line-strong));
  color: var(--obj-accent, var(--c-muted));
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  font-weight: 600;
  white-space: nowrap;
}

.badge__dot {
  inline-size: 5px;
  block-size: 5px;
  border-radius: 50%;
  background: currentcolor;
  flex: none;
}

/* Standalone badges outside an object need explicit colours. */
.badge--ok {
  --obj-accent: var(--c-accent);
}

.badge--quiet {
  --obj-accent: var(--c-line-strong);
  color: var(--c-muted);
}

/* ------------------------------------------------------------------- tags */

/* Small annotations that float beside a diagram node ("manual entry",
   "waiting"). Deliberately lighter than a badge so they read as commentary. */
.tag {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  padding: 0.2em 0.55em;
  border-radius: var(--r-sm);
  background: var(--c-clay-soft);
  color: var(--c-clay-deep);
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  white-space: nowrap;
}

.tag--good {
  background: var(--c-accent-soft);
  color: var(--c-accent-deep);
}

[data-ground='dark'] .tag--good {
  color: var(--c-accent-on-dark);
}

/* --------------------------------------------------------------- interactive */

/* Only objects that are actually clickable get lift. */
a.obj:hover,
button.obj:hover,


/* ==== assets/css/components/tab.css ==== */
/* =============================================================================
   Tabs — the filter controls in the digitise catalogue and the workflow
   before/after switcher. They switch a view rather than accumulate a
   selection, so they use a filled ink state rather than a checkbox.
   ========================================================================== */

.tab {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  padding: 0.55em 1.05em;
  background: transparent;
  border: 1px solid var(--c-line);
  border-radius: var(--r-pill);
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
  transition:
    background var(--d-base) var(--e-in-out),
    border-color var(--d-base) var(--e-in-out),
    color var(--d-base) var(--e-in-out);
}

.tab:hover {
  color: var(--c-ink);
  border-color: var(--c-line-strong);
}

.tab[aria-selected='true'] {
  background: var(--c-ink);
  border-color: var(--c-ink);
  color: var(--c-canvas);
}

.tab__count {
  font-variant-numeric: tabular-nums;
  opacity: 0.6;
}


/* The dock. Geometry is written by dock.js as inline transforms; this owns the
   two things a stylesheet should: what the row promotes, and what the cursor's
   proximity looks like. Tabs keep their hover fill — unlike the nav's links
   these are controls being chosen between, and the fill is the click
   affordance the magnification cannot replace. */
[data-dock='on'] .tab {
  --near: 0;
  transform-origin: 50% 50%;
  will-change: transform;
}

@media (prefers-reduced-motion: reduce) {
  [data-dock='on'] .tab {
    transform: none !important;
    will-change: auto;
  }
}


/* ==== assets/css/components/slider.css ==== */
/* =============================================================================
   Slider — team size control.

   Built on a real <input type="range"> so keyboard support, arrow keys,
   Home/End, and screen-reader announcement come from the platform rather than
   from re-implemented JS. Only the skin is custom.
   ========================================================================== */

.slider {
  /* The thumb's size is geometry, not decoration: the fill has to stop where
     the thumb's centre stops, and both thumb rules below need the same number.
     Stated once here so those three cannot drift apart. */
  --thumb: 26px;

  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

.slider__value {
  display: flex;
  align-items: baseline;
  gap: var(--s-3);
}

.slider__number {
  font-size: clamp(3rem, 6vw, 5rem);
  line-height: 0.9;
  letter-spacing: -0.04em;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
}

.slider__unit {
  font-size: var(--t-lead);
  color: var(--c-muted);
}

.slider__control {
  position: relative;
  display: block;
  padding-block: var(--s-4);
}

/* The track is drawn on the wrapper so the filled portion can be animated with
   a custom property rather than a repaint of the input itself. */
.slider__control::before,
.slider__control::after {
  content: '';
  position: absolute;
  inset-block-start: 50%;
  translate: 0 -50%;
  block-size: 2px;
  border-radius: 1px;
  pointer-events: none;
}

.slider__control::before {
  inset-inline: 0;
  background: var(--c-line-strong);
}

/*
 * The thumb's CENTRE travels from half a thumb in to half a thumb short of the
 * end -- that is how a range input works -- so a fill measured across the whole
 * box only agrees with the thumb at the midpoint. At the top of the range the
 * accent ran a clear 13px past the knob it was supposed to be following.
 */
.slider__control::after {
  inset-inline-start: 0;
  inline-size: calc(var(--thumb) / 2 + var(--fill, 0) * (100% - var(--thumb)));
  background: var(--c-accent);
}

.slider__input {
  -webkit-appearance: none;
  appearance: none;
  /*
   * Block, not the default inline.
   *
   * An inline replaced element sits on the line box's baseline, so the control
   * was as tall as a line of text and the input was parked wherever the
   * baseline happened to fall -- 4px below the track it is meant to sit on, and
   * 15px above it once the touch rule at the foot of this file gave the input a
   * real height. The track is centred on the control, so the input has to be
   * the only thing deciding the control's height for the two to coincide.
   */
  display: block;
  position: relative;
  z-index: 1;
  inline-size: 100%;
  block-size: 2px;
  margin: 0;
  background: transparent;
  cursor: pointer;
}

/* Thumb: 26px is above the 24px touch-target floor and still looks precise. */
.slider__input::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  inline-size: var(--thumb);
  block-size: var(--thumb);
  border-radius: 50%;
  background: var(--c-canvas);
  border: 1.5px solid var(--c-accent);
  box-shadow: var(--shadow-lift);
  cursor: grab;
  transition:
    transform var(--d-fast) var(--e-out),
    border-color var(--d-fast) var(--e-out);
}

.slider__input::-moz-range-thumb {
  inline-size: var(--thumb);
  block-size: var(--thumb);
  border-radius: 50%;
  background: var(--c-canvas);
  border: 1.5px solid var(--c-accent);
  box-shadow: var(--shadow-lift);
  cursor: grab;
  transition:
    transform var(--d-fast) var(--e-out),
    border-color var(--d-fast) var(--e-out);
}

.slider__input:hover::-webkit-slider-thumb {
  transform: scale(1.08);
}

.slider__input:hover::-moz-range-thumb {
  transform: scale(1.08);
}

.slider__input:active::-webkit-slider-thumb {
  cursor: grabbing;
  background: var(--c-accent-soft);
}

.slider__input:active::-moz-range-thumb {
  cursor: grabbing;
  background: var(--c-accent-soft);
}

/* The platform ring lands on the input box, which is only 2px tall — draw it on
   the thumb instead so keyboard focus is actually visible. */
.slider__input:focus-visible {
  outline: none;
}

.slider__input:focus-visible::-webkit-slider-thumb {
  outline: 2px solid var(--c-focus);
  outline-offset: 3px;
}

.slider__input:focus-visible::-moz-range-thumb {
  outline: 2px solid var(--c-focus);
  outline-offset: 3px;
}

.slider__scale {
  display: flex;
  justify-content: space-between;
  color: var(--c-muted);
  font-variant-numeric: tabular-nums;
}


/*
 * Touch hit area.
 *
 * The input's own box is 2px tall — the visible track is painted by a sibling
 * and the thumb is drawn outside the box, so a finger landing anywhere but
 * exactly on the thumb misses entirely.
 *
 * Height gives it a real target; the matching negative margin keeps the LAYOUT
 * box at 2px so nothing below moves. The input is transparent, so there is
 * nothing new to see.
 *
 * This lives here rather than in base.css because base.css is linked first —
 * the identical rule there loses the cascade to the 2px above, silently.
 */
@media (pointer: coarse) {
  .slider__input {
    block-size: 44px;
    margin-block: -21px;
  }
}


/* ==== assets/css/components/accordion.css ==== */
/* =============================================================================
   Accordion — FAQ rows.

   Generous full-width rows with a hairline between them; the answer opens with
   a grid-template-rows transition so the height animates without JS measuring
   the content (and therefore without forcing layout on every frame).
   ========================================================================== */

.accordion {
  border-block-start: var(--border);
}

.accordion__item {
  border-block-end: var(--border);
}

.accordion__trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-5);
  inline-size: 100%;
  padding-block: clamp(var(--s-5), 3vw, var(--s-7));
  text-align: start;
  font-size: clamp(1.1rem, 2vw, 1.6rem);
  font-weight: 500;
  letter-spacing: -0.018em;
  line-height: 1.25;
  color: var(--c-ink);
  transition: color var(--d-base) var(--e-in-out);
}

.accordion__trigger:hover {
  color: var(--c-accent);
}

.accordion__icon {
  position: relative;
  flex: none;
  inline-size: 40px;
  block-size: 40px;
  border: 1px solid var(--c-line-strong);
  border-radius: 50%;
  transition:
    background var(--d-base) var(--e-in-out),
    border-color var(--d-base) var(--e-in-out),
    transform var(--d-slow) var(--e-out);
}

/* Two bars; the vertical one collapses on open, so the plus becomes a minus and
   the whole icon rotates a quarter turn. */
.accordion__icon::before,
.accordion__icon::after {
  content: '';
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  background: currentcolor;
  translate: -50% -50%;
  transition: transform var(--d-slow) var(--e-out);
}

.accordion__icon::before {
  inline-size: 13px;
  block-size: 1.5px;
}

.accordion__icon::after {
  inline-size: 1.5px;
  block-size: 13px;
}

.accordion__trigger:hover .accordion__icon {
  border-color: var(--c-accent);
}

.accordion__trigger[aria-expanded='true'] .accordion__icon {
  transform: rotate(90deg);
  background: var(--c-accent-soft);
  border-color: var(--c-accent);
  color: var(--c-accent-deep);
}

.accordion__trigger[aria-expanded='true'] .accordion__icon::after {
  transform: translate(-50%, -50%) scaleY(0);
}

/* The panel is a 0fr → 1fr grid row. Content stays in the DOM and readable
   throughout the transition. */
.accordion__panel {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows var(--d-slow) var(--e-in-out);
}

.accordion__panel[data-open='true'] {
  grid-template-rows: 1fr;
}

.accordion__panel > div {
  overflow: hidden;
}

.accordion__answer {
  max-width: var(--measure);
  padding-block-end: var(--s-6);
  color: var(--c-ink-soft);
  text-wrap: pretty;
}

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

/* =============================================================================
   The wallet.

   The questions ride out of a document wallet and slide back behind its pocket
   as the section is read. Taken from the reference recording, rebuilt in our
   own colours and at about half its pace — the original moved fast enough that
   the cards read as a blur rather than as paper.

   Two edges, two different mechanisms, both deliberate:

     top     a mask. Fades the cards out as they leave.
     bottom  the pocket. An opaque shape drawn IN FRONT of them.

   Neither is an `overflow` container, and that is the point: an overflow
   wrapper becomes a scroll container, so tabbing to a card that had travelled
   out of view would scroll it and tear the whole stack off its transform. A
   mask and an opaque overlay do the same job to the eye and are invisible to
   focus.
   ========================================================================== */

.faq__stage {
  position: relative;
  /* Written by faq.js from the measured card column: the taller the stack, the
     longer the scroll, so adding a question slows the section rather than
     speeding the cards up. */
  min-block-size: var(--faq-length, 220vh);
  --progress: 0;
}

/*
 * The mask lives HERE, on the element with a defined height — not on the rail.
 * On the rail it was measured against the card column, so it faded the first
 * card's top edge and clipped nothing: cards travelling past the top simply
 * spilled out over the section heading.
 *
 * `no-repeat` is load-bearing. A mask defaults to repeating, which tiles the
 * gradient forever and masks nothing; with no-repeat, everything outside the
 * wallet's own box falls outside the mask and is therefore clipped — the
 * clipping an `overflow` container would give, without becoming a scroll
 * container that focus can yank around.
 */
.faq__wallet {
  position: sticky;
  inset-block-start: var(--pin-top);
  block-size: min(74vh, 41rem);
  /* Same idiom as .nav__inner: cap the measure, but never at the cost of the
     page gutter. At 46rem flat the cards ran their borders off both screen
     edges on a phone while the section heading beside them stayed inset. */
  inline-size: min(100% - (var(--grid-gutter) * 2), 46rem);
  margin-inline: auto;
  -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 8%, #000 100%);
  mask-image: linear-gradient(to bottom, transparent 0, #000 8%, #000 100%);
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
}

/* The travelling column. `--start` puts the first card down inside the pocket;
   `--travel` carries the last one clear of the top. Both are measured. */
.faq__rail {
  position: absolute;
  inset-inline: 0;
  inset-block-start: 0;
  transform: translateY(
    calc(var(--start, 22%) - var(--travel, 100%) * var(--progress))
  );
}

/* Cards read as loose sheets: paper white, hairline, a shadow that says the
   sheet is lifted off the one behind it. */
.faq__rail .accordion {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
  border: 0;
}

.faq__rail .accordion__item {
  border: 1px solid var(--c-line-strong);
  border-radius: var(--r-md);
  background: #fbfaf5;
  box-shadow: 0 1px 2px rgb(16 18 16 / 5%), 0 10px 26px rgb(16 18 16 / 6%);
  overflow: hidden;
}

.faq__rail .accordion__trigger {
  padding: var(--s-5) var(--s-6);
}

.faq__rail .accordion__panel {
  padding-inline: var(--s-6);
}

/* --------------------------------------------------------------- the pocket */

/* The front of the wallet, with the diagonal cut that makes it read as a
   document sleeve rather than as a coloured rectangle. */
.faq__pocket {
  position: absolute;
  inset-inline: -2%;
  inset-block-end: -1px;
  block-size: 38%;
  z-index: 2;
  background: var(--c-accent);
  border-radius: 0 0 var(--r-lg) var(--r-lg);
  clip-path: polygon(0 30%, 44% 30%, 100% 0, 100% 100%, 0 100%);
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--s-4);
  padding: var(--s-5) var(--s-6);
  pointer-events: none;
}

.faq__pocket-tab,
.faq__pocket-count {
  color: color-mix(in srgb, #f5f4ee 78%, var(--c-accent));
}

.faq__pocket-count {
  font-variant-numeric: tabular-nums;
}

/* A hairline along the cut, so the fold catches light like a real crease. */
.faq__pocket::before {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(
    116deg,
    transparent 49.6%,
    rgb(245 244 238 / 22%) 49.6% 50%,
    transparent 50%
  );
}

@media (max-width: 700px) {
  .faq__wallet {
    block-size: min(76vh, 32rem);
  }

  .faq__rail .accordion__trigger {
    padding: var(--s-4) var(--s-5);
  }

  .faq__rail .accordion__panel {
    padding-inline: var(--s-5);
  }
}

/*
 * With motion reduced the wallet is dead weight: the cards must not depend on a
 * scroll transform to be reachable. The stage collapses to its natural height
 * and the accordion is just an accordion again.
 */
/*
 * The wallet collapses to a plain accordion in two cases, and for the same
 * reason both times: the travelling stack is a flourish, and where it cannot
 * pay for itself the questions matter more than the gesture.
 *
 *   reduced motion — the reader asked for less of exactly this
 *   narrow screens  — the pocket covers 38% of the wallet, so on a phone a
 *                     third of the visible area is decoration and seven
 *                     questions cost about two thousand pixels of scroll to
 *                     read. An accordion shows all seven in one screen.
 *
 * A media query LIST rather than two duplicated blocks, so the two conditions
 * cannot drift apart later.
 */
@media (prefers-reduced-motion: reduce), (max-width: 760px) {
  .faq__stage {
    min-block-size: 0;
  }

  .faq__wallet {
    position: static;
    block-size: auto;
  }

  .faq__rail {
    position: static;
    transform: none;
    mask-image: none;
  }

  .faq__pocket {
    display: none;
  }
}


/* ==== assets/css/components/field.css ==== */
/* =============================================================================
   Form fields — used only by the closing contact section.

   Underline fields rather than boxes: five boxed inputs stacked would read as
   the "government application" the brief warns against.
   ========================================================================== */

.field {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.field__label {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
}

.field__input,
.field__select,
.field__textarea {
  inline-size: 100%;
  padding: var(--s-3) 0;
  background: transparent;
  border: 0;
  border-block-end: 1px solid var(--c-line-strong);
  border-radius: 0;
  color: var(--c-ink);
  font-size: var(--t-body);
  transition:
    border-color var(--d-base) var(--e-in-out),
    background var(--d-base) var(--e-in-out);
}

.field__input::placeholder,
.field__textarea::placeholder {
  color: var(--c-muted);
  opacity: 0.75;
}

.field__input:focus,
.field__select:focus,
.field__textarea:focus {
  outline: none;
  border-block-end-color: var(--c-accent);
}

/* The underline alone is not a sufficient focus indicator for keyboard users,
   so add a real ring on keyboard focus only. */
.field__input:focus-visible,
.field__select:focus-visible,
.field__textarea:focus-visible {
  outline: 2px solid var(--c-focus);
  outline-offset: 4px;
  border-radius: var(--r-xs);
}

.field__textarea {
  min-block-size: 7.5rem;
  resize: vertical;
  line-height: 1.55;
}

.field__select {
  appearance: none;
  cursor: pointer;
  /* Chevron drawn inline so the control needs no image request. */
  background-image: linear-gradient(45deg, transparent 50%, currentcolor 50%),
    linear-gradient(135deg, currentcolor 50%, transparent 50%);
  background-position:
    calc(100% - 10px) calc(50% + 2px),
    calc(100% - 5px) calc(50% + 2px);
  background-size: 5px 5px;
  background-repeat: no-repeat;
  padding-inline-end: var(--s-6);
}

.field__select option {
  color: #101210;
  background: #f5f4ee;
}

/* Inline validation message — hidden until the field is marked invalid. */
.field__error {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--c-stop);
}

[data-ground='dark'] .field__error {
  color: #e39b87;
}

.field__error:empty {
  display: none;
}

.field :is(input, textarea, select)[aria-invalid='true'] {
  border-block-end-color: var(--c-stop);
}

.form-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--s-6) var(--grid-gap);
}

.form-grid__full {
  grid-column: 1 / -1;
}

@media (max-width: 620px) {
  .form-grid {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* Submission acknowledgement. Announced politely rather than stealing focus. */
.form-status {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-accent);
}

/* The way round the form, for a reader who would rather not use one. Quiet:
   it is an alternative, not a competing call to action. */
.form-fallback {
  margin-block-start: var(--s-3);
  font-size: var(--t-small);
  color: var(--c-muted);
}

.form-fallback a {
  color: var(--c-accent);
}


/* =============================================================================
   The phone field.

   A dialling-code select sitting against the number input, reading as one
   control. They share a single underline: two separate rules under one label
   look like two unrelated fields that happen to be adjacent.
   ========================================================================== */

.field__group {
  display: flex;
  align-items: stretch;
  gap: var(--s-3);
  border-block-end: 1px solid var(--c-line-strong);
  transition: border-color var(--d-base) var(--e-in-out);
}

/* The underline moves to the group, so the children must not draw their own. */
.field__group .field__select,
.field__group .field__input {
  border-block-end: 0;
}

/* Focus is shown on the group, so the pair lights as one control whichever
   half the reader is actually in. */
.field__group:focus-within {
  border-block-end-color: var(--c-accent);
}

/*
 * The focus ring belongs to the GROUP, not to whichever half of it has focus.
 *
 * The dial code and the number are one field wearing two controls. Ringing the
 * control put a 2px box with a 4px offset around one of them: focusing the
 * number drew a line 125px INTO the field, straight down the middle of the
 * dial select beside it, and 6px past the field on the other side. It read as
 * a box that had come loose rather than as a focus indicator.
 *
 * `:has(:focus-visible)` rather than `:focus-within` on purpose — the ring is
 * for keyboard users, and focus-within would also fire on a mouse click, which
 * is the behaviour the rest of this file deliberately avoids.
 */
.field__group:has(:focus-visible) {
  outline: 2px solid var(--c-focus);
  outline-offset: 4px;
}

/* ...and the control inside it stops drawing its own. */
.field__group .field__input:focus-visible,
.field__group .field__select:focus-visible {
  outline: none;
}

/*
 * Sized in `ch` against the mono face, so the box fits a flag plus the longest
 * dialling code (+1868) and stops there. A percentage would grow with the form
 * and leave the code stranded in white space.
 */
/*
 * Width is set against the LONGEST value, not the typical one.
 *
 * The base .field__select draws its own arrow and reserves --s-6 of padding
 * for it, so any width here is that much smaller in practice — sized by eye
 * against "+31" this clipped to "+4" the moment a four-digit code came up.
 * The longest is "+1268" (Antigua), so the box holds a flag, a space and five
 * characters, plus the arrow's own room.
 */
.field__dial {
  flex: none;
  inline-size: calc(9ch + var(--s-6));
  font-family: var(--font-mono);
  font-size: var(--t-small);
  text-align: start;
  cursor: pointer;
}

.field__group .field__input {
  flex: 1;
  min-inline-size: 0;
}

/* Says the field can be skipped, without a parenthetical in the label that a
   screen reader would read as part of the field's name. */
.field__optional {
  margin-inline-start: 0.6em;
  color: var(--c-muted);
  text-transform: none;
  letter-spacing: 0;
  font-style: italic;
}

[data-ground='dark'] .field__optional {
  color: var(--c-dark-muted);
}

/* The flag glyphs come from the platform. Naming the emoji families first
   keeps the rest of the option in the interface face rather than letting the
   whole string fall back to whatever ships the flags. */
.field__dial option {
  font-family:
    'Apple Color Emoji', 'Segoe UI Emoji', 'Noto Color Emoji', var(--font-mono);
}


/*
 * The honeypot.
 *
 * Moved off-screen rather than `display: none` or `hidden`: the crawlers worth
 * catching skip fields the browser reports as unrendered, and a field nothing
 * fills catches nothing. This stays in the layout tree, so a bot filling every
 * input finds it, while `aria-hidden` and `tabindex="-1"` on the markup keep it
 * away from anyone real.
 */
.honeypot {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

/* =============================================================================
   The acknowledgement.

   A form that quietly clears itself and shows one grey line has not told
   anybody anything. This is the same object the rest of the site is built
   from — a ruled panel with a mono code across its head — so the answer to
   "did that work?" arrives in the page's own language rather than as a
   notification bolted onto it.

   Every colour resolves through the tokens, so it is correct on the dark
   ground the form sits on and would be correct on canvas without a change.
   ========================================================================== */

.sent {
  /* It arrives from a little above and settles, then stands for twenty seconds
     and steps back out of the way. The movement is what stops both ends of
     that reading as the page glitching. */
  transition:
    opacity var(--d-slow) var(--e-out),
    transform var(--d-slow) var(--e-out);
  /* Room above it when the page scrolls it into view, so it does not arrive
     jammed under the sticky bar at the top. */
  scroll-margin-block-start: calc(var(--pin-top, 6rem) + var(--s-5));
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  padding: var(--s-6);
  border: 1px solid var(--c-accent);
  border-radius: var(--r-md);
  background: var(--c-accent-soft);
  max-inline-size: 34rem;
}

/* The head, set like every other record code on the site. The tick comes from
   the site's own sprite — i-approval, the same mark the workflow sections use
   for a step that has passed — rather than from an emoji or a pseudo-element
   drawn out of gradients, which was a guess at a tick and looked like one. */
.sent__mark {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  margin: 0;
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-accent);
}

.sent__tick {
  inline-size: 1.15rem;
  block-size: 1.15rem;
  flex: none;
  color: var(--c-accent);
}

.sent__line {
  margin: 0;
  font-size: var(--t-lead);
  font-weight: 500;
  line-height: 1.25;
  letter-spacing: -0.01em;
  color: var(--c-ink);
  text-wrap: balance;
}

.sent__note {
  margin: 0;
  font-size: var(--t-small);
  color: var(--c-ink-soft);
  max-inline-size: 40ch;
}

/* Where it comes in from. Cleared on the frame after it is shown, so the
   transition above has two states to move between. */
.sent[data-entering='true'] {
  opacity: 0;
  transform: translateY(-0.6rem);
}

.sent[data-leaving='true'] {
  opacity: 0;
  transform: translateY(-0.4rem);
}

/* No fade, and none needed: dismiss() swaps the form back in directly rather
   than waiting for a transition that would never be reported. */
@media (prefers-reduced-motion: reduce) {
  .sent {
    transition: none;
  }
}


/* ==== assets/css/components/placeholder.css ==== */
/* =============================================================================
   Asset placeholders.

   Every missing asset is visible and labelled rather than hidden behind stock
   imagery. One consistent treatment means a developer can find all of them by
   searching for `.ph` (or for "PLACEHOLDER" in the markup) and replace each
   from a single predictable spot.
   ========================================================================== */

.ph {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--s-2);
  padding: var(--s-6);
  text-align: center;
  border: 1px dashed var(--c-line-strong);
  border-radius: var(--r-md);
  background: var(--c-canvas-sunk);
  color: var(--c-muted);
  /* Faint diagonal hatch marks it unmistakably as "not final". */
  background-image: repeating-linear-gradient(
    -45deg,
    transparent 0 9px,
    rgb(16 18 16 / 3%) 9px 10px
  );
}

.ph__label {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-ink-soft);
}

.ph__note {
  font-size: var(--t-small);
  max-inline-size: 34ch;
  line-height: 1.45;
}

.ph__ratio {
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: 0.08em;
  opacity: 0.7;
}

/* Aspect presets so a replaced asset drops in without shifting layout. */
.ph--logo {
  aspect-ratio: 5 / 2;
  padding: var(--s-4);
}

/* The wordmark slot. Sized and positioned exactly as a real logo would be, so
   swapping in an SVG changes nothing else on the page. */
.logo-slot {
  display: inline-flex;
  align-items: center;
  gap: var(--s-3);
  text-decoration: none;
}

.logo-slot__mark {
  inline-size: 22px;
  block-size: 22px;
  flex: none;
}

/* The wordmark is two weights, exactly as the supplied logo sets it: Tools at
   medium, Sync at bold. It reads as one word with the second half carrying the
   emphasis, which is the whole idea of the name. */
.logo-slot__word {
  font-size: 1.15rem;
  font-weight: 500;
  letter-spacing: -0.03em;
}

.logo-slot__sync {
  font-weight: 700;
}


/* ==== assets/css/components/icon.css ==== */
/* =============================================================================
   Icons and the live board.

   Icons come from assets/img/icons.svg — an original 24×24 line set drawn for
   this site. They inherit colour from their surroundings via `currentColor`, so
   a green context makes a green icon and no icon carries its own palette.
   ========================================================================== */

.icon {
  inline-size: 1.25em;
  block-size: 1.25em;
  flex: none;
  /* Optical alignment against a cap-height baseline; icons sit slightly low
     next to text otherwise. */
  vertical-align: -0.22em;
}

.icon--lg {
  inline-size: 2rem;
  block-size: 2rem;
}

/* An icon in a bordered well — used where an icon leads a block of text and
   needs to read as an object rather than a bullet. */
.icon-well {
  display: grid;
  place-items: center;
  inline-size: 2.75rem;
  block-size: 2.75rem;
  flex: none;
  border: 1px solid var(--c-line);
  border-radius: var(--r-md);
  background: var(--c-canvas);
  color: var(--c-accent);
  transition:
    border-color var(--d-base) var(--e-in-out),
    background var(--d-base) var(--e-in-out),
    transform var(--d-base) var(--e-out);
}

.icon-well .icon {
  inline-size: 1.375rem;
  block-size: 1.375rem;
  vertical-align: 0;
}

.icon-well--clay {
  color: var(--c-clay);
}

/* Lifts with its parent card rather than on its own hover, so the whole block
   behaves as one object. */
:is(.approach__stage, .implementation__step, .catalogue__aside):hover .icon-well {
  border-color: currentcolor;
  transform: translateY(-2px);
}

/* =============================================================================
   The live board.

   A slim marquee of operational events between sections — the digital version
   of the andon board on a factory wall. It is the one piece of continuous
   motion on the page, and it earns that because a board that has stopped moving
   is a factory that has stopped.

   It pauses on hover and on focus-within, and stops entirely under reduced
   motion, where it becomes a plain static row.
   ========================================================================== */

.board {
  position: relative;
  overflow: hidden;
  padding-block: var(--s-3);
  background: var(--c-dark);
  color: var(--c-dark-ink);
  border-block: 1px solid var(--c-dark-line);
}

/* Feathered edges so events enter and leave rather than being chopped off. */
.board::before,
.board::after {
  content: '';
  position: absolute;
  inset-block: 0;
  inline-size: clamp(2rem, 8vw, 6rem);
  z-index: 2;
  pointer-events: none;
}

.board::before {
  inset-inline-start: 0;
  background: linear-gradient(90deg, var(--c-dark), transparent);
}

.board::after {
  inset-inline-end: 0;
  background: linear-gradient(270deg, var(--c-dark), transparent);
}

.board__track {
  display: flex;
  inline-size: max-content;
  /* The list is rendered twice; translating exactly -50% therefore lands on an
     identical frame, which is what makes the loop seamless. */
  animation: board-scroll var(--board-duration, 48s) linear infinite;
}

.board:hover .board__track,
.board:focus-within .board__track {
  animation-play-state: paused;
}

@keyframes board-scroll {
  to {
    transform: translateX(-50%);
  }
}

.board__row {
  display: flex;
  align-items: center;
  flex: none;
}

.board__item {
  display: inline-flex;
  align-items: center;
  gap: var(--s-3);
  padding-inline: clamp(var(--s-5), 2.5vw, var(--s-7));
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-dark-muted);
  white-space: nowrap;
}

/* A hairline between events, so the strip reads as a list of records. */
.board__item + .board__item {
  border-inline-start: 1px solid var(--c-dark-line);
}

.board__item .icon {
  color: var(--c-accent-on-dark);
}

.board__code {
  color: var(--c-dark-ink);
}

.board__item[data-tone='warn'] .icon,
.board__item[data-tone='warn'] .board__state {
  color: var(--c-clay-on-dark);
}

.board__state {
  color: var(--c-accent-on-dark);
}

/* The status lamp. One slow pulse — the only looping animation besides the
   marquee itself. */
.board__lamp {
  inline-size: 6px;
  block-size: 6px;
  border-radius: 50%;
  background: var(--c-accent-on-dark);
  flex: none;
  animation: board-pulse 2.6s var(--e-in-out) infinite;
}

@keyframes board-pulse {
  0%,
  100% {
    opacity: 1;
    transform: scale(1);
  }
  50% {
    opacity: 0.45;
    transform: scale(0.82);
  }
}

@media (prefers-reduced-motion: reduce) {
  .board__track {
    animation: none;
  }

  .board__lamp {
    animation: none;
  }

  /* Without the marquee the duplicate row is dead weight, and the strip becomes
     a plain scrollable row of the first few events. */
  .board__row + .board__row {
    display: none;
  }

  .board {
    overflow-x: auto;
  }
}

/* =============================================================================
   Annotations.

   A mono label with a leader line — the callout on an engineer's drawing. It
   names the state of the thing it sits beside; it is never a claim about the
   product. "Material short" is an annotation. "10x faster" would not be.
   ========================================================================== */

.annot {
  /* Leader length is the one knob a caller sets. */
  --leader: 1.5rem;
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  font-weight: 500;
  color: var(--c-muted);
  white-space: nowrap;
}

.annot::before {
  content: '';
  inline-size: var(--leader);
  block-size: 1px;
  background: var(--c-line-strong);
  flex: none;
}

.annot--good {
  color: var(--c-accent-deep);
}

.annot--good::before {
  background: var(--c-accent);
}

.annot--warn {
  color: var(--c-clay-deep);
}

.annot--warn::before {
  background: var(--c-clay);
}


/* ==== assets/css/components/coupon.css ==== */
/* =============================================================================
   The coupon.

   A voucher pinned in the hero's top-right corner: a body, a perforated line,
   and a tear-off stub carrying the code. It replaced a lanyard, and the reason
   it is better here is that a coupon is a FLAT object — it can be an anchor
   with two children and a mask, where the lanyard needed a physics solver to
   look like anything at all.

   The tear is the whole idea. Everything below exists to make one gesture
   convincing: that this is a printed thing you could take.
   ========================================================================== */

.coupon {
  --notch: 7px;
  --lift: 0px;
  --tilt: -1.6deg;

  /* Last item in .hero__visual's flow, tucked to that column's trailing edge.
     It was absolutely positioned in the column's corner, which cost the column
     8.5rem of padding held open for it and flattened the sphere above. In the
     flow it takes exactly the room it needs and can collide with nothing. */
  align-self: flex-end;
  z-index: calc(var(--z-raised) + 1);

  inline-size: 158px;
  flex: none;
  display: flex;
  flex-direction: column;
  text-decoration: none;
  color: var(--c-ink);

  transform: rotate(var(--tilt)) translateY(var(--lift));
  transform-origin: 50% 0;
  transition:
    transform var(--d-slow) var(--e-out),
    filter var(--d-slow) var(--e-out);
  filter: drop-shadow(0 2px 4px rgb(16 18 16 / 10%))
    drop-shadow(0 12px 26px rgb(16 18 16 / 10%));
}

/* Straightens and lifts under the cursor, the way you would pick one up. */
.coupon:hover,
.coupon:focus-visible {
  --tilt: 0deg;
  --lift: -3px;
  filter: drop-shadow(0 3px 6px rgb(16 18 16 / 12%))
    drop-shadow(0 18px 34px rgb(16 18 16 / 14%));
}

.coupon:focus-visible {
  outline: none;
}

.coupon:focus-visible .coupon__body {
  outline: 2px solid var(--c-focus);
  outline-offset: 2px;
}

/* ------------------------------------------------------------------- body */

/*
 * The notches are half-circles bitten out of the BODY's bottom corners and the
 * STUB's top corners, which together read as two full circles on the seam.
 *
 * Masking the whole card instead needs a percentage saying where the seam is,
 * and that percentage is a guess: it depends on how the stub's type wraps and
 * on the reader's font size. The first version used 62% and put the notches in
 * the middle of the body. Measuring each half against its own edge cannot be
 * wrong, whatever either half ends up being.
 *
 * A mask rather than two painted circles: the hero's ground is a gradient, so
 * a painted circle would be a slightly wrong shade. A mask removes the pixels
 * and whatever is behind shows through.
 */
.coupon__body {
  --notches:
    radial-gradient(circle var(--notch) at 0 100%, #0000 97%, #000 100%),
    radial-gradient(circle var(--notch) at 100% 100%, #0000 97%, #000 100%);
  -webkit-mask-image: var(--notches);
  -webkit-mask-composite: source-in;
  mask-image: var(--notches);
  mask-composite: intersect;

  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding: var(--s-4) var(--s-4) var(--s-5);
  background: var(--c-canvas);
  border: 1px solid var(--c-line-strong);
  border-block-end: 0;
  border-radius: var(--r-md) var(--r-md) 0 0;
}

.coupon__kicker {
  color: var(--c-clay);
}

.coupon__headline {
  font-size: 1rem;
  font-weight: 600;
  line-height: 1.12;
  letter-spacing: -0.025em;
}

.coupon__terms {
  color: var(--c-muted);
  font-size: var(--t-nano);
  line-height: 1.5;
  padding-block-start: var(--s-1);
}

/* ------------------------------------------------------------------- stub */

/* The stub carries the torn edge itself: the dashes are its own top border, so
   when the body rips away they stay behind on the stub, which is what a real
   perforation leaves. */
.coupon__stub {
  --notches:
    radial-gradient(circle var(--notch) at 0 0, #0000 97%, #000 100%),
    radial-gradient(circle var(--notch) at 100% 0, #0000 97%, #000 100%);
  -webkit-mask-image: var(--notches);
  -webkit-mask-composite: source-in;
  mask-image: var(--notches);
  mask-composite: intersect;

  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--s-3) var(--s-4);
  /* The dashes, drawn as a gradient rather than a dashed border: a border's
     dash length is chosen by the browser and cannot be matched to the notches
     either side of it. */
  background:
    repeating-linear-gradient(
      to right,
      color-mix(in srgb, #f5f4ee 55%, var(--c-accent)) 0 4px,
      transparent 4px 9px
    ) top / 100% 1px no-repeat,
    var(--c-accent);
  color: #f5f4ee;
  border: 1px solid var(--c-accent-deep);
  border-block-start: 0;
  border-radius: 0 0 var(--r-md) var(--r-md);
}

.coupon__redeem {
  display: inline-flex;
  align-items: center;
  gap: 0.35em;
  color: color-mix(in srgb, #f5f4ee 82%, var(--c-accent));
}

.coupon:hover .coupon__redeem {
  color: #f5f4ee;
}

.coupon:hover .btn__arrow {
  transform: translateX(3px);
}

/* ------------------------------------------------------------- the tear */

/*
 * Clicking tears the voucher along its perforation: the body lifts and turns
 * away while the stub stays behind, then the page changes underneath it. It is
 * the one animation on the site that is allowed to be a small piece of theatre,
 * because the gesture it imitates — tearing a coupon out — is the whole reason
 * the element is shaped this way.
 */
.coupon[data-tearing='true'] {
  pointer-events: none;
}

.coupon[data-tearing='true'] .coupon__body {
  animation: coupon-tear 460ms var(--e-in-out) forwards;
}

.coupon[data-tearing='true'] .coupon__stub {
  animation: coupon-stay 460ms var(--e-out) forwards;
}

@keyframes coupon-tear {
  35% {
    transform: translateY(-4px) rotate(-1.5deg);
  }
  100% {
    transform: translate(14px, -34px) rotate(7deg);
    opacity: 0;
  }
}

@keyframes coupon-stay {
  40% {
    transform: translateY(2px);
  }
  100% {
    transform: translateY(0);
  }
}

/* ------------------------------------------------------------- responsive */

/* Below the nav's own breakpoint the hero headline claims the full width and
   the corner is no longer free. The voucher is a supplementary invitation, not
   content: every route it offers is also in the nav, the footer and the
   buttons. */
@media (max-width: 1080px) {
  .coupon {
    display: none;
  }
}

/* On a short viewport the hero is already giving up type size; the voucher is
   the first thing that should go rather than crowd what is left. */
@media (max-height: 700px) {
  .coupon {
    display: none;
  }
}

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

  .coupon[data-tearing='true'] .coupon__body,
  .coupon[data-tearing='true'] .coupon__stub {
    animation: none;
  }
}


/* ==== assets/css/components/shopframe.css ==== */
/* =============================================================================
   Shop frame — a storefront drawn from tokens.

   A component rather than a section, because two pages use it: the home page's
   pinned turn assembles one as you scroll, and /storefronts/ shows a finished
   one beside its hero.

   Deliberately, obviously drawn. There is no portfolio to screenshot yet, and a
   mockup dressed up as a screenshot of a client site we do not have would be
   the one dishonest thing on the site. The proportions are a real product grid,
   so it reads as a shop at a glance without claiming to be anyone's.
   ========================================================================== */

.shopframe {
  border: var(--border-strong);
  border-radius: var(--r-md);
  background: var(--c-canvas);
  box-shadow: var(--shadow-lift);
  overflow: hidden;
  /* Belongs to the reading order visually, not semantically. */
  user-select: none;
}

.shopframe__bar {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  background: var(--c-canvas-sunk);
  border-block-end: var(--border);
}

.shopframe__dots {
  display: flex;
  gap: 5px;
  flex: none;
}

.shopframe__dots span {
  inline-size: 8px;
  block-size: 8px;
  border-radius: 50%;
  background: var(--c-line-strong);
}

.shopframe__url {
  flex: 1;
  min-inline-size: 0;
  padding: 0.25em 0.7em;
  border-radius: var(--r-pill);
  background: var(--c-canvas);
  border: var(--border);
  color: var(--c-muted);
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.shopframe__body {
  padding: var(--s-4);
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

.shopframe__banner {
  block-size: clamp(4.5rem, 9vw, 6.5rem);
  border-radius: var(--r-sm);
  background:
    linear-gradient(
      120deg,
      var(--c-accent-soft) 0%,
      var(--c-tint-green) 55%,
      var(--c-canvas-sunk) 100%
    );
  display: flex;
  align-items: center;
  padding-inline: var(--s-4);
}

.shopframe__banner span {
  inline-size: 42%;
  block-size: 8px;
  border-radius: 4px;
  background: var(--c-accent);
  opacity: 0.5;
}

.shopframe__grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s-3);
}

.shopframe__card {
  display: flex;
  flex-direction: column;
  gap: 6px;
}

.shopframe__thumb {
  aspect-ratio: 1;
  border-radius: var(--r-sm);
  background: var(--c-panel);
  border: var(--border);
}

/* Two rules of different length: a product name and a price. */
.shopframe__card i {
  display: block;
  block-size: 6px;
  border-radius: 3px;
  background: var(--c-line-strong);
}

.shopframe__card i:last-child {
  inline-size: 40%;
  background: var(--c-line);
}

/* The one live-looking detail, and the only one that matters to the argument:
   stock, coming from the same record the warehouse works from. */
.shopframe__stock {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  padding-block-start: var(--s-3);
  border-block-start: var(--border);
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
}

.shopframe__stock b {
  color: var(--c-accent);
  font-weight: 500;
}


/* ---------------------------------------------------------------- on dark */

/*
 * On the dark ground every surface in here resolves to the page's own black —
 * --c-canvas IS the dark ground — so the frame vanished and left an outline
 * behind. These lift each surface off the one behind it by a few percent,
 * which is all a dark interface ever needs.
 */
[data-ground='dark'] .shopframe {
  background: var(--c-canvas-sunk);
  border-color: var(--c-line-strong);
}

[data-ground='dark'] .shopframe__bar {
  background: color-mix(in srgb, var(--c-dark-ink) 4%, transparent);
}

[data-ground='dark'] .shopframe__url {
  background: color-mix(in srgb, var(--c-dark-ink) 3%, transparent);
}

[data-ground='dark'] .shopframe__thumb {
  background: color-mix(in srgb, var(--c-dark-ink) 7%, transparent);
  border-color: var(--c-line-strong);
}

[data-ground='dark'] .shopframe__card i {
  background: color-mix(in srgb, var(--c-dark-ink) 22%, transparent);
}

[data-ground='dark'] .shopframe__card i:last-child {
  background: color-mix(in srgb, var(--c-dark-ink) 12%, transparent);
}


/* ==== assets/css/components/mark.css ==== */
/* =============================================================================
   Marks.

   Small drawings that lead a block of text, in place of the icon set.

   An icon well says "this item has a category". A mark says what the item
   actually is — and because every one is built from the same three primitives,
   a row of them reads as a family rather than as a row of stock pictograms,
   which is the thing they replaced.

   The kit is deliberately tiny:

     bar    a filled rectangle          the unit of quantity or of a thing made
     rule   a thin horizontal line      the unit of text, or of a shelf
     dot    a small circle              the unit of a person, or of a point

   Every mark is two to four <span>s inside .mark; the variant decides what
   those spans become. Colour is currentColor, so a mark inherits its family
   hue where one is set (see the catalogue) and the accent everywhere else.

   Sized against .icon-well's 2.75rem box so nothing below them moved when
   they took over.
   ========================================================================== */

.mark {
  /* inline-flex and an explicit max-content width, because a mark leads a
     column of text in three different layouts here — flex, grid and flow — and
     a block-level flex box stretches to the column in two of them. The well
     this replaced carried a fixed 2.75rem and never showed the problem.
     Variants needing a set width in order to wrap (.mark--rack) override. */
  display: inline-flex;
  inline-size: max-content;
  align-items: flex-end;
  align-self: start;
  gap: 3px;
  block-size: 1.375rem;
  color: var(--c-accent);
  flex: none;
}

.mark span {
  background: currentColor;
  border-radius: 2px;
}

/* Columns of text or shelving stack downwards instead. */
.mark--stack {
  flex-direction: column;
  align-items: flex-start;
  justify-content: flex-end;
  gap: 3px;
}

/* -------------------------------------------------------- storefronts (3) */

/* A catalogue: three tiles, the first one taller because it is the one open. */
.mark--shop span {
  inline-size: 11px;
  block-size: 14px;
}

.mark--shop span:first-child {
  block-size: 20px;
}

/* A page: a heading rule over two shorter lines. */
.mark--page span {
  block-size: 3px;
  inline-size: 30px;
}

.mark--page span:first-child {
  block-size: 6px;
  inline-size: 18px;
}

.mark--page span:last-child {
  inline-size: 22px;
  opacity: 0.5;
}

/* Two turns of a conversation, the reply set back and quieter. */
.mark--talk span {
  block-size: 8px;
  inline-size: 22px;
}

.mark--talk span:last-child {
  inline-size: 14px;
  margin-inline-start: 8px;
  opacity: 0.5;
}

/* --------------------------------------------------------- catalogue (4) */

/* Manufacturing: two parts going in, one finished thing coming out. */
.mark--make span {
  inline-size: 7px;
  block-size: 9px;
  opacity: 0.5;
}

.mark--make span:last-child {
  inline-size: 9px;
  block-size: 22px;
  opacity: 1;
  margin-inline-start: 3px;
}

/* Warehouse: pallets on two levels of racking. */
.mark--rack {
  flex-wrap: wrap;
  align-content: flex-end;
  inline-size: 25px;
}

.mark--rack span {
  inline-size: 11px;
  block-size: 9px;
}

.mark--rack span:nth-child(3) {
  opacity: 0.5;
}

/* Office: a document — the admin that piles up between people. */
.mark--paper span {
  block-size: 3px;
  inline-size: 26px;
}

.mark--paper span:nth-child(2) {
  inline-size: 20px;
}

.mark--paper span:last-child {
  inline-size: 12px;
  opacity: 0.5;
}

/* Yours: the one that is not drawn yet, because it is only in someone's head.
   An outline rather than a fill — the only mark in the set that is empty. */
.mark--yours span {
  inline-size: 20px;
  block-size: 20px;
  background: transparent;
  border: 1px dashed currentColor;
  border-radius: var(--r-xs);
}

/* --------------------------------------------------------- approach (3) */

/* Understand: the process as found — three steps at three different heights,
   because nobody planned it that way. */
.mark--observe span {
  inline-size: 5px;
  block-size: 20px;
}

.mark--observe span:nth-child(2) {
  block-size: 9px;
}

.mark--observe span:last-child {
  block-size: 15px;
}

/* Simplify: the same three, with the one that stopped earning its place gone. */
.mark--reduce span {
  inline-size: 5px;
  block-size: 20px;
}

.mark--reduce span:nth-child(2) {
  block-size: 20px;
  opacity: 0.25;
}

.mark--reduce span:last-child {
  block-size: 20px;
}

/* Connect: two points and the line that now runs between them. */
.mark--join {
  align-items: center;
  gap: 0;
}

.mark--join span:first-child,
.mark--join span:last-child {
  inline-size: 8px;
  block-size: 8px;
  border-radius: 50%;
}

.mark--join span:nth-child(2) {
  inline-size: 12px;
  block-size: 2px;
  border-radius: 0;
}

/* --------------------------------------------------- implementation (5) */

/* Map: the path the work actually takes, which is not a straight one. */
.mark--map span {
  inline-size: 4px;
  border-radius: 1px;
}

.mark--map span:nth-child(1) { block-size: 8px; }
.mark--map span:nth-child(2) { block-size: 20px; }
.mark--map span:nth-child(3) { block-size: 12px; }
.mark--map span:nth-child(4) { block-size: 16px; }

/* Remove: a step lifted out of the middle of the line. */
.mark--cut span {
  inline-size: 6px;
  block-size: 16px;
}

.mark--cut span:nth-child(2) {
  block-size: 2px;
  align-self: center;
  opacity: 0.4;
}

/* Build: something assembled, one course on top of another. */
.mark--build {
  flex-direction: column;
  align-items: flex-start;
  justify-content: flex-end;
  gap: 2px;
}

.mark--build span {
  block-size: 6px;
  inline-size: 22px;
}

.mark--build span:first-child {
  inline-size: 14px;
}

/* Train: two people, one alongside the other rather than one above. */
.mark--people span {
  inline-size: 9px;
  block-size: 9px;
  border-radius: 50%;
}

.mark--people span:last-child {
  inline-size: 13px;
  block-size: 13px;
}

/* Improve: the second version, taller than the first. */
.mark--lift span {
  inline-size: 7px;
}

.mark--lift span:first-child {
  block-size: 10px;
  opacity: 0.45;
}

.mark--lift span:last-child {
  block-size: 20px;
}


/* ==== assets/css/sections/chrome.css ==== */
/* =============================================================================
   Page chrome — information strip, navigation, footer.
   ========================================================================== */

/* ------------------------------------------------------------------ strip */

.strip {
  position: sticky;
  inset-block-start: 0;
  z-index: var(--z-strip);
  background: var(--c-accent-soft);
  color: var(--c-accent-deep);
  border-block-end: 1px solid rgb(46 107 79 / 18%);
  /*
   * Marks that the page has moved WITHOUT changing its own height.
   *
   * This used to compact by shrinking its padding from 0.7rem to 0.4rem. The
   * comment here claimed that was "not enough to shift the layout underneath
   * it", but a sticky element occupies flow space, so losing 10px of height
   * moved the entire document up by 10px the moment the reader scrolled. Every
   * anchor landing and every sticky offset on the page inherited that shift.
   *
   * The cue is now carried by colour and a shadow, which cost no layout at all.
   */
  transition:
    background var(--d-slow) var(--e-out),
    box-shadow var(--d-slow) var(--e-out);
  padding-block: 0.7rem;
}

.strip[data-compact='true'] {
  background: color-mix(in srgb, var(--c-accent-soft) 82%, var(--c-accent));
  box-shadow: 0 1px 0 rgb(46 107 79 / 22%);
}

.strip__inner {
  inline-size: min(100% - (var(--grid-gutter) * 2), var(--grid-max));
  margin-inline: auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-5);
}

.strip__message {
  font-size: var(--t-small);
  font-weight: 450;
  letter-spacing: -0.008em;
}

.strip__action {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  font-family: var(--font-mono);
  /* A step below the eyebrows elsewhere: uppercase mono at 0.14em is wide for
     its size, and at --t-micro this ran 172px -- reading louder than the
     sentence it sits beside, which is the thing the strip is actually for. */
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  text-decoration: none;
  white-space: nowrap;
  border-block-end: 1px solid currentcolor;
  padding-block-end: 2px;
  transition: opacity var(--d-base) var(--e-in-out);
}

.strip__action:hover {
  opacity: 0.7;
}

/*
 * Touch hit area, without touching the box.
 *
 * Growing the link itself to 44px would carry its underline down with it, and
 * at this size that left the rule floating a clear 30px under the words. An
 * overlay centred on the text gives a finger the same 44px to land on while the
 * link keeps the height of what it draws.
 */
@media (pointer: coarse) {
  .strip__action {
    position: relative;
  }

  .strip__action::after {
    content: '';
    position: absolute;
    inset-inline: 0;
    inset-block-start: 50%;
    block-size: 44px;
    translate: 0 -50%;
  }
}

.strip__action:hover .btn__arrow {
  transform: translateY(3px);
}

@media (max-width: 760px) {
  .strip__message {
    font-size: var(--t-small);
    line-height: 1.35;
  }

  /* On a narrow screen the arrow link alone carries the meaning; the words
     would wrap the strip to three lines. */
  .strip__action span:not(.btn__arrow) {
    display: none;
  }
}

/* -------------------------------------------------------------------- nav */

.nav {
  position: sticky;
  /* Sits directly beneath the strip. The strip's own height varies, so this is
     driven by a custom property that nav.js keeps in sync. */
  inset-block-start: var(--strip-height, 44px);
  z-index: var(--z-nav);
  padding-block: var(--s-4);
  background: color-mix(in srgb, var(--c-canvas) 88%, transparent);
  backdrop-filter: blur(10px);
  border-block-end: 1px solid transparent;
  transition:
    transform var(--d-slow) var(--e-out),
    border-color var(--d-base) var(--e-in-out),
    background var(--d-base) var(--e-in-out);
}

/* Once scrolled, the bar earns a hairline so it separates from content. */
.nav[data-scrolled='true'] {
  border-block-end-color: var(--c-line);
}

/* Recedes during the two immersive sections, and whenever the reader is moving
   down the page. It returns on any upward scroll — never trapped off-screen. */
.nav[data-hidden='true'] {
  transform: translateY(calc(-100% - var(--strip-height, 44px) - 8px));
}

.nav__inner {
  inline-size: min(100% - (var(--grid-gutter) * 2), var(--grid-max));
  margin-inline: auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-5);
}

/* The links sit in a capsule so the bar reads as one small control rather than
   a full-width menu.

   Extra inline padding is the dock's headroom: magnified labels push their
   neighbours outward, and without it the end labels ride over the capsule's
   own curve. The vertical padding is deliberately NOT increased — labels are
   meant to rise slightly out of the bar, the way dock icons do. */
.nav__links {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  padding: var(--s-2) var(--s-6);
  border: var(--border);
  border-radius: var(--r-pill);
  background: var(--c-canvas);
}

/*
 * --near is the cursor's influence on this label, 0 to 1, written by dock.js.
 * The transform is written there too — geometry is its job. Everything the
 * influence LOOKS like is decided here, so the effect can be retuned without
 * touching the maths.
 */
.nav__link {
  --near: 0;
  padding: 0.45em 0.85em;
  border-radius: var(--r-pill);
  font-size: var(--t-small);
  text-decoration: none;
  white-space: nowrap;
  /* Scaling about the label's own centre is what lets the displacement maths
     assume symmetric growth. */
  transform-origin: 50% 50%;
  color: color-mix(in srgb, var(--c-ink) calc(58% + var(--near) * 42%), transparent);
  transition: color var(--d-fast) linear;
}

.nav__links[data-dock='on'] .nav__link {
  will-change: transform;
}

/* With the dock running, magnification IS the hover feedback — a filled
   rectangle underneath it as well would be two answers to the same question,
   and the rectangle scales with the label, which looks like a bug. */
.nav__links:not([data-dock='on']) .nav__link:hover {
  background: var(--c-panel);
  color: var(--c-ink);
}

/* Set by nav.js from a scroll-spy observer: which section is being read. It
   stays a filled pill, because that is state rather than feedback — it has to
   be legible when the cursor is nowhere near the bar. */
.nav__link[aria-current='true'] {
  background: var(--c-accent-soft);
  color: var(--c-accent-deep);
}

/* The page you are actually on, as opposed to the section you are scrolled to.
   An outline rather than a fill, so it cannot be mistaken for the scroll-spy
   pill when both are visible. */
.nav__link[aria-current='page'] {
  color: var(--c-accent-deep);
  box-shadow: inset 0 0 0 1px var(--c-accent);
}

@media (prefers-reduced-motion: reduce) {
  .nav__link {
    transform: none !important;
    will-change: auto;
  }
}

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

.nav__toggle {
  display: none;
  position: relative;
  inline-size: 44px;
  block-size: 44px;
  border: var(--border);
  border-radius: var(--r-pill);
}

/* Three bars drawn from one element plus its pseudo-elements; they fold into an
   X when the drawer opens. */
.nav__toggle-bars,
.nav__toggle-bars::before,
.nav__toggle-bars::after {
  position: absolute;
  inline-size: 16px;
  block-size: 1.5px;
  background: currentcolor;
  transition:
    transform var(--d-base) var(--e-in-out),
    opacity var(--d-fast) linear;
}

.nav__toggle-bars {
  inset-block-start: 50%;
  inset-inline-start: 50%;
  translate: -50% -50%;
}

.nav__toggle-bars::before,
.nav__toggle-bars::after {
  content: '';
  inset-inline-start: 0;
}

.nav__toggle-bars::before {
  inset-block-start: -5px;
}

.nav__toggle-bars::after {
  inset-block-start: 5px;
}

.nav__toggle[aria-expanded='true'] .nav__toggle-bars {
  background: transparent;
}

.nav__toggle[aria-expanded='true'] .nav__toggle-bars::before {
  transform: translateY(5px) rotate(45deg);
}

.nav__toggle[aria-expanded='true'] .nav__toggle-bars::after {
  transform: translateY(-5px) rotate(-45deg);
}

.nav__drawer {
  display: none;
  flex-direction: column;
  gap: var(--s-5);
  padding: var(--s-6) var(--grid-gutter) var(--s-7);
  border-block-start: var(--border);
  margin-block-start: var(--s-4);
  background: var(--c-canvas);
}

.nav__drawer[data-open='true'] {
  display: flex;
}

.nav__drawer-links {
  display: flex;
  flex-direction: column;
}

.nav__drawer-link {
  padding-block: var(--s-4);
  border-block-end: var(--border);
  font-size: 1.25rem;
  font-weight: 500;
  letter-spacing: -0.02em;
  text-decoration: none;
}

@media (max-width: 1080px) {
  /* Everything the drawer takes over stands down together, the switcher
     included — its .lang-row is waiting inside. */
  .nav__links,
  .nav__cta,
  .lang {
    display: none;
  }

  .nav__toggle {
    display: block;
  }
}

/* ------------------------------------------------------ language switcher */

/*
 * A <details>, so the menu opens, closes and is keyboard-operable with no
 * JavaScript at all — the same reason the FAQ is markup now. It sits in the
 * bar as a small capsule beside the CTA, and shares the CTA's vertical
 * padding rather than a fixed height, so the two align however the type
 * scale is retuned.
 */
.lang {
  position: relative;
}

.lang__summary {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  padding: 0.8em var(--s-4);
  border: var(--border);
  border-radius: var(--r-pill);
  font-size: var(--t-small);
  font-weight: 500;
  line-height: 1;
  color: color-mix(in srgb, var(--c-ink) 58%, transparent);
  cursor: pointer;
  /* The default disclosure triangle, removed in both dialects: the caret in
     the markup replaces it, because that one can rotate. */
  list-style: none;
  transition:
    color var(--d-fast) linear,
    background var(--d-fast) linear,
    border-color var(--d-fast) linear;
}

.lang__summary::-webkit-details-marker {
  display: none;
}

/* Open is a state, hover is feedback, and here they look the same on purpose:
   both mean "this control is live", and the caret says which one you are in. */
.lang__summary:hover,
.lang[open] .lang__summary {
  background: var(--c-panel);
  border-color: var(--c-line-strong);
  color: var(--c-ink);
}

/* Two letters, so they are set apart rather than read as a word. */
.lang__code {
  letter-spacing: 0.06em;
}

.lang__caret {
  inline-size: 10px;
  block-size: 6px;
  transition: transform var(--d-base) var(--e-in-out);
}

.lang[open] .lang__caret {
  transform: rotate(180deg);
}

/* Hangs from the end of the capsule rather than its start: the switcher is the
   last thing before the page edge, and a menu growing rightward from a control
   that is already there leaves the viewport. */
.lang__menu {
  position: absolute;
  inset-block-start: calc(100% + var(--s-2));
  inset-inline-end: 0;
  z-index: 1;
  min-inline-size: 12ch;
  padding: var(--s-2);
  border: var(--border);
  border-radius: var(--r-md);
  background: var(--c-canvas);
  box-shadow: var(--shadow-lift);
  list-style: none;
  animation: lang-menu-in var(--d-base) var(--e-out) both;
}

.lang__link {
  display: block;
  padding: var(--s-2) var(--s-3);
  border-radius: var(--r-sm);
  font-size: var(--t-small);
  white-space: nowrap;
  text-decoration: none;
  color: var(--c-ink-soft);
  transition:
    color var(--d-fast) linear,
    background var(--d-fast) linear;
}

.lang__link:hover {
  background: var(--c-panel);
  color: var(--c-ink);
}

/* The language you are already reading. Filled rather than outlined, because
   unlike the nav there is no second kind of "current" here to confuse it with. */
.lang__link[aria-current='true'] {
  background: var(--c-accent-soft);
  color: var(--c-accent-deep);
}

@keyframes lang-menu-in {
  from {
    opacity: 0;
    transform: translateY(-4px);
  }
}

/* ------------------------------------------- the same choice in the drawer */

/* The drawer has room to show the languages outright, and a reader who has
   already opened one disclosure should not have to open a second one inside
   it. Same links, laid flat. */
.lang-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
}

.lang-row__link {
  padding: var(--s-2) var(--s-4);
  border: var(--border);
  border-radius: var(--r-pill);
  font-size: var(--t-small);
  text-decoration: none;
  color: var(--c-ink-soft);
}

.lang-row__link[aria-current='true'] {
  background: var(--c-accent-soft);
  border-color: transparent;
  color: var(--c-accent-deep);
}

@media (prefers-reduced-motion: reduce) {
  .lang__menu {
    animation: none;
  }

  .lang__caret {
    transition: none;
  }
}

/* ----------------------------------------------------------------- footer */

.footer {
  background: var(--c-canvas);
  color: var(--c-ink);
  padding-block: var(--s-8) var(--s-6);
}

.footer__inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-6);
  padding-block-end: var(--s-7);
}

/* The footer wordmark sits alone without the nav's mark or the "logo tbc"
   note, so it needs its own slightly larger setting. */
.footer__logo .logo-slot__word {
  font-size: 1.4rem;
}

.footer__links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-3) var(--s-6);
}

.footer__link {
  font-size: var(--t-small);
  color: var(--c-muted);
  text-decoration: none;
  transition: color var(--d-base) var(--e-in-out);
}

.footer__link:hover {
  color: var(--c-ink);
}

/* Set in the mono, like every other piece of hard fact on this site — a code,
   a date, an address. It is a fact, not a call to action, so it does not get a
   button's weight. */
.footer__mail {
  /* .mono uppercases everything it touches, which is right for a code and
     wrong for an address: an email is written the way it is written, and
     INFO@TOOLSSYNC.COM reads as shouting rather than as a fact. */
  text-transform: none;
  font-size: var(--t-small);
  color: var(--c-muted);
  text-decoration-color: var(--c-line-strong);
  transition: color var(--d-base) var(--e-in-out);
}

.footer__mail:hover {
  color: var(--c-accent);
  text-decoration-color: currentcolor;
}

.footer__social {
  display: flex;
  gap: var(--s-3);
}

.footer__social-link {
  display: grid;
  place-items: center;
  inline-size: 36px;
  block-size: 36px;
  border: 1px solid var(--c-line);
  border-radius: 50%;
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  text-decoration: none;
  color: var(--c-muted);
  transition:
    border-color var(--d-base) var(--e-in-out),
    color var(--d-base) var(--e-in-out);
}

.footer__social-link:hover {
  border-color: var(--c-accent);
  color: var(--c-accent);
}

.footer__legal {
  border-block-start-color: var(--c-line);
}

/* The legal row sits between the copyright and the descriptor rather than
   among the section links: nobody browses to a privacy policy, they go looking
   for it, and it is found faster next to the copyright than buried in a list
   of marketing destinations. */
.footer__legal-links {
  display: flex;
  gap: var(--s-4);
}

.footer__link--legal {
  font-size: inherit;
  letter-spacing: inherit;
  text-transform: inherit;
}

/* Social slots with no account behind them yet. They are spans, not links, so
   they must not invite a click they cannot honour. */
.footer__social-link[data-placeholder] {
  opacity: 0.4;
  cursor: default;
}

@media (max-width: 860px) {
  /* The descriptor is the first thing to go: the copyright and the privacy
     link both have to stay reachable, a tagline does not. */
  .footer__legal-tag {
    display: none;
  }
}

@media (max-width: 700px) {
  .footer__inner {
    flex-direction: column;
    align-items: flex-start;
  }
}


/* =============================================================================
   Small screens: the chrome stops eating the page.

   The strip and the navigation are both sticky, which is right on a desktop
   where they total 140px of a 900px window. On a phone the strip's one line
   becomes five and the pair takes 208px of an 844px viewport — a quarter of
   every screen, permanently, for a strapline and a menu button. At 360px it is
   seven lines and 34%.

   So below the width where the strip still fits on one line, it stops being
   sticky. It is an opening remark, not a control: read once at the top, then
   out of the way. The navigation keeps its place, because that IS a control.
   ========================================================================== */

@media (max-width: 900px) {
  .strip {
    position: static;
  }

  /* With nothing pinned above it, the bar sits against the top of the
     viewport rather than below a strip that is no longer there. */
  .nav {
    inset-block-start: 0;
  }

  /* The hide-on-scroll transform moved the bar by its own height plus the
     strip's; the strip is no longer part of that stack. */
  .nav[data-hidden='true'] {
    transform: translateY(calc(-100% - 8px));
  }

  /*
   * Everything that pins itself below the chrome has to agree with the chrome
   * about how tall it is. --strip-height is written to :root by nav.js as an
   * inline style, so a stylesheet cannot override it — but --pin-top is ours,
   * and redefining it here re-points every sticky offset on the site at once.
   */
  :root {
    --pin-top: 6rem;
  }
}

/*
 * The strip stacks before it gets desperate.
 *
 * Side by side, the message and the link share one row — so as the screen
 * narrows, the message keeps its half and wraps instead. At 320px that meant
 * nine lines filling 77% of the first screen, a wall of green before the
 * reader had seen the company name.
 *
 * Stacked, the sentence gets the full width and settles into three lines. The
 * link goes underneath, where it is also a bigger target than it was squeezed
 * into a corner.
 */
@media (max-width: 480px) {
  .strip__inner {
    flex-direction: column;
    align-items: flex-start;
    gap: var(--s-2);
  }
}


/* ==== assets/css/sections/hero.css ==== */
/* =============================================================================
   Hero.

   The sentence is the artwork. The convergence field sits in its own column
   beside the supporting copy rather than floating across the whole hero — an
   overlay put "One connected workflow" straight on top of the headline.
   ========================================================================== */

.hero {
  position: relative;
  /*
   * Exactly one screen, chrome included.
   *
   * The hero was 781px tall and started 140px down, so its last 21px sat below
   * the fold: the sheet line was cut in half and the first screen looked like
   * it had been cropped rather than composed. Subtracting the strip and the
   * navigation from the viewport makes the section end where the screen does.
   *
   * svh, not vh: on a phone vh is the height WITHOUT the browser's own chrome,
   * so a vh-sized hero is always taller than what you can actually see.
   * --strip-rest-height is the strip's uncompacted height, which is what is on
   * screen when the hero is.
   */
  min-block-size: calc(100svh - var(--strip-rest-height, 49px) - 5.7rem);
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: clamp(var(--s-5), 2.4vw, var(--s-7));
  padding-block: clamp(1.25rem, 2vw, 1.75rem);
  overflow: clip;
  /* A soft wash behind the headline so the first screen is not flat canvas.
     Two very low-opacity radial pools, warm on the left where the "scattered"
     idea lives and green on the right where it resolves. */
  background:
    radial-gradient(
      70ch 50ch at 12% 18%,
      color-mix(in srgb, var(--c-clay-soft) 60%, transparent),
      transparent 70%
    ),
    radial-gradient(
      60ch 45ch at 88% 78%,
      color-mix(in srgb, var(--c-accent-soft) 55%, transparent),
      transparent 70%
    ),
    var(--c-canvas);
}

/*
 * Two columns, both starting on the same line.
 *
 * `align-items: start` is the whole fix for the scatter. This was a flex column
 * holding a full-width headline over a row whose align-items was `center`: the
 * copy was centred against a sphere half again its height, which left 147px of
 * empty between the headline and the lead and made the two read as unrelated
 * blocks. Now the words are one column and the sphere is the other, and the top
 * of the sphere lines up with the top of the eyebrow.
 */
.hero__inner {
  position: relative;
  z-index: var(--z-raised);
  display: grid;
  grid-template-columns: minmax(0, 1.08fr) minmax(0, 0.92fr);
  align-items: start;
  gap: clamp(var(--s-6), 4vw, var(--s-8));
}

.hero__eyebrow {
  color: var(--c-muted);
}

.hero__title {
  display: flex;
  flex-direction: column;
  letter-spacing: -0.04em;
}

.hero__line {
  display: block;
}

/* The count ticks up on load — the sentence's own small joke, and the first
   sign that numbers on this page move rather than sit. */
.hero__count {
  display: inline-block;
  min-inline-size: 1.6ch;
  color: var(--c-clay);
  font-variant-numeric: tabular-nums;
}

/* --------------------------------------------------------------- the foot */



.hero__words {
  display: flex;
  flex-direction: column;
  /* Eyebrow, headline, lead and buttons. The lead and the buttons sit closer to
     the headline than to each other's neighbours, so the four read as one
     paragraph of page rather than as four stacked things. */
  gap: clamp(var(--s-4), 1.6vw, var(--s-5));
}

.hero__words .hero__lead {
  margin-block-start: clamp(var(--s-3), 1vw, var(--s-4));
}

.hero__words .hero__actions {
  margin-block-start: clamp(var(--s-3), 1.2vw, var(--s-5));
}

.hero__lead {
  max-inline-size: 42ch;
}

.hero__actions {
  gap: var(--s-3);
}

/* The pair needs 605px of a 595px column at 1440. Ten pixels of padding is a
   cheaper fix than wrapping the primary action onto its own line. */
.hero__actions .btn {
  padding-inline: var(--s-5);
}

.hero__sheet {
  position: relative;
  z-index: var(--z-raised);
  color: var(--c-muted);
}

/* ------------------------------------------------------------------ field */

/*
 * One centred stack: the sphere, its caption, then the voucher. The wrapper
 * owns --progress, because the caption sits outside the field and still has to
 * fade on the same clock as the icons inside it.
 *
 * This column used to hold 8.5rem of padding open at the bottom for a coupon
 * absolutely positioned in its corner. That padding was what squeezed the field
 * into a 243px letterbox — and since the sphere's radius is bounded by the
 * SHORT side of the field, a letterbox field is a flattened sphere. In the flow
 * the voucher needs no reserved space, so the sphere gets it back.
 */
.hero__visual {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-4);
  --progress: 0;
}

/*
 * An explicit height sized against the viewport's HEIGHT, not its width.
 *
 * Not an aspect ratio: with `aspect-ratio` and a max-block-size the capped
 * height fed back into the width and the field collapsed to a square inside a
 * much wider column. And not a width-based height either — that is why the
 * hero once did not fit: on a 1440x800 laptop the field still asked for the
 * height it takes on a 1440x1200 monitor, and the sheet line underneath it fell
 * off the screen. Tying it to vh gives the space back on exactly the screens
 * that are short of it.
 */
.hero__field {
  position: relative;
  inline-size: 100%;
  block-size: clamp(10rem, 36vh, 24rem);

  /* How much of the field the sphere's two axes are allowed to use. Read by
     icon-cloud.js — see the note there for why they live out here. */
  --cloud-radius-x: 0.40;
  --cloud-radius-y: 0.43;
}

/* =============================================================================
   The icon cloud.

   Sixteen logos on a slowly rotating sphere — the tools a business already
   runs on, all in the air at once. It replaced the scattered-chips
   convergence, and inherited that composition's job: as the reader scrolls it
   contracts toward the middle and hands over to "One connected workflow"
   below.

   Geometry is written by icon-cloud.js as inline transforms, so there is
   deliberately no positional CSS here — only the things the stylesheet should
   own: size, cursor, and how the whole field behaves when motion is reduced.
   ========================================================================== */

.cloud {
  position: absolute;
  inset: 0;
  /* Grab-to-spin. `touch-action: none` is what stops a horizontal drag on a
     phone being stolen by the page scroller mid-gesture. */
  cursor: grab;
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}

.cloud[data-grabbing='true'] {
  cursor: grabbing;
}

.cloud__icon {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  /* Width and height are written per icon by icon-cloud.js, which sizes each
     logo to the same optical AREA rather than the same box — see the note
     there. The custom property is only the fallback for the first frame. */
  inline-size: var(--cloud-icon-size, 40px);
  block-size: var(--cloud-icon-size, 40px);
  object-fit: contain;
  /* The transform is rewritten every frame from JS; promoting the layer once
     keeps the browser from re-uploading each texture as it moves. */
  will-change: transform, opacity;
  pointer-events: none;
  /* Dragging an <img> natively picks it up as a file, which would abort the
     spin the moment the pointer moved. */
  -webkit-user-drag: none;
}

/* The caption under the sphere.

   It fades on the same clock as the icons — by the time the core pill lands,
   the line that set the problem up has stepped out of the way of the line that
   answers it. */
/*
 * Centred under the sphere, on the same axis.
 *
 * It was left-aligned against the column's edge while the sphere sat centred
 * 280px away, and capped at `100% - 11rem` purely to dodge the coupon in the
 * corner. With the coupon in the flow there is nothing to dodge, so the line
 * can sit where it belongs: directly beneath the object it captions.
 */
.hero__caption {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.35em;
  margin: 0;
  max-inline-size: 30rem;
  text-align: center;
  font-size: var(--t-small);
  color: var(--c-muted);
  opacity: calc(1 - clamp(0, var(--progress) * 1.6, 1));
  pointer-events: none;
}

.hero__caption-line {
  text-wrap: balance;
}

/* Present but quiet, and it brightens when the sphere is actually grabbable:
   a permanent instruction would read as a manual, this reads as a nudge. */
.hero__hint {
  color: var(--c-muted);
  opacity: 0.5;
  transition: opacity var(--d-base) var(--e-out);
}

.hero__visual:hover .hero__hint {
  opacity: 1;
}

/* Nothing to drag without a pointer, so the hint would be a lie. */
@media (hover: none) {
  .hero__hint {
    display: none;
  }
}

/* The thing everything collapses into. Appears only in the last third of the
   sequence, so the convergence has somewhere to arrive. */
.hero__core {
  /* One arrival curve, named once and reused by the ring below, so the two
     cannot be tuned apart by accident. */
  --in: clamp(0, calc((var(--progress) - 0.58) * 3.2), 1);
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  padding: 0.7em 1.2em;
  border: 1px solid var(--c-accent);
  border-radius: var(--r-pill);
  background: var(--c-accent-soft);
  color: var(--c-accent-deep);
  white-space: nowrap;
  box-shadow: 0 2px 18px color-mix(in srgb, var(--c-accent) 18%, transparent);
  opacity: var(--in);
  transform: translate(-50%, -50%) scale(calc(0.88 + var(--in) * 0.12));
}

/* A single ring pulses out of the core as it lands. */
.hero__core::before {
  content: '';
  position: absolute;
  inset: -1px;
  border-radius: inherit;
  border: 1px solid var(--c-accent);
  opacity: calc(clamp(0, (var(--progress) - 0.75) * 4, 1) * 0.5);
  transform: scale(calc(1 + clamp(0, (var(--progress) - 0.75) * 4, 1) * 0.28));
}

@media (max-width: 900px) {
  .hero__inner {
    grid-template-columns: minmax(0, 1fr);
  }

  /*
   * One column, so the field is now as wide as the whole shell while still
   * capped at 20rem tall — a far wider letterbox than the two-column field it
   * replaces. The sphere is bounded by the SHORT axis, so at the desktop
   * factors its silhouette overshot the column on both sides. Narrower axes
   * hold it inside, and it still fills the field: a wider box, same object.
   */
  .hero__field {
    max-block-size: 20rem;
    --cloud-radius-x: 0.34;
    --cloud-radius-y: 0.30;
  }
}

@media (prefers-reduced-motion: reduce) {
  .cloud {
    cursor: default;
  }

  .cloud__icon {
    will-change: auto;
  }

  /* Painted once and left alone, so the sphere still reads as a composition
     rather than disappearing for anyone who has asked motion to stop. The core
     label is shown outright, because there is no convergence to reveal it. */
  .hero__core {
    --in: 1;
  }

  /* The core label is shown outright, so the line that sets up the problem
     would be sitting underneath the line that answers it. */
  .hero__caption {
    opacity: 0;
  }
}


/* =============================================================================
   Short viewports.

   The hero is one screen by construction, so on a laptop that is 800px or less
   the type and the spacing have to give rather than the composition spilling
   past the fold. Everything still reads; it reads smaller.
   ========================================================================== */

@media (max-height: 860px) {
  .hero {
    gap: var(--s-4);
    padding-block: var(--s-4);
  }

  /* The sphere gives back a little of its height on a short laptop. At 36vh a
     1366x768 screen pushed the sheet line 14px below the fold, and the hero is
     one screen by construction. */
  .hero__field {
    block-size: clamp(10rem, 32vh, 24rem);
  }

  .hero__title {
    font-size: clamp(1.9rem, 3.4vw, 3.1rem);
  }

  .hero__inner {
    gap: var(--s-5) clamp(var(--s-6), 5vw, var(--s-9));
  }
}

@media (max-height: 720px) {
  .hero__lead {
    font-size: var(--t-small);
  }

  /* The caption is the first thing to go: the sphere still argues on its own,
     and the drag hint is a nicety rather than a route to anything. */
  .hero__caption {
    display: none;
  }
}


/* ==== assets/css/sections/friction.css ==== */
/* =============================================================================
   "This is how work usually starts" — the scattered mess.

   Nodes are placed on a deterministic scatter rather than a timeline, and the
   connecting lines accumulate as the section is read, so the composition gets
   visibly busier without anything moving fast.
   ========================================================================== */

.friction__field {
  position: relative;
  /* Aspect-ratio rather than a fixed height so the scatter keeps its
     proportions across viewports and never causes layout shift. */
  aspect-ratio: 1000 / 620;
  min-block-size: 340px;
  margin-block: var(--s-6);
  --progress: 0;
}

.friction__wires {
  position: absolute;
  inset: 0;
  inline-size: 100%;
  block-size: 100%;
  overflow: visible;
}

.friction__wire {
  fill: none;
  stroke: var(--c-clay);
  opacity: 0.5;
  stroke-width: 1;
  stroke-dasharray: var(--len, 300);
  /* Each line has its own delay window inside the section's progress, so they
     arrive in sequence instead of all at once. */
  stroke-dashoffset: calc(
    var(--len, 300) * (1 - clamp(0, (var(--progress) - var(--at, 0)) * 4, 1))
  );
  opacity: 0.75;
}

/* The last few connections are the ones that make it feel out of hand. */
.friction__wire--late {
  stroke: var(--c-stop);
  stroke-dasharray: 3 4;
  opacity: calc(clamp(0, (var(--progress) - var(--at, 0)) * 4, 1) * 0.7);
}

.friction__nodes {
  position: absolute;
  inset: 0;
}

.friction__node {
  position: absolute;
  translate: -50% -50%;
  inline-size: max-content;
  max-inline-size: 15rem;
  opacity: 0;
  transform: translateY(10px) scale(0.96);
  transition:
    opacity var(--d-slow) var(--e-out),
    transform var(--d-slow) var(--e-out);
}

.friction__node[data-visible='true'] {
  opacity: 1;
  transform: none;
}

/* Each node borrows the operational-object language at miniature scale: a mono
   source label above a short line of the actual message. */
.friction__card {
  display: flex;
  flex-direction: column;
  gap: 0.3em;
  padding: 0.55em 0.7em;
  background: var(--c-canvas);
  border: 1px solid var(--c-line);
  border-radius: var(--r-sm);
  box-shadow: var(--shadow-card);
}

/* The source label is the loudest part of each node: it names who is holding
   the information at that moment, which is the whole point of the diagram. */
/* The icon leads the source label, so a node's artifact is readable at a
   glance before the text is. */
.friction__source {
  display: flex;
  align-items: center;
  gap: 0.4em;
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--c-clay-deep);
}

.friction__source .icon {
  inline-size: 1.15em;
  block-size: 1.15em;
  vertical-align: 0;
  opacity: 0.85;
}

.friction__text {
  font-size: var(--t-small);
  line-height: 1.35;
  letter-spacing: -0.005em;
}

/* Shape cues so the eight nodes are distinguishable at a glance without icons
   that would need explaining. */
.friction__card[data-kind='message'] {
  border-end-start-radius: 2px;
}

.friction__card[data-kind='sheet'] {
  background-image: linear-gradient(var(--c-line) 1px, transparent 1px);
  background-size: 100% 1.35em;
  background-position: 0 1.9em;
}

.friction__card[data-kind='paper'] {
  border-style: dashed;
}

.friction__card[data-kind='barcode'] {
  border-inline-start: 3px double var(--c-ink);
}

.friction__card[data-kind='approval'] {
  border-color: var(--c-clay);
}

.friction__card[data-kind='invoice'] {
  border-block-end-width: 3px;
}

/* --------------------------------------------------------------- the turn */

.friction__turn {
  display: grid;
  gap: var(--s-7);
  margin-block-start: var(--s-8);
}

.friction__statement {
  max-inline-size: 18ch;
}

.friction__resolve {
  display: grid;
  grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr);
  gap: var(--s-7) var(--grid-gap);
  align-items: start;
  padding-block-start: var(--s-7);
  border-block-start: var(--border-strong);
}

@media (max-width: 900px) {
  .friction__resolve {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* On small screens the scatter becomes an offset vertical sequence: still not a
   plain list, but readable in one column. */
@media (max-width: 760px) {
  .friction__field {
    aspect-ratio: auto;
    min-block-size: 0;
    display: flex;
    flex-direction: column;
    gap: var(--s-3);
  }

  .friction__wires {
    display: none;
  }

  .friction__nodes {
    position: static;
    display: flex;
    flex-direction: column;
    gap: var(--s-3);
  }

  .friction__node {
    /* Alternating indent keeps the sense of a process wandering across the
       business rather than marching down a list.
     *
     * Capped as a percentage as well as a length, because on the narrowest
     * phones a fixed 1.5rem step eats a third of the column. And the cap is
     * SUBTRACTED from the width: a plain `max-inline-size: 100%` measures the
     * containing block, not the space left after the margin, so the deepest
     * card overhung the screen edge by exactly its own indent. */
    --indent: min(calc(var(--stagger-indent, 0) * 1.5rem), 12%);

    position: static;
    translate: none;
    margin-inline-start: var(--indent);
    max-inline-size: calc(100% - var(--indent));
  }
}


/* ==== assets/css/sections/rail.css ==== */
/* =============================================================================
   The rail — horizontal storytelling.

   One job travelling through the whole business, laid out left to right while
   the page scrolls down. The reader is pushing the job along the line: the
   scroll IS the conveyor.

   IMPORTANT — the cascade is deliberately inverted here.

   The DEFAULT styles below are the vertical layout: stations stacked down a
   datum line, no pin, no horizontal movement, everything readable. The pinned
   horizontal treatment is layered on top only when rail.js sets
   `data-pinned="true"`, which it does only on a wide viewport with motion
   allowed.

   Written this way round, every failure mode lands somewhere good: no JS, a
   thrown error, a narrow screen, or reduced motion all leave the reader with
   the plain vertical sequence instead of a pinned region they cannot scroll
   past. Nothing inside is focusable, so the pin can never trap a keyboard.
   ========================================================================== */

.rail {
  position: relative;
  background: var(--c-canvas);
  color: var(--c-ink);
  padding-block: var(--section-y);
  --progress: 0;
}

.rail__pin {
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
}

/* ------------------------------------------------------------------- head */

/* An explicit two-track grid rather than flex space-between: the title column
   is allowed to shrink (minmax 0) so a long heading wraps instead of pushing
   the counter out of the section. */
.rail__head {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: end;
  gap: var(--s-4) var(--s-6);
}

.rail__title {
  max-inline-size: 18ch;
  /* Belt and braces: never let a single long word overflow the column. */
  overflow-wrap: break-word;
}

@media (max-width: 720px) {
  .rail__head {
    grid-template-columns: minmax(0, 1fr);
  }
}

.rail__station-of {
  gap: var(--s-1);
  font-variant-numeric: tabular-nums;
}

.rail__station-now {
  font-size: clamp(2rem, 4vw, 3.2rem);
  font-weight: 500;
  letter-spacing: -0.04em;
  line-height: 1;
  color: var(--c-accent);
}

/* --------------------------------------------------- default: vertical -- */

.rail__viewport {
  position: relative;
  margin-block: var(--s-7);
}

/* The datum: a drawing's dimension rule, and the one continuous thing on the
   page. Vertical by default, running down the left of the stations. */
.rail__datum {
  position: absolute;
  inset-block: 0;
  inset-inline-start: var(--s-5);
  inline-size: 1px;
  background: var(--c-line);
}

/* Accent fills the datum as far as the job has travelled. */
.rail__datum::after {
  content: '';
  position: absolute;
  inset-inline: 0;
  inset-block-start: 0;
  block-size: calc(var(--progress) * 100%);
  background: var(--c-accent);
}

.rail__track {
  display: flex;
  flex-direction: column;
  gap: var(--s-6);
}

.rail__station {
  position: relative;
  /* Start padding clears the datum and its tick. The end padding is the page's
     own gutter, so a stacked card lines up with every other block on the page
     instead of running under the screen edge — which is what it did at every
     width below the pinned layout, most visibly on a phone. */
  padding-inline-start: var(--s-8);
  padding-inline-end: var(--grid-gutter);
}

.rail__body {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

/* The tick where this station meets the datum. */
.rail__tick {
  position: absolute;
  inset-block-start: var(--s-5);
  inset-inline-start: var(--s-5);
  inline-size: 9px;
  block-size: 9px;
  border-radius: 50%;
  border: 1px solid var(--c-line-strong);
  background: var(--c-canvas);
  translate: -50% -50%;
  transition:
    background var(--d-base) var(--e-in-out),
    border-color var(--d-base) var(--e-in-out),
    scale var(--d-base) var(--e-snap);
}

.rail__station[data-state='done'] .rail__tick,
.rail__station[data-state='now'] .rail__tick {
  background: var(--c-accent);
  border-color: var(--c-accent);
}

.rail__station[data-state='now'] .rail__tick {
  scale: 1.6;
}

/* The stem joining a card to its tick. Only meaningful in the horizontal
   layout, where the card sits away from the datum. */
.rail__stem {
  display: none;
}

.rail__index {
  color: var(--c-muted);
  transition: color var(--d-base) var(--e-in-out);
}

.rail__station[data-state='done'] .rail__index,
.rail__station[data-state='now'] .rail__index {
  color: var(--c-accent);
}

/* The record each station leaves behind — the "one source of truth" argument
   made by accumulation rather than by a claim. */
.rail__record {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--c-muted);
}

.rail__record::before {
  content: '';
  inline-size: 5px;
  block-size: 5px;
  border-radius: 1px;
  background: var(--c-accent);
  flex: none;
}

.rail__foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  color: var(--c-muted);
}

/* The scroll hint only makes sense when the rail is actually horizontal. */
.rail__hint {
  display: none;
}

/* =============================================================================
   Pinned horizontal treatment — applied only when rail.js opts in.
   ========================================================================== */

.rail[data-pinned='true'] {
  /* Length of the pin, set by rail.js from the station count so adding a
     station lengthens the journey rather than speeding it up. */
  block-size: var(--rail-length, 400vh);
  padding-block: 0;
  --rail-travel: 0px;
}

.rail[data-pinned='true'] .rail__pin {
  position: sticky;
  inset-block-start: 0;
  block-size: 100vh;
  display: grid;
  grid-template-rows: auto 1fr auto;
  /* The track inside is deliberately `max-content` wide (~3300px). Without an
     explicit column, this grid's implicit `auto` column sizes to that
     max-content and the head's centred .shell resolves 100% against 3300px —
     which pushed the title ~900px right and clipped it off screen. */
  grid-template-columns: minmax(0, 1fr);
  /* The middle row must be allowed to shrink. A grid row of `1fr` still gets an
     automatic minimum of its content, so the full-height stations pushed the
     grid past 100vh and `overflow: clip` cut the bottom row of cards off. */
  grid-template-rows: auto minmax(0, 1fr) auto;
  /* The information strip is permanent and never hides, so the pinned head has
     to start below it or the title sits underneath. The nav does hide on the
     way down, which is exactly the behaviour this section wants. */
  padding-block: calc(var(--strip-height, 44px) + var(--s-4)) var(--s-4);
  gap: var(--s-4);
  overflow: clip;
}

.rail[data-pinned='true'] .rail__viewport {
  margin-block: 0;
  display: flex;
  align-items: stretch;
  overflow: clip;
  /* Belt and braces with the explicit tracks above: a grid item's automatic
     minimum size is its content, in both axes. */
  min-inline-size: 0;
  min-block-size: 0;
}

/* Horizontal datum, spanning the visible viewport rather than the track, so
   the fill reads as overall progress through the journey. */
.rail[data-pinned='true'] .rail__datum {
  inset-block: auto;
  inset-block-start: 50%;
  inset-inline: 0;
  inline-size: auto;
  block-size: 1px;
  translate: 0 -0.5px;
}

.rail[data-pinned='true'] .rail__datum::after {
  inset-block: 0;
  inset-inline-start: 0;
  inline-size: calc(var(--progress) * 100%);
  block-size: auto;
}

.rail[data-pinned='true'] .rail__track {
  flex-direction: row;
  align-items: stretch;
  gap: clamp(var(--s-6), 4vw, var(--s-8));
  padding-inline: var(--grid-gutter);
  min-inline-size: max-content;
  /* The entire horizontal movement, on the compositor. */
  transform: translate3d(calc(var(--progress) * var(--rail-travel) * -1), 0, 0);
  will-change: transform;
}

/* Full-height stations split into equal halves. Because every station is the
   same height, the 50% mark lines up with the datum on all of them — a card
   with fewer rows than its neighbour must not drag its tick off the line. */
.rail[data-pinned='true'] .rail__station {
  inline-size: clamp(15rem, 21vw, 19rem);
  flex: none;
  block-size: 100%;
  display: grid;
  grid-template-rows: 1fr 1fr;
  /* The pinned track carries the gutter itself — see .rail__track above. */
  padding-inline: 0;
}

/* Stations alternate above and below the line — a timeline drawing rather than
   a row of cards, and it lets twice as many fit in the same width. */
.rail[data-pinned='true'] .rail__station:nth-child(odd) .rail__body {
  grid-row: 1;
  justify-content: flex-end;
  padding-block-end: var(--s-5);
}

.rail[data-pinned='true'] .rail__station:nth-child(even) .rail__body {
  grid-row: 2;
  justify-content: flex-start;
  padding-block-start: var(--s-5);
}

/* Each half of a station is one viewport-half tall, which on a short laptop
   screen is not much. The card is compacted inside the rail so both rows stay
   fully visible rather than the lower one being clipped by the pin. */
.rail[data-pinned='true'] .rail__body {
  gap: var(--s-2);
  min-block-size: 0;
}

.rail[data-pinned='true'] .rail__card .obj__head {
  padding: var(--s-2) var(--s-3);
}

.rail[data-pinned='true'] .rail__card .obj__body {
  padding: var(--s-3);
  gap: var(--s-2);
}

.rail[data-pinned='true'] .rail__tick {
  inset-block-start: 50%;
  inset-inline-start: var(--s-5);
}

.rail[data-pinned='true'] .rail__stem {
  display: block;
  position: absolute;
  inset-inline-start: var(--s-5);
  inline-size: 1px;
  block-size: var(--s-6);
  background: var(--c-line);
}

.rail[data-pinned='true'] .rail__station:nth-child(odd) .rail__stem {
  inset-block-end: 50%;
}

.rail[data-pinned='true'] .rail__station:nth-child(even) .rail__stem {
  inset-block-start: 50%;
}

/* Stations the job has not reached sit back; the current one comes forward.
   Opacity and scale only — no layout is touched during the scroll. */
.rail[data-pinned='true'] .rail__card,
.rail[data-pinned='true'] .rail__record {
  transition:
    opacity var(--d-slow) var(--e-out),
    scale var(--d-slow) var(--e-out);
}

.rail[data-pinned='true'] .rail__card {
  opacity: 0.32;
  scale: 0.97;
}

.rail[data-pinned='true'] .rail__record {
  opacity: 0;
}

.rail[data-pinned='true'] .rail__station[data-state='done'] .rail__card {
  opacity: 0.75;
  scale: 1;
}

.rail[data-pinned='true'] .rail__station[data-state='now'] .rail__card {
  opacity: 1;
  scale: 1;
}

.rail[data-pinned='true'] .rail__station[data-state='done'] .rail__record,
.rail[data-pinned='true'] .rail__station[data-state='now'] .rail__record {
  opacity: 1;
}

/* The hint fades once the reader has clearly understood the mechanism. */
.rail[data-pinned='true'] .rail__hint {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  opacity: clamp(0, calc(1 - var(--progress) * 6), 1);
  transition: opacity var(--d-base) linear;
}

.rail__hint-arrow {
  animation: rail-nudge 2.4s var(--e-in-out) infinite;
}

@keyframes rail-nudge {
  0%,
  60%,
  100% {
    transform: translateX(0);
  }
  30% {
    transform: translateX(4px);
  }
}

@media (prefers-reduced-motion: reduce) {
  .rail__hint-arrow {
    animation: none;
  }
}


/* ==== assets/css/sections/stack.css ==== */
/* =============================================================================
   Stack calculator.

   Centred and single-column, following the reference calculator's shape: a
   framing note, then two questions, then one big number. The app tiles are logo
   slots that reveal their name and per-user price on hover or focus.
   ========================================================================== */

.stack-section {
  background:
    radial-gradient(
      80ch 40ch at 50% 0%,
      color-mix(in srgb, var(--c-tint-clay) 90%, transparent),
      transparent 65%
    ),
    var(--c-canvas-sunk);
  text-align: center;
}

.stack-section .section-head {
  align-items: center;
  margin-block-end: var(--s-6);
}

.stack-section .eyebrow {
  justify-content: center;
}

.stack-section .title {
  max-inline-size: 22ch;
  margin-inline: auto;
}

/* ------------------------------------------------------------------- note */

/* The framing statement, set as a card with an accent hairline across the top
   so it reads as a note from us rather than body copy. */
.stack__note {
  position: relative;
  max-inline-size: 62ch;
  margin: 0 auto var(--s-8);
  padding: var(--s-5) var(--s-6);
  background: var(--c-canvas);
  border: var(--border);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-card);
  overflow: hidden;
  font-size: var(--t-lead);
  line-height: 1.55;
  color: var(--c-ink-soft);
}

.stack__note::before {
  content: '';
  position: absolute;
  inset-block-start: 0;
  inset-inline: 0;
  block-size: 3px;
  background: linear-gradient(
    90deg,
    transparent,
    var(--c-accent),
    var(--c-clay),
    transparent
  );
}

.stack__note strong {
  color: var(--c-ink);
  font-weight: 600;
}

/* ------------------------------------------------------------------ steps */

.stack__step {
  margin-block-end: var(--s-8);
}

.stack__question {
  font-size: clamp(1.35rem, 2.4vw, 1.9rem);
  font-weight: 550;
  letter-spacing: -0.022em;
  line-height: 1.2;
}

.stack__aside {
  margin-block-start: var(--s-2);
  font-size: var(--t-small);
  color: var(--c-muted);
}

/* ------------------------------------------------------------- app tiles */

.stack__apps {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(104px, 1fr));
  gap: var(--s-3);
  max-inline-size: 62rem;
  margin: var(--s-6) auto 0;
}

/* A square tile holding a logo slot. Nothing is printed on the face — the
   identity is revealed on hover and focus, which keeps the grid calm. */
.app-tile {
  position: relative;
  display: grid;
  place-items: center;
  aspect-ratio: 1;
  padding: var(--s-4);
  background: var(--c-canvas);
  border: 1px solid var(--c-line);
  border-radius: var(--r-lg);
  transition:
    background var(--d-base) var(--e-in-out),
    border-color var(--d-base) var(--e-in-out),
    transform var(--d-base) var(--e-snap),
    box-shadow var(--d-base) var(--e-out);
}

.app-tile:hover,
.app-tile:focus-visible {
  border-color: var(--c-line-strong);
  transform: translateY(-4px);
  box-shadow: var(--shadow-lift);
}

.app-tile[aria-pressed='true'] {
  background: var(--c-accent-soft);
  border-color: var(--c-accent);
}

/* A tick in the corner, so selection is legible without relying on fill alone. */
.app-tile[aria-pressed='true']::after {
  content: '';
  position: absolute;
  inset-block-start: 8px;
  inset-inline-end: 8px;
  inline-size: 7px;
  block-size: 7px;
  border-radius: 50%;
  background: var(--c-accent);
}

/* The placeholder standing in for a real logo. Dashed, like every other
   placeholder on the page, so it is obviously not final artwork. */
.app-tile__slot {
  display: grid;
  place-items: center;
  inline-size: 44px;
  block-size: 44px;
  border: 1px dashed var(--c-line-strong);
  border-radius: var(--r-md);
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: 0.06em;
  color: var(--c-muted);
  transition:
    border-color var(--d-base) var(--e-in-out),
    color var(--d-base) var(--e-in-out);
}

.app-tile[aria-pressed='true'] .app-tile__slot {
  border-style: solid;
  border-color: var(--c-accent);
  color: var(--c-accent-deep);
}

/* Once a real logo is supplied in data/apps.js it replaces the slot. Unselected
   tiles desaturate it so the grid stays quiet until you choose. */
/*
 * A landscape box, not a square one.
 *
 * The supplied logos are two different kinds of asset: square icon marks
 * (Slack, Asana, Drive) and wide wordmarks (Monday is 5.4:1, Wrike 5:1). In a
 * 40x40 box `contain` fits a wordmark to its WIDTH, so Monday rendered about
 * seven pixels tall beside Slack's forty. A 72x40 box caps the square marks at
 * 40 exactly as before, and gives the wordmarks nearly twice the height.
 *
 * When square icon marks replace the wordmarks this rule needs no change —
 * they will simply cap on height like the others.
 */
.app-tile__logo {
  inline-size: 100%;
  max-inline-size: 72px;
  block-size: 40px;
  object-fit: contain;
  filter: grayscale(1);
  opacity: 0.55;
  transition:
    filter var(--d-base) var(--e-in-out),
    opacity var(--d-base) var(--e-in-out);
}

.app-tile:hover .app-tile__logo,
.app-tile:focus-visible .app-tile__logo,
.app-tile[aria-pressed='true'] .app-tile__logo {
  filter: none;
  opacity: 1;
}

/* The reveal: name and price, on hover or keyboard focus. Sits above the tile
   on its own layer so it is never clipped by the grid. */
.app-tile__reveal {
  position: absolute;
  inset-block-end: calc(100% + 8px);
  inset-inline-start: 50%;
  translate: -50% 0;
  z-index: var(--z-raised);
  display: flex;
  flex-direction: column;
  gap: 1px;
  padding: var(--s-2) var(--s-3);
  background: var(--c-ink);
  color: var(--c-canvas);
  border-radius: var(--r-md);
  white-space: nowrap;
  pointer-events: none;
  opacity: 0;
  transform: translateY(4px);
  transition:
    opacity var(--d-fast) var(--e-out),
    transform var(--d-fast) var(--e-out);
}

/* The little pointer under the bubble. */
.app-tile__reveal::after {
  content: '';
  position: absolute;
  inset-block-start: 100%;
  inset-inline-start: 50%;
  translate: -50% -2px;
  border: 4px solid transparent;
  border-block-start-color: var(--c-ink);
}

.app-tile:hover .app-tile__reveal,
.app-tile:focus-visible .app-tile__reveal {
  opacity: 1;
  transform: none;
}

.app-tile__name {
  font-size: var(--t-micro);
  font-weight: 600;
}

.app-tile__price {
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  color: var(--c-accent-on-dark);
}

/* ----------------------------------------------------------------- slider */

.stack__slider {
  max-inline-size: 34rem;
  margin: var(--s-6) auto 0;
}

.stack__slider .slider__value {
  justify-content: center;
}

/* ----------------------------------------------------------------- result */

.stack__result {
  max-inline-size: 34rem;
  margin-inline: auto;
  padding: var(--s-7) var(--s-6);
  background: var(--c-canvas);
  border: 1px solid var(--c-line);
  border-radius: var(--r-lg);
  box-shadow: var(--shadow-lift);
  transition: border-color var(--d-slow) var(--e-in-out);
}

/* The panel warms up once there is something to show. */
[data-has-selection='true'] .stack__result {
  border-color: var(--c-accent);
}

.stack__result-label {
  color: var(--c-muted);
  text-transform: none;
  letter-spacing: 0.02em;
}

.stack__figure {
  margin-block: var(--s-3) var(--s-2);
  font-size: clamp(3rem, 8vw, 5rem);
  line-height: 1;
  letter-spacing: -0.045em;
  font-weight: 500;
  font-variant-numeric: tabular-nums;
  color: var(--c-clay);
  transition: color var(--d-slow) var(--e-in-out);
}

.stack__count {
  color: var(--c-muted);
  text-transform: none;
  letter-spacing: 0.02em;
}

.stack__meta {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--s-4);
  margin-block-start: var(--s-6);
  padding-block-start: var(--s-5);
  border-block-start: var(--border);
}

.stack__meta dt {
  color: var(--c-muted);
}

.stack__meta dd {
  margin: 0;
  margin-block-start: var(--s-1);
  font-size: 1.5rem;
  font-weight: 500;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
}

/* The annual figure is the one that lands, so it carries the accent. */
.stack__meta div:last-child dd {
  color: var(--c-accent);
}

.stack__disclaimer {
  max-inline-size: 60ch;
  margin: var(--s-5) auto 0;
  font-size: var(--t-small);
  font-style: italic;
  color: var(--c-muted);
  line-height: 1.5;
}

/* ---------------------------------------------------------------- message */

.stack__message {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s-5);
  max-inline-size: 60ch;
  margin: var(--s-9) auto 0;
  padding-block-start: var(--s-8);
  border-block-start: var(--border-strong);
}

.stack__message .prose {
  text-align: center;
}

@media (max-width: 620px) {
  .stack__apps {
    grid-template-columns: repeat(auto-fit, minmax(84px, 1fr));
  }

  /* Tooltips are useless on touch, where there is no hover — the accessible
     name still carries the detail for assistive tech. */
  .app-tile__reveal {
    display: none;
  }
}


/* ==== assets/css/sections/catalogue.css ==== */
/* =============================================================================
   Digitise catalogue.

   An index page, not a feature grid: a dense multi-column list where the sheer
   length is the argument. The oversized category numeral anchors each panel.
   ========================================================================== */

.catalogue__tabs {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  padding-block-end: var(--s-6);
  border-block-end: var(--border-strong);
}

/* Reserve the tallest panel's height so switching category doesn't bounce the
   page. min-height is set from the longest list rather than measured in JS. */
.catalogue__panels {
  min-block-size: 34rem;
}

.catalogue__panel[hidden] {
  display: none;
}

.catalogue__panel {
  padding-block-start: var(--s-7);
  display: grid;
  grid-template-columns: minmax(0, 0.5fr) minmax(0, 1.5fr);
  gap: var(--s-7) clamp(var(--s-6), 5vw, var(--s-9));
  align-items: start;
}

.catalogue__aside {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
  position: sticky;
  inset-block-start: calc(var(--strip-height, 44px) + 5.5rem);
}

.catalogue__num {
  color: var(--c-line-strong);
  line-height: 0.8;
}

.catalogue__count {
  font-variant-numeric: tabular-nums;
}

/* The list itself — three columns on desktop, so the eye takes in the volume at
   once rather than scrolling through it. */
.catalogue__list {
  columns: 3;
  column-gap: clamp(var(--s-5), 3vw, var(--s-8));
  column-rule: var(--border);
}

.catalogue__item {
  break-inside: avoid;
  display: flex;
  align-items: baseline;
  gap: var(--s-3);
  padding-block: 0.55em;
  border-block-end: var(--border);
  font-size: var(--t-small);
  color: var(--c-ink-soft);
  transition: color var(--d-base) var(--e-in-out);
}

/* A hairline tick that fills on hover — the list should feel checkable. */
.catalogue__item::before {
  content: '';
  flex: none;
  inline-size: 6px;
  block-size: 6px;
  border: 1px solid var(--c-line-strong);
  border-radius: 1px;
  transition:
    background var(--d-base) var(--e-in-out),
    border-color var(--d-base) var(--e-in-out);
}

.catalogue__item:hover {
  color: var(--c-ink);
}

.catalogue__item:hover::before {
  background: var(--c-accent);
  border-color: var(--c-accent);
}

@media (max-width: 1080px) {
  .catalogue__list {
    columns: 2;
  }
}

@media (max-width: 900px) {
  .catalogue__panel {
    grid-template-columns: minmax(0, 1fr);
  }

  .catalogue__aside {
    position: static;
  }
}

@media (max-width: 560px) {
  .catalogue__list {
    columns: 1;
  }
}

/* ------------------------------------------------------------ custom panel */

/* The fourth category has no list — it is an invitation, so it gets a
   completely different composition from the other three. */
.catalogue__custom {
  grid-column: 2;
  display: flex;
  flex-direction: column;
  gap: var(--s-6);
  align-items: start;
  padding: clamp(var(--s-6), 4vw, var(--s-8));
  border: 1px dashed var(--c-line-strong);
  border-radius: var(--r-lg);
  background: var(--c-canvas-sunk);
}

.catalogue__custom-title {
  max-inline-size: 20ch;
}

@media (max-width: 900px) {
  .catalogue__custom {
    grid-column: 1;
  }
}

/* =============================================================================
   Family colour.

   Each of the four workflow families carries its own hue, set on the panel and
   inherited by the numeral, the icon and the list ticks. This is categorical
   colour doing a job — telling manufacturing from warehouse at a glance — not
   decoration, which is why it appears here and almost nowhere else.
   ========================================================================== */

.catalogue__panel {
  --family: var(--c-accent);
  --family-soft: var(--c-accent-soft);
}

.catalogue__panel[data-family='warehouse'] {
  --family: var(--c-slate);
  --family-soft: var(--c-slate-soft);
}

.catalogue__panel[data-family='office'] {
  --family: var(--c-ochre);
  --family-soft: var(--c-ochre-soft);
}

.catalogue__panel[data-family='custom'] {
  --family: var(--c-clay);
  --family-soft: var(--c-clay-soft);
}

.catalogue__num {
  color: var(--family);
  opacity: 0.28;
}

.catalogue__aside .icon-well {
  color: var(--family);
  border-color: color-mix(in srgb, var(--family) 35%, transparent);
  background: var(--family-soft);
}

.catalogue__item:hover::before {
  background: var(--family);
  border-color: var(--family);
}

.catalogue__custom {
  border-color: color-mix(in srgb, var(--family) 45%, transparent);
  background: color-mix(in srgb, var(--family-soft) 55%, var(--c-canvas));
}

/* The active tab picks up its family colour rather than flat ink, so the tab
   row previews the palette of the panel it opens. */
.catalogue__tabs .tab[aria-selected='true'] {
  background: var(--tab-family, var(--c-ink));
  border-color: var(--tab-family, var(--c-ink));
}


/* ==== assets/css/sections/transform.css ==== */
/* =============================================================================
   Before / after transformation (Animation B).

   The first dark section on the page. Twelve steps on the left collapse into
   five on the right; steps sharing a merge key slide together and fade, so the
   reduction reads as consolidation rather than deletion.
   ========================================================================== */

.transform {
  background: var(--c-canvas);
  color: var(--c-ink);
  --progress: 0;
}

.transform__controls {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  padding-block-end: var(--s-6);
  border-block-end: 1px solid var(--c-line);
}

.transform__stage {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: clamp(var(--s-5), 3.5vw, var(--s-8));
  padding-block: var(--s-6);
  position: relative;
}

/* A single hairline between the two columns — the seam the work crosses. */
.transform__stage::before {
  content: '';
  position: absolute;
  inset-block: var(--s-6);
  inset-inline-start: 50%;
  inline-size: 1px;
  background: var(--c-line);
}

/* Grid children default to min-width:auto, which lets a long step label push
   the column wider than its track and overflow the section. */
.transform__column {
  min-inline-size: 0;
}

.transform__column-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-4);
  padding-block-end: var(--s-3);
  border-block-end: 1px solid var(--c-line);
  margin-block-end: var(--s-5);
  color: var(--c-muted);
}

.transform__count {
  font-size: 1.5rem;
  font-weight: 500;
  letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums;
  color: var(--c-ink);
}

.transform__list {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

/* ------------------------------------------------------------ before step */

.transform__step {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  padding: 0.42em 0.75em;
  /* Clay marks the before side throughout the page. */
  border: 1px solid color-mix(in srgb, var(--c-clay) 45%, transparent);
  border-radius: var(--r-sm);
  background: var(--c-canvas-sunk);
  font-size: var(--t-small);
  transform-origin: top;
  transition:
    opacity var(--d-slow) var(--e-in-out),
    transform var(--d-slow) var(--e-in-out),
    border-color var(--d-slow) var(--e-in-out),
    margin var(--d-slow) var(--e-in-out);
}

/* Steps that merge into an existing one collapse upward and fade. Height is
   animated via margin + scaleY so no layout is measured per frame. */
.transform__step[data-merged='true'] {
  opacity: 0;
  transform: translateY(-8px) scaleY(0.4);
  margin-block-end: calc(var(--s-2) * -1 - 2.6em);
  pointer-events: none;
}

.transform__step-label {
  min-inline-size: 0;
}

.transform__friction {
  flex: none;
}

/* ------------------------------------------------------------- after step */

.transform__after-step {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  padding: 0.5em 0.8em;
  border: 1px solid var(--c-line);
  border-radius: var(--r-sm);
  background: transparent;
  font-size: var(--t-small);
  opacity: 0.25;
  transform: translateX(-6px);
  transition:
    opacity var(--d-slow) var(--e-out),
    transform var(--d-slow) var(--e-out),
    border-color var(--d-slow) var(--e-out),
    background var(--d-slow) var(--e-out);
}

.transform__after-step[data-active='true'] {
  opacity: 1;
  transform: none;
  border-color: var(--c-accent);
  background: var(--c-accent-soft);
}

.transform__foot {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: var(--s-5) var(--s-7);
  align-items: start;
  padding-block-start: var(--s-5);
  border-block-start: 1px solid var(--c-line);
}

.transform__impact {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  color: var(--c-ink-soft);
}

.transform__impact li {
  display: flex;
  gap: var(--s-3);
  font-size: var(--t-small);
}

.transform__impact li::before {
  content: '→';
  color: var(--c-accent);
  flex: none;
}

@media (max-width: 860px) {
  .transform__stage {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-7);
  }

  /* The seam becomes horizontal so the two columns still read as one crossing. */
  .transform__stage::before {
    inset: auto 0 auto 0;
    inset-block-start: 50%;
    inline-size: auto;
    block-size: 1px;
  }

  .transform__foot {
    grid-template-columns: minmax(0, 1fr);
  }
}


/* ==== assets/css/sections/factory.css ==== */
/* =============================================================================
   Manufacturing section.

   Three operational objects showing situations a factory actually has to
   handle. The linear production flow lives in the rail section instead, so this
   section is not a second diagram of the same sequence.
   ========================================================================== */

/* ------------------------------------------------------------------ cases */

.factory__cases {
  margin-block-start: var(--s-7);
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--grid-gap);
}

@media (max-width: 980px) {
  .factory__cases {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

@media (max-width: 660px) {
  .factory__cases {
    grid-template-columns: minmax(0, 1fr);
  }
}

.factory__case .obj__body {
  gap: var(--s-4);
}

/* The replenishment chain reveals link by link once the card is in view. */
.factory__chain {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-2);
  padding-block-start: var(--s-3);
  border-block-start: var(--border);
}

.factory__chain > * {
  opacity: 0;
  transform: translateX(-4px);
  transition:
    opacity var(--d-base) var(--e-out),
    transform var(--d-base) var(--e-out);
  transition-delay: calc(var(--link-index, 0) * 130ms);
}

.factory__chain[data-run='true'] > * {
  opacity: 1;
  transform: none;
}

.factory__chain-arrow {
  color: var(--c-line-strong);
  font-size: 0.75rem;
}

/* ---------------------------------------------------------------- states */

.factory__states {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  padding-block-start: var(--s-3);
  border-block-start: var(--border);
}

.factory__state {
  padding: 0.25em 0.6em;
  border: 1px solid var(--c-line);
  border-radius: var(--r-pill);
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--c-muted);
  transition:
    background var(--d-base) var(--e-in-out),
    color var(--d-base) var(--e-in-out),
    border-color var(--d-base) var(--e-in-out);
}

.factory__state[data-done] {
  color: var(--c-ink-soft);
  border-color: var(--c-line-strong);
}

.factory__state[data-current] {
  background: var(--c-stop);
  border-color: var(--c-stop);
  color: #fbfbf7;
}

.factory__reason {
  color: var(--c-stop);
  text-transform: none;
  letter-spacing: 0.02em;
}

/* --------------------------------------------------------------- decision */

.factory__decision {
  display: flex;
  gap: var(--s-2);
  padding-block-start: var(--s-3);
  border-block-start: var(--border);
}

/* The quality card resolves to Pass once it has been on screen a moment — the
   decision is shown being made, not just listed. */
.factory__decision .tag[data-chosen='true'] {
  background: var(--c-accent-soft);
  color: var(--c-accent-deep);
}

.factory__decision .tag[data-chosen='false'] {
  opacity: 0.4;
}



/* ==== assets/css/sections/warehouse.css ==== */
/* =============================================================================
   Warehousing section.

   An abstract bin map in CSS grid (no 3D warehouse, no isometric clichés) beside
   a handheld scanner. The scanner is the artwork — it is the interface the
   warehouse team would actually hold.
   ========================================================================== */

/*
 * The pinned track.
 *
 * Only above 900px, where the map and the handheld sit side by side and the
 * pair is short enough to hold still on screen. Below that they stack into a
 * column taller than the viewport, where pinning would park a tall element
 * against the top and leave a screen of nothing under it — so the track
 * collapses to its natural height and the sequence runs as the stage scrolls
 * past, which motion.js falls back to on its own.
 */
.warehouse__track {
  position: relative;
}

@media (min-width: 900px) {
  .warehouse__track {
    min-block-size: var(--warehouse-length, 200vh);
  }

  /* Centred in whatever the viewport has left, rather than parked under the
     nav with a third of a screen of nothing beneath it. --stage-h is written
     by warehouse.js; the max() keeps it clear of the strip and nav on short
     windows, where centring would slide it up underneath them. */
  .warehouse__stage {
    position: sticky;
    inset-block-start: max(
      var(--pin-top),
      calc((100vh - var(--stage-h, 28rem)) / 2)
    );
  }
}

.warehouse__stage {
  display: grid;
  grid-template-columns: minmax(0, 1.55fr) minmax(0, 0.8fr);
  gap: clamp(var(--s-6), 5vw, var(--s-9));
  align-items: start;
  margin-block: var(--s-7);
}

@media (max-width: 900px) {
  .warehouse__stage {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* -------------------------------------------------------------------- map */

.warehouse__map {
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
  padding: clamp(var(--s-5), 3vw, var(--s-7));
  border: var(--border);
  border-radius: var(--r-lg);
  background: var(--c-canvas-sunk);
}

.warehouse__zones {
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
}

.warehouse__zone {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.warehouse__zone-label {
  color: var(--c-muted);
}

/* Exactly four, not auto-fill. auto-fill sized the tracks to whatever the
   column happened to be and left the last bay empty whenever the count and the
   fit disagreed; a rack has a fixed number of bays, so the grid should too. */
.warehouse__bins {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--s-2);
}

@media (max-width: 520px) {
  .warehouse__bins {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

.warehouse__bin {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--s-3) var(--s-2);
  border: 1px solid var(--c-line);
  border-radius: var(--r-xs);
  background: var(--c-canvas);
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--c-muted);
  text-align: center;
  transition:
    background var(--d-base) var(--e-in-out),
    border-color var(--d-base) var(--e-in-out),
    color var(--d-base) var(--e-in-out);
}

/* Fill level, drawn as a bar at the base of the bin rather than a number. */
.warehouse__bin::after {
  content: '';
  position: absolute;
  inset-inline: 3px;
  inset-block-end: 3px;
  block-size: 3px;
  border-radius: 1px;
  background: var(--c-line-strong);
  transform: scaleX(var(--fill, 0));
  transform-origin: left;
  transition: transform 900ms var(--e-out);
}

.warehouse__bin[data-state='stored'] {
  border-color: var(--c-line-strong);
  color: var(--c-ink-soft);
}

.warehouse__bin[data-state='active'] {
  border-color: var(--c-accent);
  background: var(--c-accent-soft);
  color: var(--c-accent-deep);
}

.warehouse__bin[data-state='active']::after {
  background: var(--c-accent);
}

.warehouse__legend {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2) var(--s-5);
  padding-block-start: var(--s-4);
  border-block-start: var(--border);
}

.warehouse__legend span {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
}

.warehouse__key {
  inline-size: 10px;
  block-size: 10px;
  border: 1px solid var(--c-line);
  border-radius: 2px;
}

/* An empty bin is the outline alone — stated explicitly so the legend's three
   states are all defined in one place rather than one relying on the default. */
.warehouse__key--empty {
  background: transparent;
}

.warehouse__key--stored {
  border-color: var(--c-line-strong);
  background: var(--c-panel);
}

.warehouse__key--active {
  border-color: var(--c-accent);
  background: var(--c-accent-soft);
}

/* ---------------------------------------------------------------- scanner */

.warehouse__scanner {
  position: sticky;
  inset-block-start: calc(var(--strip-height, 44px) + 6rem);
}

.scanner {
  display: flex;
  flex-direction: column;
  border: 1px solid var(--c-line-strong);
  border-radius: var(--r-lg);
  background: var(--c-canvas);
  overflow: hidden;
  box-shadow: var(--shadow-lift);
}

.scanner__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: var(--s-3) var(--s-4);
  border-block-end: var(--border);
  color: var(--c-muted);
}

.scanner__screen {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  padding: var(--s-6) var(--s-5);
  background: var(--c-dark);
  color: var(--c-dark-ink);
  min-block-size: 11rem;
  overflow: hidden;
}

.scanner__prompt {
  color: var(--c-dark-muted);
}

.scanner__code {
  font-family: var(--font-mono);
  animation: scanner-code var(--d-slow) var(--e-out);
  font-size: clamp(1.5rem, 3vw, 2rem);
  letter-spacing: 0.02em;
  font-variant-numeric: tabular-nums;
}

.scanner__confirm {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-accent-on-dark);
  opacity: 0;
  transform: translateY(4px);
  transition:
    opacity var(--d-slow) var(--e-out),
    transform var(--d-slow) var(--e-out);
  /* Lands after the beam has swept, so the tick reads as the RESULT of the
     scan rather than as something that happened alongside it. */
  transition-delay: 1800ms;
}

.scanner__screen[data-confirmed='true'] .scanner__confirm {
  opacity: 1;
  transform: none;
}

.scanner__tick {
  display: grid;
  place-items: center;
  inline-size: 16px;
  block-size: 16px;
  border-radius: 50%;
  border: 1px solid currentcolor;
  font-size: var(--t-nano);
}

/* A single sweep on each scan. Runs once per step, not continuously. */
.scanner__beam {
  position: absolute;
  inset-inline: 0;
  block-size: 2px;
  background: linear-gradient(
    90deg,
    transparent,
    var(--c-accent-on-dark),
    transparent
  );
  opacity: 0;
  inset-block-start: 0;
}

/* 1.6s, not 620ms. A real handheld takes a beat to read a label, and at the old
   speed the sweep was over before the eye found it — the section's one moment
   of "something just happened" was invisible. Slow enough to notice, still only
   once per scan. */
.scanner__screen[data-scanning='true'] .scanner__beam {
  animation: scanner-sweep 2600ms var(--e-in-out);
}

@keyframes scanner-code {
  from {
    opacity: 0;
    translate: 0 6px;
  }
}

@keyframes scanner-sweep {
  0% {
    opacity: 0;
    transform: translateY(0);
  }
  12% {
    opacity: 0.9;
  }
  82% {
    opacity: 0.9;
  }
  100% {
    opacity: 0;
    transform: translateY(11rem);
  }
}

.scanner__steps {
  display: flex;
  flex-direction: column;
}

.scanner__step {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-4);
  border-block-start: var(--border);
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--c-muted);
  transition: color var(--d-base) var(--e-in-out);
}

.scanner__step-dot {
  inline-size: 6px;
  block-size: 6px;
  border-radius: 50%;
  border: 1px solid var(--c-line-strong);
  flex: none;
  transition:
    background var(--d-base) var(--e-in-out),
    border-color var(--d-base) var(--e-in-out);
}

.scanner__step[data-done='true'] {
  color: var(--c-ink-soft);
}

.scanner__step[data-done='true'] .scanner__step-dot {
  background: var(--c-accent);
  border-color: var(--c-accent);
}

.scanner__step[data-active='true'] {
  color: var(--c-accent-deep);
}

/* ---------------------------------------------------------------- journey */

.warehouse__journey {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
  padding-block: var(--s-6);
  border-block: var(--border-strong);
}

.warehouse__leg {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: 0.5em 0.9em;
  border: 1px solid var(--c-line);
  border-radius: var(--r-pill);
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
  transition:
    background var(--d-base) var(--e-in-out),
    border-color var(--d-base) var(--e-in-out),
    color var(--d-base) var(--e-in-out);
}

.warehouse__leg[data-done='true'] {
  border-color: var(--c-accent);
  background: var(--c-accent-soft);
  color: var(--c-accent-deep);
}

@media (max-width: 900px) {
  .warehouse__scanner {
    position: static;
  }

  .scanner {
    max-inline-size: 26rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .scanner__screen[data-scanning='true'] .scanner__beam {
    animation: none;
  }
}


/* ==== assets/css/sections/follow.css ==== */
/* =============================================================================
   Follow the job.

   The section that closes the "for manufacturing" / "for warehousing" pair. It
   replaced two photographs — one at the foot of each — that were decorative:
   a floor and an aisle, captioned, sitting after the argument rather than
   making it. One object that carries both sections is a stronger close than
   two pictures that carry neither.

   Left: the five moments, in words, as buttons.
   Right: the same five moments as a stack of cards that deals itself.

   The story is deliberately the SAME job the rail near the top of the page
   carries — order SO-4471, three hundred units for Ashgrove Ltd. The rail shows
   it as records; this shows it as places. A reader who has scrolled past both
   should recognise the codes.
   ========================================================================== */

.follow__inner {
  display: grid;
  grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
  align-items: center;
  gap: clamp(var(--s-6), 5vw, var(--s-9));
}

.follow__words {
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
}

.follow__lead {
  max-inline-size: var(--measure);
}

/* ------------------------------------------------------------------- steps */

/*
 * The story in words.
 *
 * This is not a legend for the animation, it is the argument: five moments,
 * each with the record it produced. It reads on its own with the stack removed,
 * with motion off, and to a screen reader — and it is the keyboard route into a
 * stack that would otherwise only answer to a mouse.
 */
.follow__steps {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  border-block-start: var(--border);
}

.follow__step {
  inline-size: 100%;
  display: grid;
  grid-template-columns: 2.5rem minmax(0, 1fr) auto;
  align-items: baseline;
  gap: var(--s-3);
  padding-block: var(--s-3);
  padding-inline: 0;
  text-align: start;
  background: none;
  border: 0;
  border-block-end: var(--border);
  color: var(--c-muted);
  cursor: pointer;
  font: inherit;
  transition:
    color var(--d-base) var(--e-out),
    background-color var(--d-base) var(--e-out);
}

.follow__step:hover {
  color: var(--c-ink);
}

/* The current moment. Not a highlight for its own sake: it is the only thing
   tying the words to whichever card is at the front. */
.follow__step[aria-current='true'] {
  color: var(--c-ink);
}

.follow__step-index {
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  color: var(--c-line-strong);
  transition: color var(--d-base) var(--e-out);
}

.follow__step[aria-current='true'] .follow__step-index {
  color: var(--c-accent);
}

.follow__step-name {
  font-size: var(--t-body);
}

.follow__step[aria-current='true'] .follow__step-name {
  font-weight: 500;
}

.follow__step-code {
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  color: var(--c-muted);
  font-variant-numeric: tabular-nums;
}

.follow__step[aria-current='true'] .follow__step-code {
  color: var(--c-accent);
}

/* A rule that fills in under the current step, so the eye can find it without
   the row changing size and shunting the four below it. */
.follow__step {
  position: relative;
}

.follow__step::after {
  content: '';
  position: absolute;
  inset-block-end: -1px;
  inset-inline-start: 0;
  block-size: 1px;
  inline-size: 0;
  background: var(--c-accent);
  transition: inline-size var(--d-slow) var(--e-out);
}

.follow__step[aria-current='true']::after {
  inline-size: 100%;
}

/* ------------------------------------------------------------------- stack */

/*
 * The stage.
 *
 * A fixed aspect box so the section's height never depends on where the cards
 * happen to be mid-swap, and the perspective lives here rather than on the
 * cards, so all five share one vanishing point. `overflow: clip` on the inline
 * axis alone would still let the falling card show; it is not clipped at all,
 * because the fall is the move.
 */
.follow__stage {
  position: relative;
  aspect-ratio: 5 / 4;
  perspective: 1100px;
  perspective-origin: 45% 45%;

  /* How far apart the cards sit. Read by cardswap.js — the offsets are a
     responsive question and this is where this project answers those.

     --swap-turn is the angle the cards are presented at, and it is a rotateY
     rather than the original's skewY. Both tilt a card; only the rotation is a
     real one. The skew shears, so the codes and names came out slanted like a
     rendering fault, where a rotation is foreshortened by the perspective above
     and stays true. --swap-skew is still read, so the original shear can be put
     back with a number, but it is off. */
  /* Scaled, not fixed. The card's width is the stage minus this fan four times
     over, so a constant offset that reads well at 1920 eats the card alive on a
     narrow one: at a flat 74px the card had collapsed to 170px by 1024, with
     the record squeezed and the photograph a stamp. */
  --swap-dx: clamp(30px, 4.6vw, 74px);
  --swap-dy: clamp(28px, 3.6vw, 58px);
  --swap-turn: -19;
  --swap-skew: 0;
}

/*
 * Every card is placed on the same point and separated only by its transform,
 * so the stack stays concentric whatever the card size. The size is set here
 * rather than in script: a card is a piece of layout.
 */
.swapcard {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: 50%;
  /* The stage has to hold the front card AND the fan of the four behind it.
     --swap-span is that fan, written by cardswap.js, which is the only side
     that knows the card count. The floor keeps the record readable when the
     subtraction gets aggressive on a narrow phone. */
  inline-size: max(11rem, min(100% - var(--swap-span, 0px), 25rem));
  margin: 0;

  display: flex;
  flex-direction: column;
  overflow: hidden;
  border: 1px solid var(--c-line-strong);
  border-radius: var(--r-md);
  background: var(--c-panel);
  box-shadow: var(--shadow-card);

  transform-style: preserve-3d;
  will-change: transform;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  cursor: pointer;

  transition:
    border-color var(--d-base) var(--e-out),
    box-shadow var(--d-base) var(--e-out);
}

/* The card at the front is the one being read, so it is the one that is lit. */
.swapcard[data-front='true'] {
  border-color: var(--c-accent);
  box-shadow: var(--shadow-lift);
}

.follow__stage:hover .swapcard[data-front='true'] {
  border-color: var(--c-accent-deep);
}

.swapcard__shot {
  position: relative;
  aspect-ratio: 4 / 3;
  background: var(--c-canvas-sunk);
}

.swapcard__shot img {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
  display: block;
}

/* The placeholder fills the shot exactly, so dropping the photograph in changes
   nothing about the card's size. */
.swapcard__ph {
  position: absolute;
  inset: 0;
  border: 0;
  border-radius: 0;
  gap: var(--s-1);
  padding: var(--s-4);
}

/* Same reasoning as the foot: in the few millimetres of a card that show behind
   the front one, a stray half-glyph of the brief reads as breakage. The hatch
   stays, so the edge still looks like a card. Once the photographs land there is
   no .ph here at all and this rule stops applying to anything. */
.swapcard[data-front='false'] .swapcard__ph > * {
  opacity: 0;
  transition: opacity var(--d-slow) var(--e-out);
}

/*
 * The foot is the record the moment produced: a code and a name, set the way
 * every other record on this site is set. It is what makes the photograph
 * evidence rather than decoration.
 */
/*
 * Only the front card's record is legible.
 *
 * The cards behind show a few millimetres of their right edge, and with the
 * text left in that sliver was the tail of a code — "…B", "…41" — which reads
 * as something clipped rather than as a stack. The bar itself stays, so the
 * edge still has the card's structure in it, and the record fades up as its
 * card arrives.
 */
.swapcard__foot {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-3);
  padding: var(--s-4) var(--s-5);
  border-block-start: var(--border);
  background: var(--c-canvas);
}

.swapcard__foot > * {
  transition: opacity var(--d-slow) var(--e-out);
}

.swapcard[data-front='false'] .swapcard__foot > * {
  opacity: 0;
}

.swapcard__name {
  font-size: var(--t-small);
  font-weight: 500;
  color: var(--c-ink);
}

.swapcard__code {
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  color: var(--c-accent);
  font-variant-numeric: tabular-nums;
}

/* --------------------------------------------------------------- narrower */

/*
 * One column earlier than the rest of the page collapses.
 *
 * Two columns leave the stage under 480px below about 1200px, and the stage has
 * to hold a card AND the fan of four behind it — so the card was being squeezed
 * long before the type was. Given the whole width the stack has room again, and
 * the stage is capped so it does not sprawl across a tablet.
 */
@media (max-width: 1100px) {
  .follow__inner {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-7);
  }

  /* Words and stack share one column and one width, so the step rows do not
     run the full shell while the stack sits centred under them — which read as
     two different layouts stacked on each other rather than one. */
  .follow__words {
    inline-size: min(100%, 38rem);
    justify-self: center;
  }

  /* The stack needs less room to lean into when it is not beside a column of
     type, and a shallower angle keeps the rearmost card legible. */
  .follow__stage {
    aspect-ratio: 4 / 3;
    /* An explicit width, and justify-self rather than an auto margin. Every
       child here is absolutely positioned, so the moment the auto margin took
       the grid item off stretch its shrink-to-fit width was zero — the stage
       measured 0x0, the cards were placed around a point at the far left of the
       page, and the whole stack landed outside the section. */
    inline-size: min(100%, 38rem);
    justify-self: center;
    --swap-dx: clamp(30px, 5.4vw, 56px);
    --swap-dy: clamp(28px, 4.4vw, 48px);
    --swap-turn: -16;
  }
}

@media (max-width: 560px) {
  .follow__stage {
    aspect-ratio: 1 / 1;
    --swap-dx: 30px;
    --swap-dy: 28px;
    --swap-turn: -13;
    inline-size: 100%;
  }

  .follow__step {
    grid-template-columns: 2rem minmax(0, 1fr);
    row-gap: var(--s-1);
  }

  /* The code drops under the name rather than being squeezed against it. */
  .follow__step-code {
    grid-column: 2;
  }
}

/* -------------------------------------------------------- reduced motion */

/*
 * No stack. The cards become a plain list, every one readable at once.
 *
 * A frozen three-quarter stack would be a still of an animation, and only its
 * front card could be read — which is not the story, it is one fifth of it.
 * cardswap.js clears the inline transforms it would otherwise have written.
 */
@media (prefers-reduced-motion: reduce) {
  .follow__stage {
    aspect-ratio: auto;
    perspective: none;
    display: flex;
    flex-direction: column;
    gap: var(--s-5);
  }

  .swapcard {
    position: relative;
    inset: auto;
    inline-size: 100%;
    transform: none;
    will-change: auto;
    cursor: default;
    border-color: var(--c-line-strong);
    box-shadow: var(--shadow-card);
  }

  /* Every card is being read here, so every record and brief has to be
     legible. */
  .swapcard[data-front='false'] .swapcard__foot > *,
  .swapcard[data-front='false'] .swapcard__ph > * {
    opacity: 1;
  }
}


/* ==== assets/css/sections/explorer.css ==== */
/* =============================================================================
   Workflow explorer — the small diagnostic.

   Options on the left, a proposed shape on the right. Kept deliberately light:
   this is a conversation starter, not a lead-capture step.
   ========================================================================== */

/* Shares the sunk ground with the stack calculator: the two sections that are
   instruments rather than reading sit on the same surface. */
.explorer {
  background: var(--c-canvas-sunk);
}

.explorer__body {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
  gap: clamp(var(--s-6), 5vw, var(--s-9));
  align-items: start;
}

@media (max-width: 900px) {
  .explorer__body {
    grid-template-columns: minmax(0, 1fr);
  }
}

/*
 * The line list.
 *
 * Each row carries a single --effect between 0 and 1, written by line-list.js
 * from the cursor's vertical distance. Everything below derives from that one
 * value, which is what keeps the colour, the shift, the rule and the number
 * moving as one gesture instead of four transitions easing separately.
 *
 * The left gutter is the rule's runway: it has to be reserved in the layout,
 * because a rule that grew by pushing the text would relayout the column on
 * every frame.
 */
.explorer__options {
  display: flex;
  flex-direction: column;
  padding-inline-start: var(--rule-length, 56px);
}

/* Full-width rows rather than buttons in a grid, so the list reads like a set
   of honest admissions. */
.explorer__option {
  --effect: 0;
  --rule-length: 56px;
  position: relative;
  display: flex;
  align-items: baseline;
  gap: var(--s-4);
  inline-size: 100%;
  padding: var(--s-5) var(--s-3);
  text-align: start;
  border-block-end: var(--border);
  font-size: clamp(1rem, 1.5vw, 1.15rem);
  /* From muted toward the accent as the cursor arrives. */
  color: color-mix(
    in srgb,
    var(--c-accent-deep) calc(var(--effect) * 100%),
    var(--c-ink-soft)
  );
  /* Transform, not padding: the row slides toward the reader without the
     column relaying out beneath it. */
  transform: translateX(calc(var(--effect) * 14px));
  cursor: pointer;
}

/* A faint accent wash follows the cursor down the column, so the row under it
   looks pressable before it is pressed. */
.explorer__option:not([aria-checked='true']) {
  background: linear-gradient(
    to right,
    color-mix(in srgb, var(--c-accent-soft) calc(var(--effect) * 55%), transparent),
    transparent 70%
  );
}

.explorer__option:first-child {
  border-block-start: var(--border);
}

/* The rule reaching out of the gutter. It starts short and grows with
   proximity, so the column reads as a scale being tracked. */
.explorer__rule {
  position: absolute;
  inset-block-start: 50%;
  inset-inline-start: calc(-1 * var(--rule-length));
  inline-size: var(--rule-length);
  block-size: 1px;
  transform-origin: left center;
  transform: translateY(-50%) scaleX(calc(0.42 + var(--effect) * 0.58));
  background: color-mix(
    in srgb,
    var(--c-accent) calc(var(--effect) * 100%),
    var(--c-line-strong)
  );
}

/* A short static tick between rows, centred on the border. It gives the
   gutter a rhythm so the growing rule has something to measure against. */
.explorer__option:not(:last-child)::after {
  content: '';
  position: absolute;
  inset-block-end: -1px;
  inset-inline-start: calc(-1 * var(--rule-length));
  inline-size: calc(var(--rule-length) * 0.38);
  block-size: 1px;
  background: var(--c-line);
}

.explorer__index {
  flex: none;
  color: var(--c-muted);
  opacity: calc(0.5 + var(--effect) * 0.5);
}

.explorer__label {
  flex: 1;
}

@media (max-width: 620px) {
  /* The gutter is a luxury at phone width; the rule tucks in behind the row
     rather than eating a third of the line length. */
  .explorer__options {
    padding-inline-start: var(--s-5);
  }

  .explorer__option {
    --rule-length: 20px;
  }
}

@media (prefers-reduced-motion: reduce) {
  .explorer__option {
    transform: none;
  }
}

/* The chosen row. An accent fill behind it and a solid rule beside it, so the
   answer you picked is obvious from across the page rather than a shade of
   grey away from the ones you did not. */
.explorer__option[aria-checked='true'] {
  color: var(--c-accent-deep);
  font-weight: 500;
  background: linear-gradient(
    to right,
    var(--c-accent-soft),
    color-mix(in srgb, var(--c-accent-soft) 25%, transparent) 78%,
    transparent
  );
}

.explorer__option[aria-checked='true'] .explorer__index {
  color: var(--c-accent-deep);
  opacity: 1;
}

/* The arrow. This is what says the row is a control: a list of sentences with
   no affordance reads as copy, and nobody clicks copy. It is always present so
   the row is obviously clickable at rest, and slides in as you approach. */
.explorer__arrow {
  flex: none;
  margin-inline-start: auto;
  color: var(--c-accent);
  opacity: calc(0.3 + var(--effect) * 0.7);
  transform: translateX(calc(var(--effect) * 6px));
}

.explorer__option[aria-checked='true'] .explorer__arrow {
  opacity: 1;
}

/* ----------------------------------------------------------------- output */

.explorer__output {
  position: sticky;
  inset-block-start: calc(var(--strip-height, 44px) + 6rem);
  padding: clamp(var(--s-5), 3vw, var(--s-7));
  border: var(--border);
  border-radius: var(--r-lg);
  /* Raised off the section's sunk ground so the answer panel reads as the
     output of the control beside it. */
  background: var(--c-canvas);
  min-block-size: 22rem;
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
}

/* Content swaps are cross-faded by toggling this class, so the panel never
   flashes empty between selections. */
.explorer__content {
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
  opacity: 1;
  transform: none;
  transition:
    opacity var(--d-base) var(--e-out),
    transform var(--d-base) var(--e-out);
}

.explorer__content[data-swapping='true'] {
  opacity: 0;
  transform: translateY(6px);
}

.explorer__reading {
  font-size: var(--t-lead);
  line-height: 1.4;
  letter-spacing: -0.018em;
  text-wrap: pretty;
}

.explorer__proposal {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding-block-start: var(--s-5);
  border-block-start: var(--border);
}

.explorer__step {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: 0.65em 0.9em;
  border: 1px solid var(--c-line);
  border-radius: var(--r-sm);
  background: var(--c-canvas-sunk);
  font-size: var(--t-small);
  opacity: 0;
  transform: translateY(6px);
  animation: explorer-step-in var(--d-slow) var(--e-out) forwards;
  animation-delay: calc(var(--step-index, 0) * 90ms);
}

@keyframes explorer-step-in {
  to {
    opacity: 1;
    transform: none;
  }
}

.explorer__step-index {
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: 0.08em;
  color: var(--c-accent);
  flex: none;
}

.explorer__note {
  font-size: var(--t-small);
  color: var(--c-muted);
  text-wrap: pretty;
}

.explorer__cta {
  margin-block-start: auto;
  padding-block-start: var(--s-5);
  border-block-start: var(--border);
}

@media (max-width: 900px) {
  .explorer__output {
    position: static;
    min-block-size: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .explorer__step {
    animation: none;
    opacity: 1;
    transform: none;
  }
}


/* ==== assets/css/sections/approach.css ==== */
/* =============================================================================
   Approach + implementation.

   Three quieter, type-led sections. They share this file because they share one
   idea: numbered sequence as the composition, no cards.
   ========================================================================== */

/* --------------------------------------------------------------- approach */

.approach__stages {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s-7) var(--grid-gap);
  padding-block: var(--s-8);
}

.approach__stage {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  padding-block-start: var(--s-5);
  border-block-start: var(--border-strong);
}

.approach__stage .icon-well {
  margin-block-end: var(--s-2);
}

/* The numeral is the graphic. Set in the outline colour so it recedes behind
   the heading rather than competing with it. */
.approach__num {
  font-size: clamp(3rem, 6vw, 5.5rem);
  color: var(--c-line-strong);
}

@media (max-width: 860px) {
  .approach__stages {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* The miniature process map: tangled paths on the left resolving to parallel
   ones on the right, drawn as the section scrolls. */
.approach__map {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  padding-block-start: var(--s-6);
  border-block-start: var(--border);
  --progress: 0;
}

.approach__map svg {
  inline-size: 100%;
  block-size: auto;
}

.approach__path {
  fill: none;
  stroke: var(--c-line-strong);
  stroke-width: 1.25;
  stroke-dasharray: var(--len, 600);
  stroke-dashoffset: calc(var(--len, 600) * (1 - var(--progress)));
}

.approach__path--clean {
  stroke: var(--c-accent);
}

.approach__map-label {
  text-align: end;
}

/* --------------------------------------------------------- implementation */

.implementation__steps {
  display: flex;
  flex-direction: column;
  border-block-start: var(--border-strong);
}

/* A stepped indent: each row starts slightly further in than the last, so the
   list visibly narrows — the messy process becoming a clean one. */
.implementation__step {
  display: grid;
  grid-template-columns: 11rem minmax(0, 1fr);
  gap: var(--s-4) var(--s-6);
  align-items: baseline;
  padding-block: var(--s-6);
  border-block-end: var(--border);
  padding-inline-start: calc(var(--step-indent, 0) * 1.6rem);
  transition: padding-inline-start var(--d-slow) var(--e-out);
}

/* The icon and the step number travel together in the left column. */
.implementation__lead {
  display: flex;
  align-items: center;
  gap: var(--s-4);
}

.implementation__index {
  color: var(--c-accent);
}

.implementation__step .prose {
  grid-column: 2;
  max-inline-size: 54ch;
}

@media (max-width: 720px) {
  .implementation__step {
    grid-template-columns: minmax(0, 1fr);
    padding-inline-start: 0;
  }

  .implementation__step .prose {
    grid-column: 1;
  }
}


/* ==== assets/css/sections/economics.css ==== */
/* =============================================================================
   Economics — the bill, itemised.

   This section closes the argument, so it has to make one. What was here
   before did not: a grid of twenty-four abstract squares, twenty-two "proven"
   and two "custom", deliberately unlabelled because the copy beside it promised
   not to claim a percentage. It was artwork built to avoid saying something,
   which is the definition of decoration.

   The replacement says the thing directly, and says it in the object language
   the rest of the site already speaks: two bills, side by side. An agency's
   itemises itself line by line and only ONE of those lines is the build. Ours
   has four and all four are. No figure is claimed anywhere — the LENGTH of the
   two lists is the whole argument, which keeps the section's promise intact
   while making its point far harder than a percentage would.

   The ground moved off --c-tint-clay too. It was the only clay-grounded
   section on a page that otherwise runs green and canvas, which is why it read
   as belonging to a different site.
   ========================================================================== */

/* ------------------------------------------------------------------- bills */

.bills {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(var(--s-5), 3vw, var(--s-7));
  align-items: start;
  margin-block: var(--s-8);
}

/*
 * One bill. Deliberately the same object as the rail's records and the
 * follow section's cards: a head with a code, ruled rows, a foot that totals.
 * A reader who has come down the page has been reading this shape all along.
 */
.bill {
  display: flex;
  flex-direction: column;
  border: var(--border);
  border-radius: var(--r-md);
  background: var(--c-canvas);
  overflow: hidden;
}

/*
 * The agency's bill is the state of things, not the proposal, so it stays in
 * the page's plain register: no accent anywhere on it except the one row that
 * is actually the build. It is also the taller of the two by three rows, and
 * that height is doing the arguing, so nothing else on it should compete.
 */
.bill--via {
  border-color: var(--c-line);
  background: var(--c-canvas-sunk);
}

.bill--via .bill__head {
  background: var(--c-panel);
}

/* Ours is the one being argued for, so it is the one that is lit. */
.bill--direct {
  border-color: var(--c-accent);
}

.bill__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-4);
  padding: var(--s-4) var(--s-5);
  border-block-end: var(--border);
  background: var(--c-canvas-sunk);
}

.bill--direct .bill__head {
  border-block-end-color: var(--c-accent);
  background: var(--c-accent-soft);
}

.bill__who {
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
}

.bill--direct .bill__who {
  color: var(--c-accent);
}

/*
 * The running count.
 *
 * It counts LINES, not money — it ticks up as the rows land, so the number is
 * only ever a description of what is already on screen. That is what keeps it
 * on the right side of the section's promise: nothing here is a price.
 */
.bill__count {
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  color: var(--c-muted);
  font-variant-numeric: tabular-nums;
}

.bill--direct .bill__count {
  color: var(--c-accent);
}

.bill__lines {
  list-style: none;
  margin: 0;
  padding: 0;
  flex: 1;
}

/*
 * A ruled docket that gets written on, rather than rows that appear.
 *
 * The row itself — its rule, its height — is there from the start; only the
 * writing arrives. This matters more than it sounds: with the whole row fading
 * in, our bill sat as a large empty bordered box for the two seconds the
 * agency's took to itemise, and an empty box reads as something failing to
 * load. Two ruled dockets, one visibly shorter than the other, make the
 * argument the instant the section is on screen — and then ink themselves in.
 */
.bill__line {
  --in: 0;
  display: flex;
  align-items: baseline;
  gap: var(--s-3);
  padding: var(--s-3) var(--s-5);
  border-block-end: var(--border);
  font-size: var(--t-small);
  color: var(--c-muted);
}

.bill__label {
  opacity: var(--in);
  transform: translateX(calc((1 - var(--in)) * -0.4rem));
  transition:
    opacity var(--d-base) var(--e-out),
    transform var(--d-base) var(--e-out);
}

.bill__line:last-child {
  border-block-end: 0;
}

/*
 * The line that is actually the work.
 *
 * On the agency bill exactly one row carries this, and it is the last to land —
 * so the reader watches six lines of coordination stack up before the build
 * finally arrives. On our bill every row carries it. That contrast is the
 * argument, and it is made without a single number.
 */
.bill__line[data-build] {
  color: var(--c-ink);
  font-weight: 500;
}

.bill__line[data-build]::before {
  content: '';
  inline-size: 6px;
  block-size: 6px;
  flex: none;
  align-self: center;
  border-radius: 50%;
  background: var(--c-accent);
  transform: scale(var(--in));
  transition: transform var(--d-slow) var(--e-snap);
}

/* Coordination rows get the same slot, empty, so every label starts on one
   vertical line whether or not it is preceded by a dot. */
.bill__line:not([data-build])::before {
  content: '';
  inline-size: 6px;
  flex: none;
}

.bill__foot {
  --in: 0;
  padding: var(--s-4) var(--s-5);
  border-block-start: var(--border);
  background: var(--c-canvas-sunk);
}

/* Same reasoning as the rows: the totalling strip is part of the docket, the
   total written on it is not. */
.bill__foot > * {
  opacity: var(--in);
  transition: opacity var(--d-slow) var(--e-out);
}

.bill--direct .bill__foot {
  border-block-start-color: var(--c-accent);
  background: var(--c-accent-soft);
}

.bill__total {
  margin: 0;
  font-size: var(--t-small);
  color: var(--c-ink);
}

.bill--direct .bill__total {
  font-weight: 500;
}

/* --------------------------------------------------------------- signature */

/*
 * Three marks, not three faces.
 *
 * The photographs live on the About page and belong there; this is a section
 * about a model, and turning it into a team section would change the subject
 * at the exact moment the argument lands. Three marks say the same thing and
 * stay about the work.
 */
.bill__sign {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  margin: var(--s-3) 0 0;
  padding-block-start: var(--s-3);
  border-block-start: 1px solid var(--c-accent);
  font-size: var(--t-small);
  color: var(--c-muted);
}

.bill__marks {
  display: inline-flex;
  gap: 4px;
  flex: none;
}

.bill__marks i {
  inline-size: 7px;
  block-size: 7px;
  border-radius: 50%;
  background: var(--c-accent);
  transform: scale(var(--in, 1));
  transition: transform var(--d-slow) var(--e-snap);
  transition-delay: calc(var(--mark-i, 0) * 90ms);
}

/* ------------------------------------------------------------- the seven */

/*
 * The seven steps, short.
 *
 * They were a tall numbered list down the left of this section, which gave
 * seven lines of prominence to the method at the moment the reader wants the
 * conclusion. As a single run of chips they still say what the build is made
 * of, in a fifth of the height, under the argument rather than beside it.
 */
.seven {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
}

.seven__step {
  display: inline-flex;
  align-items: baseline;
  gap: var(--s-2);
  padding: var(--s-2) var(--s-4);
  border: var(--border);
  border-radius: var(--r-pill);
  background: var(--c-canvas);
  font-size: var(--t-small);
  color: var(--c-muted);
  white-space: nowrap;
}

.seven__step .mono {
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  color: var(--c-line-strong);
}

/* The last one is the handover, which is the only step the client experiences
   as an event rather than as work going on. */
.seven__step--end {
  border-color: var(--c-accent);
  color: var(--c-ink);
}

.seven__step--end .mono {
  color: var(--c-accent);
}

.economics__seven {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
  padding-block-start: var(--s-7);
  border-block-start: var(--border);
}

.economics__seven-title {
  margin: 0;
  color: var(--c-muted);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
}

/* --------------------------------------------------------------- closing */

.economics__notes {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: clamp(var(--s-6), 5vw, var(--s-9));
  align-items: start;
  margin-block-start: var(--s-8);
}

.economics__lines {
  list-style: none;
  margin: 0;
  padding: 0;
}

.economics__lines li {
  padding-block: var(--s-3);
  border-block-end: var(--border);
}

.economics__lines li:last-child {
  border-block-end: 0;
}

/* ---------------------------------------------------------------- narrower */

@media (max-width: 900px) {
  /*
   * The bills stack rather than shrink. Side by side under about 900px each
   * one is too narrow for its rows to hold a line, and a bill that wraps every
   * row is no longer a list of lines — which is the one thing this section
   * needs it to be.
   */
  .bills {
    grid-template-columns: minmax(0, 1fr);
  }

  .economics__notes {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-6);
  }
}

/* ---------------------------------------------------------- reduced motion */

/*
 * Everything present, nothing staged. The argument is the two lists next to
 * each other; the staging only controls the order they are read in, so with
 * motion off the reader simply gets both at once.
 */
@media (prefers-reduced-motion: reduce) {
  .bill__line,
  .bill__foot {
    --in: 1;
  }

  .bill__label,
  .bill__foot > * {
    transform: none;
    transition: none;
  }

  .bill__line[data-build]::before,
  .bill__marks i {
    transform: none;
    transition: none;
  }
}


/* ==== assets/css/sections/foundation.css ==== */
/* =============================================================================
   "The software underneath" — the Odoo section.

   The quietest section on the page. Previously it ran a very long paragraph
   down a narrow left column with the module chips stranded at the top right and
   a large dead area beneath — the layout gave a short aside the proportions of
   a major section. It is now a compact band: heading and copy on a comfortable
   measure, the chips directly under the copy they describe.
   ========================================================================== */

.foundation {
  background: var(--c-tint-green);
  padding-block: clamp(4rem, 7vw, 6.5rem);
}

.foundation__inner {
  display: grid;
  grid-template-columns: minmax(0, 0.62fr) minmax(0, 1fr);
  gap: var(--s-6) clamp(var(--s-7), 6vw, var(--s-10));
  align-items: start;
  max-inline-size: 78rem;
}

/* The eyebrow and heading sit in the left column as a title block; the copy and
   chips run down the right. That keeps the paragraph on a readable measure
   without leaving a column of empty space beside it. */
.foundation__lede {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

.foundation__body {
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
}

.foundation__body .prose {
  max-inline-size: 58ch;
}

.foundation__claim {
  font-size: var(--t-lead);
  line-height: 1.5;
  color: var(--c-ink);
  padding-inline-start: var(--s-5);
  border-inline-start: 2px solid var(--c-accent);
}

/* A quiet row of module labels — small, unemphasised, explicitly not a logo
   wall. Sits directly beneath the sentence that introduces it. */
/*
 * Eight fixed columns and a four-column fallback is what this was, and both
 * clipped: `minmax(0, 1fr)` lets a track shrink below its own content, and
 * MANUFACTURING set in uppercase mono at 0.08em needs about 7rem. At 1024px it
 * was losing 47px of itself, and at 320px, 48px.
 *
 * Flex-wrap rather than a grid, for two reasons a grid could not give at once.
 * A flex item's automatic minimum is its min-content, so a label can never be
 * clipped again whatever the container does — the row wraps instead. And the
 * items grow, so every row fills completely: eight blocks across a grid's
 * auto-fit tracks came out five and three with two empty cells trailing, which
 * is a poor look for something captioned as a layer.
 */
.foundation__blocks {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
}

.foundation__block {
  /* Grow into the row, from a basis wide enough for the longest label. */
  flex: 1 1 7.25rem;
  display: grid;
  place-items: center;
  padding: 0.7em 0.4em;
  text-align: center;
  background: var(--c-canvas);
  border: 1px solid var(--c-line);
  border-radius: var(--r-sm);
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--c-ink-soft);
  transition:
    border-color var(--d-base) var(--e-in-out),
    color var(--d-base) var(--e-in-out),
    transform var(--d-fast) var(--e-out);
}

.foundation__block:hover {
  border-color: var(--c-accent);
  color: var(--c-accent-deep);
  transform: translateY(-2px);
}

/* The source line. Not mono-caps: it is a sentence, and setting a sentence in
   letter-spaced uppercase made it harder to read than the claim it supports. */
.foundation__source {
  font-size: var(--t-small);
  color: var(--c-muted);
}

.foundation__source a {
  color: var(--c-accent-deep);
}

@media (max-width: 900px) {
  .foundation__inner {
    grid-template-columns: minmax(0, 1fr);
  }

  .foundation__lede {
    position: static;
  }
}


/* =============================================================================
   The layer stack.

   Your workflow on top, ToolsSync in the middle, the Odoo blocks underneath —
   which is exactly what the copy either side of it says, drawn as a section
   through the thing rather than asserted in a sentence.
   ========================================================================== */

.layers {
  display: flex;
  flex-direction: column;
}

.layers__tier {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  gap: var(--s-4);
  align-items: center;
  padding: var(--s-4) var(--s-5);
  border: 1px solid var(--c-line-strong);
}

/* The top tier is the business itself: darkest, because it is the fixed point
   the other two have to fit around. */
.layers__tier--top {
  background: var(--c-ink);
  border-color: var(--c-ink);
  color: var(--c-canvas);
}

.layers__tier--mid {
  background: var(--c-accent-soft);
  border-color: var(--c-accent);
}

.layers__num {
  color: var(--c-muted);
}

.layers__tier--top .layers__num,
.layers__tier--top .layers__note {
  color: var(--c-dark-muted);
}

.layers__tier--mid .layers__num,
.layers__tier--mid .layers__note {
  color: var(--c-accent-deep);
}

.layers__name {
  font-size: var(--t-small);
  font-weight: 550;
  letter-spacing: -0.02em;
}

.layers__join {
  display: flex;
  align-items: center;
  gap: var(--s-3);
  padding: var(--s-2) 0 var(--s-2) var(--s-6);
  color: var(--c-accent-deep);
}

.layers__join--quiet {
  color: var(--c-muted);
}

.layers__arrow {
  inline-size: 11px;
  block-size: 16px;
  flex: none;
}

.layers__base {
  padding: var(--s-4);
  border: 1px solid var(--c-line-strong);
  background: var(--c-canvas-sunk);
}

.layers__base-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-4);
  margin-block-end: var(--s-3);
  color: var(--c-muted);
}

@media (max-width: 620px) {
  .layers__tier {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-1);
  }
}

/* =============================================================================
   Interface miniatures.

   What a "building block" looks like in practice, at a size where it reads as a
   thumbnail rather than as a component you are meant to use. Drawn rather than
   screenshotted: Odoo's interface is theirs.
   ========================================================================== */

.mini {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  margin-block-start: var(--s-5);
}

.mini__panel {
  border: 1px solid var(--c-line-strong);
  border-radius: var(--r-sm);
  overflow: hidden;
  background: var(--c-canvas);
}

.mini__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  padding: var(--s-2) var(--s-3);
  background: var(--c-ink);
  color: var(--c-dark-muted);
}

.mini__head .mono {
  font-size: var(--t-nano);
}

/* Gridlines as gaps rather than borders — no doubling along shared edges. */
.mini__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  gap: 1px;
  background: var(--c-line);
}

.mini__cell {
  padding: 0.35em 0.6em;
  background: var(--c-canvas);
  font-size: var(--t-small);
  color: var(--c-ink-soft);
}

.mini__cell--head {
  background: var(--c-canvas-sunk);
  color: var(--c-muted);
  font-size: var(--t-nano);
}

.mini__cell--n {
  text-align: end;
  font-variant-numeric: tabular-nums;
}

.mini__cell--good {
  color: var(--c-accent-deep);
}

.mini__cell--short {
  color: var(--c-clay-deep);
}

.mini__pair {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--s-3);
}

.mini__card {
  display: flex;
  flex-direction: column;
  gap: var(--s-1);
  padding: var(--s-3);
  border: 1px solid var(--c-line-strong);
  border-radius: var(--r-sm);
  background: var(--c-canvas);
}

.mini__card .mono {
  font-size: var(--t-nano);
  margin-block-end: var(--s-1);
}

.mini__row {
  display: flex;
  justify-content: space-between;
  gap: var(--s-3);
  font-size: var(--t-small);
  color: var(--c-muted);
}

.mini__row--total {
  padding-block-start: var(--s-1);
  border-block-start: 1px dotted var(--c-line);
  color: var(--c-ink);
}

.mini__row--total .mini__n {
  color: var(--c-accent-deep);
  font-weight: 600;
}

.mini__n {
  color: var(--c-ink);
  font-variant-numeric: tabular-nums;
}

/* A barcode drawn as a repeating gradient — no image, no generator. */
.mini__bars {
  block-size: 1.35rem;
  margin-block: var(--s-1);
  background-image: repeating-linear-gradient(
    90deg,
    var(--c-ink) 0 2px,
    transparent 2px 4px,
    var(--c-ink) 4px 5px,
    transparent 5px 8px,
    var(--c-ink) 8px 11px,
    transparent 11px 13px
  );
}

.mini__code {
  font-size: var(--t-nano);
  letter-spacing: 0.12em;
  color: var(--c-ink);
}

.mini__caption {
  font-size: var(--t-nano);
  color: var(--c-muted);
}

@media (max-width: 900px) {
  .mini {
    max-inline-size: 26rem;
  }
}


/* ==== assets/css/sections/close.css ==== */
/* =============================================================================
   Closing section — the designed conclusion.

   Dark, full-bleed, with connector lines converging toward the centre from the
   edges. The form is the only lit object.
   ========================================================================== */

.close {
  position: relative;
  background: var(--c-canvas);
  color: var(--c-ink);
  padding-block: clamp(var(--s-9), 12vw, 11rem);
  overflow: clip;
  --progress: 0;
}

.close__field {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.close__wires {
  inline-size: 100%;
  block-size: 100%;
}

/* Lines are drawn inward as the section arrives — the whole page's argument,
   restated without words. */
.close__wire {
  fill: none;
  stroke: var(--c-accent);
  stroke-width: 1;
  stroke-dasharray: var(--len, 600);
  stroke-dashoffset: calc(var(--len, 600) * (1 - var(--progress)));
  opacity: 0.28;
}

.close__inner {
  position: relative;
  z-index: var(--z-raised);
  display: grid;
  grid-template-columns: minmax(0, 0.95fr) minmax(0, 1.05fr);
  gap: clamp(var(--s-7), 6vw, var(--s-10));
  align-items: start;
}

@media (max-width: 900px) {
  .close__inner {
    grid-template-columns: minmax(0, 1fr);
  }
}

.close__lead {
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
}

.close__title {
  letter-spacing: -0.05em;
}

.close__form {
  display: flex;
  flex-direction: column;
  gap: var(--s-7);
  padding: clamp(var(--s-5), 3vw, var(--s-7));
  border: 1px solid var(--c-line);
  border-radius: var(--r-lg);
  background: var(--c-canvas-sunk);
}

.close__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-4);
}

/*
 * Once the two actions no longer fit side by side they stop being content-width
 * and take the column instead.
 *
 * Wrapped but still sized to their labels, the submit button came out 296px
 * against the 304px fields directly above it -- close enough to read as a
 * misalignment rather than a decision. Stretching them makes the button share
 * the fields' edges exactly, which is what the eye is checking against.
 */
@media (max-width: 560px) {
  .close__actions {
    flex-direction: column;
    align-items: stretch;
  }

  .close__actions .btn {
    justify-content: center;
    /* .btn is nowrap by default, which on a 320px screen makes this label wider
       than the column it is stretched into -- the button overhung the form and
       lost its arrow to the section's clip. Stacked, it may take two lines. */
    white-space: normal;
    text-align: center;
  }
}


/* =============================================================================
   The close section as its own page.

   Same composition, one difference: arriving here directly, the section is the
   whole page rather than the end of one, so it has to fill the window and
   carry a little more reassurance than a closing line needs.
   ========================================================================== */

.close--page {
  display: flex;
  align-items: center;
  min-block-size: calc(100vh - var(--pin-top) - 6rem);
}

/* Three short promises about the call itself. On the home page the reader has
   just scrolled the whole argument and needs none of this; arriving cold from
   a nav link or the coupon, they have not, and do. */
.close__points {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  margin-block-start: var(--s-6);
  padding-block-start: var(--s-5);
  border-block-start: 1px solid var(--c-dark-line);
  font-size: var(--t-small);
  color: var(--c-dark-muted);
}

.close__points li {
  display: flex;
  gap: var(--s-3);
}

/* A short accent rule instead of a bullet: the same gesture the eyebrows use,
   so the list belongs to the page rather than to a default stylesheet. */
.close__points li::before {
  content: '';
  flex: none;
  inline-size: 0.75rem;
  block-size: 1px;
  margin-block-start: 0.7em;
  background: var(--c-accent-on-dark);
}


/* ==== assets/css/sections/storefront.css ==== */
/* =============================================================================
   Storefronts — the turn.

   One pinned section, two acts, driven by a single `--progress` that
   storefront.js writes as the section crosses the pin. Everything below reads
   that number; nothing here animates on a timer, so the reader is always
   holding the thing still.

     --act-turn   0 → 1 across the first half: the sentence changes one word
     --act-build  0 → 1 across the second: a shop assembles and hands over

   Both are written by JS rather than derived in calc, because a sub-range
   clamped in CSS has to be typed with @property to be reliable and the two
   numbers are wanted in JS anyway for the counter.

   The dark ground is doing structural work, not decoration: nine light
   sections run between #transform and #contact, and this breaks that stretch
   in the place where the page changes subject.
   ========================================================================== */

.storefront {
  padding-block: 0;
  position: relative;
}

/*
 * The runway.
 *
 * Height here rather than on the section: the section also holds the list that
 * follows, and a min-height on the whole thing put its spare 1800px BELOW that
 * list rather than behind the pin — so the list appeared on screen while the
 * pin was still running, and the section ended with an empty screen and a half.
 *
 * Tall enough that both acts get room, short enough that a reader who is not
 * interested is past it in three flicks.
 */
.storefront__scroll {
  block-size: 300vh;
  position: relative;
}

.storefront__pin {
  position: sticky;
  inset-block-start: 0;
  block-size: 100vh;
  display: grid;
  align-items: center;
  overflow: clip;
}

.storefront__stage {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  align-items: center;
  gap: clamp(var(--s-6), 5vw, var(--s-9));
  inline-size: min(100% - (var(--grid-gutter) * 2), var(--grid-max));
  margin-inline: auto;
}

/* ------------------------------------------------------------ act 1 · type */

/*
 * The sentence is the argument, so it is set at display size and given the
 * whole left column. One word changes and the claim changes with it — "before
 * an order exists" is a true description of the other half of the business
 * rather than a headline written to sound like one.
 */
.storefront__line {
  font-size: clamp(2rem, 4.2vw, 3.6rem);
  line-height: 1.08;
  letter-spacing: -0.035em;
  font-weight: 500;
  margin: 0;
  text-wrap: balance;
}

.storefront__said {
  opacity: calc(0.45 + var(--act-turn, 0) * 0.55);
  transition: opacity var(--d-slow) var(--e-out);
}

/*
 * A one-line window onto a two-line reel. The reel travels exactly half its own
 * height, which puts the second word where the first was — the words must be
 * stacked in flow for that to hold, not layered in one grid cell, or the
 * translate takes both of them off screen together.
 */
.storefront__slot {
  position: relative;
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
  /* One line, plus the descender room the overflow would otherwise cut off. */
  block-size: 1.08em;
}

/*
 * A hidden copy of the longer word, in flow, giving the slot its width.
 *
 * A width in `ch` cannot serve both: 3.4ch left a gap after "after" and cut
 * "before" down to "befoı". Letting the longer word measure itself is exact at
 * every size, and survives a font swap.
 */
.storefront__sizer {
  visibility: hidden;
}

.storefront__reel {
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  display: grid;
  /* Held until the strike lands, then travels the rest of the act. */
  --roll: clamp(0, calc((var(--act-turn, 0) - 0.5) * 2), 1);
  translate: 0 calc(var(--roll) * -50%);
}

.storefront__word {
  color: var(--c-accent);
  white-space: nowrap;
  line-height: 1.08;
  /* Each word keeps its own width. Stretched to the grid column — which the
     sizer holds at the width of the longer one — the strike below ran a full
     letter past the end of "after". */
  justify-self: start;
}

/* Struck through as it leaves, so the reader sees the claim being corrected
   rather than a word simply swapping. */
.storefront__word--out {
  background: linear-gradient(currentColor, currentColor) left center / var(--strike) 0.07em no-repeat;
  /* Drawn across the word over the first 45% of the act, and finished before
     the reel starts to move — struck THEN replaced. Overlapping the two put a
     fully struck word and its replacement on screen together, which reads as
     two words rather than one correction. */
  --strike: clamp(0%, calc(var(--act-turn, 0) * 2.2 * 100%), 100%);
}

.storefront__word--in {
  opacity: clamp(0, calc((var(--act-turn, 0) - 0.5) * 2), 1);
}

.storefront__say {
  min-inline-size: 0;
}

.storefront__sub {
  margin: var(--s-5) 0 0;
  color: var(--c-muted);
  font-size: var(--t-lead);
  opacity: var(--act-turn, 0);
  translate: 0 calc((1 - var(--act-turn, 0)) * 0.75rem);
}

/* ----------------------------------------------------------- act 2 · build */

.storefront__build {
  position: relative;
  opacity: calc(0.15 + var(--act-build, 0) * 0.85);
}

/* The frame lifts very slightly as it fills, so the assembly reads as one
   object arriving rather than parts appearing on a flat plane. */
.shopframe--live {
  scale: calc(0.965 + var(--act-build, 0) * 0.035);
  transform-origin: 50% 40%;
}

/* Typed, by revealing the text rather than rewriting it — the string stays
   whole and selectable, and there is no per-frame DOM work. */
.shopframe__typed {
  display: inline-block;
  overflow: hidden;
  white-space: nowrap;
  vertical-align: bottom;
  inline-size: calc(var(--type, 0) * 100%);
  border-inline-end: 1px solid var(--c-accent);
  --type: clamp(0, calc(var(--act-build, 0) * 4), 1);
}

.shopframe--live .shopframe__banner {
  opacity: clamp(0, calc((var(--act-build, 0) - 0.2) * 5), 1);
  scale: 1 clamp(0.2, calc((var(--act-build, 0) - 0.2) * 5), 1);
  transform-origin: 50% 0;
}

/*
 * Tiles drop in sequence. --tile is the index, set in the markup, so the
 * stagger is a property of the element rather than three near-identical rules.
 */
.shopframe--live .shopframe__card {
  --start: calc(0.34 + var(--tile) * 0.09);
  --t: clamp(0, calc((var(--act-build, 0) - var(--start)) * 7), 1);
  opacity: var(--t);
  translate: 0 calc((1 - var(--t)) * 1.25rem);
}

.shopframe--live .shopframe__stock {
  opacity: clamp(0, calc((var(--act-build, 0) - 0.62) * 6), 1);
}

/* --------------------------------------------------------- the handoff */

/*
 * The point of the whole section: the order does not arrive as an email, it
 * arrives in the record. The wire draws first, the record lands after it — in
 * that order, because the connection is the claim and the record is the proof.
 */
.storefront__handoff {
  display: flex;
  align-items: center;
  gap: var(--s-4);
  margin-block-start: var(--s-5);
  padding-inline-start: var(--s-6);
}

.storefront__wire {
  flex: none;
  block-size: 1px;
  inline-size: clamp(0px, calc((var(--act-build, 0) - 0.72) * 12 * 3rem), 3rem);
  background: var(--c-accent);
}

.storefront__record {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--s-3) var(--s-4);
  border: 1px solid var(--c-accent);
  border-radius: var(--r-sm);
  background: var(--c-accent-soft);
  --in: clamp(0, calc((var(--act-build, 0) - 0.8) * 8), 1);
  opacity: var(--in);
  translate: calc((1 - var(--in)) * -0.75rem) 0;
}

.storefront__record-code {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  color: var(--c-accent);
}

.storefront__record-note {
  font-size: var(--t-small);
  color: var(--c-ink-soft);
}

/* ------------------------------------------------------------- after the pin */

.storefront__after {
  padding-block: var(--section-y);
}

.storefront__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s-5) var(--s-6);
}

.storefront__item {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding-block-start: var(--s-4);
  border-block-start: var(--border-strong);
}

/*
 * The marks.
 *
 * Not icons. Each one is the thing it names, drawn from the same three shapes
 * the frame above is built from — a product grid, a page with a heading, two
 * turns of a conversation. A row of small pictograms above three headings is
 * the most generic move available on a page like this, and these are at least
 * made of the page's own vocabulary.
 */
.storefront__mark {
  display: flex;
  gap: 3px;
  block-size: 22px;
  align-items: flex-end;
  margin-block-end: var(--s-2);
}

.storefront__mark span {
  background: var(--c-accent);
  border-radius: 2px;
}

/* Three tiles of equal weight: a catalogue. */
.storefront__mark--shop span {
  inline-size: 11px;
  block-size: 14px;
}

.storefront__mark--shop span:first-child { block-size: 20px; }

/* A page: one heading rule over two shorter lines of text. */
.storefront__mark--page {
  flex-direction: column;
  align-items: flex-start;
  gap: 3px;
  justify-content: flex-end;
}

.storefront__mark--page span {
  block-size: 3px;
  inline-size: 30px;
}

.storefront__mark--page span:first-child {
  block-size: 6px;
  inline-size: 18px;
}

.storefront__mark--page span:last-child {
  inline-size: 22px;
  opacity: 0.5;
}

/* Two turns of a conversation, the reply set back. */
.storefront__mark--talk {
  flex-direction: column;
  align-items: flex-start;
  gap: 3px;
}

.storefront__mark--talk span {
  block-size: 8px;
  inline-size: 22px;
  border-end-start-radius: 1px;
}

.storefront__mark--talk span:last-child {
  inline-size: 14px;
  margin-inline-start: 8px;
  opacity: 0.5;
  border-end-start-radius: 2px;
  border-end-end-radius: 1px;
}

.storefront__name {
  font-size: var(--t-lead);
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.2;
  margin: 0;
}

.storefront__note {
  color: var(--c-ink-soft);
  margin: 0;
}

/* Said plainly and once. The detail belongs on the page, when there is any. */
.storefront__wip {
  color: var(--c-muted);
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  white-space: nowrap;
}

.storefront__action {
  margin-block-start: var(--s-7);
}

/* -------------------------------------------------------------- narrow */

@media (max-width: 940px) {
  .storefront__scroll {
    block-size: 260vh;
  }

  .storefront__stage {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-6);
    align-content: center;
  }

  /* The frame is the second act and must not crowd the sentence off a phone
     screen; it takes what is left after the type has had its room. */
  .storefront__build {
    max-inline-size: 26rem;
  }

  .storefront__list {
    grid-template-columns: minmax(0, 1fr);
  }
}

@media (max-width: 560px) {
  .storefront__line {
    font-size: clamp(1.6rem, 7.5vw, 2.2rem);
  }

  .storefront__handoff {
    padding-inline-start: var(--s-4);
  }
}

/* ----------------------------------------------------------- reduced motion */

/*
 * With motion reduced the pin is dead weight: there is no scrubbing to do, so
 * the section becomes an ordinary one and shows the finished state. trackProgress
 * already pins --progress at 1 for these readers; this collapses the height so
 * they are not asked to scroll three screens past a static picture.
 */
@media (prefers-reduced-motion: reduce) {
  .storefront__scroll {
    block-size: auto;
  }

  .storefront__pin {
    position: static;
    block-size: auto;
    padding-block: var(--section-y);
  }

  .storefront__word--out {
    display: none;
  }

  .shopframe__typed {
    inline-size: auto;
    border-inline-end: 0;
  }
}


/* ==== assets/css/sections/page.css ==== */
/* =============================================================================
   Secondary pages: /about/, /privacy/, and 404.

   The home page is a narrative — every section has its own composition and
   earns its own stylesheet. These pages are the opposite: they exist to be
   READ, or to get someone unstuck. One column, generous measure, no motion
   that competes with the text.

   Everything here reuses the home page's tokens, so a change to the type scale
   or the palette reaches these pages without anyone remembering to come back.
   ========================================================================== */

/* --------------------------------------------------------------- page head */

.page-head {
  padding-block: var(--s-9) var(--s-7);
  border-block-end: var(--border-strong);
}

.page-head__lead {
  max-inline-size: 46rem;
  margin-block-start: var(--s-5);
}

/* Reading measure. Long-form legal and narrative text needs a shorter line
   than the marketing sections, which are deliberately wide and sparse. */
.page-body {
  padding-block: var(--s-8);
}

.page-prose {
  max-inline-size: 42rem;
}

.page-prose > * + * {
  margin-block-start: var(--s-5);
}

.page-prose h2 {
  font-size: var(--t-headline);
  line-height: 1.2;
  margin-block-start: var(--s-8);
}

.page-prose h2:first-child {
  margin-block-start: 0;
}

.page-prose h3 {
  font-size: var(--t-small);
  font-weight: 600;
  margin-block-start: var(--s-6);
}

.page-prose ul,
.page-prose ol {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  padding-inline-start: var(--s-5);
}

.page-prose li {
  padding-inline-start: var(--s-2);
}

.page-prose ul li {
  list-style: square;
}

.page-prose ol li {
  list-style: decimal;
}

/* A definition row: the term on the left, what it means on the right. Used for
   the data table in the privacy policy, where a plain list would bury the
   thing people actually scan for. */
.page-defs {
  display: grid;
  gap: 0;
  margin-block-start: var(--s-5);
  border-block-start: var(--border);
}

.page-defs__row {
  display: grid;
  grid-template-columns: minmax(0, 0.42fr) minmax(0, 1fr);
  gap: var(--s-4) var(--s-6);
  padding-block: var(--s-4);
  border-block-end: var(--border);
}

.page-defs__term {
  color: var(--c-ink);
  font-weight: 500;
}

.page-defs__value {
  color: var(--c-muted);
}

@media (max-width: 700px) {
  .page-defs__row {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-2);
  }
}

/* ------------------------------------------------------------ legal clauses */

/*
 * A numbered clause, with the number hanging in the margin.
 *
 * The privacy policy cross-references itself by number — "addressed at clause
 * 3", "the rights set out at clause 11" — so the numbers have to be findable
 * by eye rather than buried at the start of a paragraph. An <ol> cannot do
 * this: the numbering is two-level and fixed by the document, not by position,
 * so it is written out and the marker is a real element.
 */
.clause {
  display: grid;
  grid-template-columns: 3.25rem minmax(0, 1fr);
  gap: var(--s-4);
  align-items: baseline;
}

.clause__n {
  color: var(--c-muted);
  font-family: var(--font-mono);
  font-size: 0.86em;
  font-variant-numeric: tabular-nums;
}

/* A clause whose body is a list or a table rather than a sentence: the number
   still hangs, but the content sets its own rhythm. */
.clause__body > * + * {
  margin-block-start: var(--s-4);
}

/* Sub-points inside a clause are lettered in the source, (a) to (g). */
.page-prose ol.alpha li {
  list-style: lower-alpha;
}

@media (max-width: 700px) {
  .clause {
    grid-template-columns: 2.5rem minmax(0, 1fr);
    gap: var(--s-3);
  }
}

/* -------------------------------------------------------- unresolved slots */

/*
 * A fact nobody has supplied yet.
 *
 * Deliberately loud. The rest of this project marks missing IMAGES with a
 * dashed box you cannot miss; missing FACTS on a legal page deserve the same
 * treatment, because the failure mode — publishing a privacy policy that says
 * "[company name]" — is worse than a missing photograph and much easier to
 * skim past. tools/audit.py counts these and prints them, so they cannot be
 * forgotten quietly.
 */
.todo {
  display: inline;
  padding: 0.1em 0.45em;
  border: 1px dashed var(--c-clay);
  border-radius: var(--r-xs);
  background: color-mix(in srgb, var(--c-clay) 12%, transparent);
  color: var(--c-clay);
  font-family: var(--font-mono);
  font-size: 0.86em;
  font-style: normal;
}

/* ------------------------------------------------------------------- team */

.team {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: var(--s-6);
  margin-block-start: var(--s-7);
}

.team__card {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

.team__portrait {
  aspect-ratio: 4 / 5;
  border-radius: var(--r-md);
  overflow: hidden;
  background: var(--c-panel);
}

.team__portrait img {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
}

.team__name {
  font-size: var(--t-headline);
  line-height: 1.15;
}

.team__role {
  color: var(--c-accent-deep);
}

.team__note {
  color: var(--c-muted);
  font-size: var(--t-small);
}

/* ------------------------------------------------------------------ 404 */

/*
 * The dark ground is the same one the closing section uses, so a wrong URL
 * still lands somewhere that looks like the site rather than like a server.
 */
.notfound {
  display: flex;
  align-items: center;
  min-block-size: calc(100vh - var(--pin-top) - 12rem);
  padding-block: var(--s-9);
}

/* No `inline-size` here. This element also carries .shell, which sets its own
   width as `min(100% - gutters, max)` — restating the width wiped out both the
   gutter and the centring, and the whole page sat flush against the viewport
   edge. */
.notfound__inner {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 0.85fr);
  gap: var(--s-8) clamp(var(--s-6), 6vw, var(--s-9));
  align-items: center;
}

.notfound__code {
  font-family: var(--font-mono);
  color: var(--c-accent);
}

.notfound__lead {
  margin-block-start: var(--s-5);
  max-inline-size: 34rem;
}

.notfound__links {
  display: flex;
  flex-direction: column;
  gap: 0;
  margin-block-start: var(--s-7);
  border-block-start: var(--border-strong);
}

.notfound__link {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-4);
  padding-block: var(--s-4);
  border-block-end: var(--border);
  text-decoration: none;
  color: inherit;
  transition: padding-inline-start var(--d-base) var(--e-out);
}

.notfound__link:hover,
.notfound__link:focus-visible {
  padding-inline-start: var(--s-3);
}

.notfound__link .btn__arrow {
  color: var(--c-accent);
}

/* The broken wire: the hero's convergence, failing. Two segments reach for
   each other across a gap that never closes — the page's own visual language
   saying "this connection does not exist" without a word of copy. */
.notfound__art {
  inline-size: 100%;
  block-size: auto;
  max-block-size: 20rem;
}

.notfound__wire {
  stroke: var(--c-accent);
  stroke-width: 1.5;
  stroke-linecap: round;
  fill: none;
  opacity: 0.55;
  stroke-dasharray: var(--len, 200);
  stroke-dashoffset: var(--len, 200);
  animation: notfound-draw 1400ms var(--e-out) forwards;
  animation-delay: var(--at, 0ms);
}

.notfound__node {
  fill: var(--c-accent);
  opacity: 0;
  animation: notfound-arrive 600ms var(--e-out) forwards;
  animation-delay: var(--at, 0ms);
}

/* The gap between the two reaching ends. It pulses, and never closes. */
.notfound__gap {
  fill: none;
  stroke: var(--c-clay);
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-dasharray: 2 7;
  opacity: 0;
  animation: notfound-gap 2600ms var(--e-in-out) 1500ms infinite;
}

@keyframes notfound-draw {
  to {
    stroke-dashoffset: 0;
  }
}

@keyframes notfound-arrive {
  to {
    opacity: 1;
  }
}

@keyframes notfound-gap {
  0%,
  100% {
    opacity: 0.25;
  }
  50% {
    opacity: 0.85;
  }
}

@media (max-width: 900px) {
  .notfound__inner {
    grid-template-columns: minmax(0, 1fr);
  }

  /* The artwork is the first thing that goes: on a phone the reader wants the
     links, not a diagram about their absence. */
  .notfound__art {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .notfound__wire {
    stroke-dashoffset: 0;
    animation: none;
  }

  .notfound__node {
    opacity: 1;
    animation: none;
  }

  .notfound__gap {
    opacity: 0.55;
    animation: none;
  }
}


/* ==== assets/css/sections/about.css ==== */
/* =============================================================================
   The about page.

   Dark end to end, which is the one place on this site a whole page commits to
   the ink ground. Everywhere else the dark bands are punctuation between light
   sections; here it IS the page, so the rhythm has to come from spacing, rule
   weight and type scale rather than from ground changes.

   Every colour below resolves through the tokens that [data-ground="dark"]
   re-points on the wrapper, so there is not one dark-specific literal in here.
   ========================================================================== */

.about {
  background: var(--c-dark);
  color: var(--c-dark-ink);
}

/* A hairline between bands. Enough to keep the page from reading as one long
   scroll of black, quiet enough not to become a table. */
.about > .section + .section {
  border-block-start: 1px solid var(--c-line);
}

/* ------------------------------------------------------------------- hero */

.about__hero {
  padding-block: clamp(4rem, 11vw, 9rem) clamp(3rem, 7vw, 5.5rem);
}

.about__title {
  max-inline-size: 18ch;
  margin-block-start: var(--s-5);
}

.about__lead {
  max-inline-size: 46ch;
  margin-block-start: var(--s-6);
  color: var(--c-ink-soft);
}

/* ----------------------------------------------------------------- origin */

/*
 * Per-band rhythm.
 *
 * On a page that never changes ground, spacing is the only thing separating one
 * argument from the next — so each band gets its own breathing room rather than
 * inheriting one --section-y for all of them. The story and the refusals get
 * more air than the grids, because they are read rather than scanned.
 */
.about__origin {
  padding-block: clamp(3.5rem, 8vw, 6rem);
}

.about__values,
.about__team {
  padding-block: clamp(3rem, 7vw, 5rem);
}

.about__refuse {
  padding-block: clamp(3.5rem, 8vw, 6rem);
}


.about__split {
  display: grid;
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1fr);
  gap: var(--s-7) clamp(var(--s-6), 6vw, var(--s-9));
  align-items: start;
}

.about__prose {
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
}

@media (max-width: 900px) {
  .about__split {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* ---------------------------------------------------------------- mission */

/*
 * One sentence given a whole band. It is set at display size and NOT as a
 * heading, because it is the page's claim rather than a label for what follows
 * it — making it an <h2> would put a promise in the document outline where a
 * section title belongs.
 */
.about__mission {
  padding-block: clamp(4rem, 9vw, 7rem);
  background: var(--c-dark-raised);
}

.about__mission-label {
  color: var(--c-accent);
}

.about__mission-line {
  margin-block-start: var(--s-5);
  max-inline-size: 22ch;
  font-size: var(--t-title);
  font-weight: 500;
  line-height: 1.08;
  letter-spacing: -0.03em;
  text-wrap: balance;
}

/* ----------------------------------------------------------------- values */

.values {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
  gap: 1px;
  margin-block-start: var(--s-7);
  /* One gap-coloured background behind a grid of solid cells: the dividing
     lines are the gaps themselves, so there are no borders to double up where
     cells meet. */
  background: var(--c-line);
  border-block: 1px solid var(--c-line);
}

.values__item {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  padding: var(--s-6) var(--s-6) var(--s-7);
  background: var(--c-dark);
}

.values__num {
  font-size: clamp(2.2rem, 4vw, 3rem);
  line-height: 1;
  color: var(--c-line-strong);
}

.values__name {
  color: var(--c-accent);
}

.values__body {
  color: var(--c-ink-soft);
  font-size: var(--t-small);
}

/* ------------------------------------------------------------------- team */

.crew {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: clamp(var(--s-5), 3vw, var(--s-7));
  margin-block-start: var(--s-7);
}

.crew__card {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

.crew__portrait {
  aspect-ratio: 4 / 5;
  border-radius: var(--r-md);
  overflow: hidden;
  background: var(--c-dark-raised);
}

.crew__portrait img {
  inline-size: 100%;
  block-size: 100%;
  object-fit: cover;
}

/* Just the name now. The rule above it does the separating that a role line
   used to. */
.crew__meta {
  padding-block-start: var(--s-4);
  border-block-start: 1px solid var(--c-line-strong);
}

.crew__name {
  font-size: var(--t-headline);
  line-height: 1.15;
  letter-spacing: -0.02em;
}

/* ----------------------------------------------------------------- refuse */

.refuse {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr));
  gap: var(--s-6) clamp(var(--s-6), 5vw, var(--s-8));
  margin-block-start: var(--s-7);
}

.refuse__item {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  padding-block-start: var(--s-4);
  border-block-start: 1px solid var(--c-line-strong);
}

/* A struck-through rule rather than a bullet: the list is things that do not
   happen, and the mark says so before the words do. */
.refuse__name {
  position: relative;
  font-size: 1.02rem;
  font-weight: 600;
  letter-spacing: -0.015em;
  padding-inline-start: 1.6rem;
}

.refuse__name::before {
  content: '';
  position: absolute;
  inset-inline-start: 0;
  inset-block-start: 0.62em;
  inline-size: 0.9rem;
  block-size: 1px;
  background: var(--c-clay);
}

.refuse__body {
  color: var(--c-muted);
  font-size: var(--t-small);
  padding-inline-start: 1.6rem;
}

/* -------------------------------------------------------------------- cta */

.about__cta {
  padding-block: clamp(3.5rem, 8vw, 6rem);
  background: var(--c-dark-raised);
}

.about__cta-inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-6);
}

.about__cta .prose {
  max-inline-size: 40ch;
  color: var(--c-ink-soft);
  margin-block-start: var(--s-4);
}

/* =============================================================================
   The About page, animated.

   The page was type on ink end to end: correct, and inert. Everything below
   exists to give it movement WITHOUT giving it decoration — each piece draws
   something the adjacent sentence already claims, so if you removed the words
   the pictures would still say it, and if you removed the pictures nothing
   would be lost but the life.

   All of it is scroll-driven or pointer-driven through custom properties, and
   all of it collapses to a static, complete state under reduced motion.
   ========================================================================== */

/* ------------------------------------------------------------------ spine */

/*
 * One thread the length of the page.
 *
 * It sits in the shell's left gutter, so it never competes with the measure,
 * and it only appears where there is a gutter to put it in. Below that width
 * the hairline rules between sections do the same job on their own.
 */
.spine {
  position: absolute;
  inset-block: 0;
  inset-inline-start: max(var(--s-5), (100vw - var(--grid-max)) / 2 - var(--s-6));
  inline-size: 1px;
  pointer-events: none;
  display: none;
}

.spine__track,
.spine__drawn {
  position: absolute;
  inset-inline: 0;
  inset-block-start: 0;
}

.spine__track {
  block-size: 100%;
  background: var(--c-line);
}

/* The drawn part. `--drawn` is 0 to 1, written by about-page.js from the
   page's own scroll position. */
.spine__drawn {
  block-size: calc(var(--drawn, 0) * 100%);
  background: linear-gradient(
    to bottom,
    var(--c-accent) 0%,
    var(--c-accent) calc(100% - 8rem),
    transparent 100%
  );
}

@media (min-width: 1180px) {
  .about {
    position: relative;
  }

  .spine {
    display: block;
  }
}

/* ------------------------------------------------------------- hero week */

/* The words keep the hero's original measure; the week takes the room that was
   empty beside them. */
.about__hero-words {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
}

.about__hero-inner {
  display: grid;
  grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
  align-items: center;
  gap: clamp(var(--s-6), 5vw, var(--s-9));
}

/*
 * A working week, with the hours that exist twice marked out.
 *
 * There is no number anywhere on it, and that is deliberate: three sections
 * below, this page refuses to quote a figure it has not measured, and an hours
 * count here would be exactly that figure. The shape is the argument.
 */
.week {
  /* Small on purpose. At full column width the slots became a calendar app and
     the grid started competing with the headline instead of supporting it; the
     hero's argument is the sentence, and this is the footnote that proves it. */
  margin: 0 0 0 auto;
  inline-size: min(100%, 19.5rem);
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
}

.week__head,
.week__grid {
  display: grid;
  grid-template-columns: repeat(5, minmax(0, 1fr));
  gap: 4px;
}

.week__day {
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
  text-align: center;
}

/* Column-major: six slots down each day, five days across. */
.week__grid {
  grid-template-rows: repeat(6, 1fr);
  grid-auto-flow: column;
}

/*
 * An hour of the week. The ordinary ones are solid — they are the work, and on
 * an ink ground solid means present. The lift above the page's own black is
 * what separates "an hour that holds something" from "an hour that does not",
 * and it has to be enough to read at this size.
 */
.week__slot {
  aspect-ratio: 1 / 1;
  border-radius: 2px;
  background: color-mix(in oklab, var(--c-ink-soft) 34%, transparent);
  border: 1px solid transparent;
}

/*
 * The hours that are the same thing entered twice.
 *
 * `--cleared` goes 0 to 1 as the section is read. At 0 the slot is hatched and
 * carries the clay accent, which is the only warm colour on this page and is
 * doing a job: it marks the work nobody meant to create. At 1 it is an empty
 * outline — the hour is still there, it just has nothing in it any more.
 */
.week__slot[data-dupe] {
  --cleared: 0;
  /* Hatched and clay while it is still there; an empty outline once it has
     gone. The hour does not disappear — nothing here claims to give anyone
     back a Friday — it is simply no longer full of the same thing twice. */
  border-color: color-mix(
    in oklab,
    var(--c-clay-on-dark) calc(30% + (1 - var(--cleared)) * 45%),
    transparent
  );
  background:
    repeating-linear-gradient(
      -45deg,
      color-mix(in oklab, var(--c-clay-on-dark) calc((1 - var(--cleared)) * 62%), transparent) 0 2px,
      transparent 2px 5px
    );
}

.week__note {
  font-size: var(--t-small);
  color: var(--c-muted);
  text-wrap: balance;
}

@media (max-width: 980px) {
  .about__hero-inner {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-7);
  }

  .week {
    margin-inline: 0;
    inline-size: min(100%, 22rem);
  }
}

/* --------------------------------------------------------------- handover */

/*
 * One order, retyped four times.
 *
 * Each line is the one above it plus a plausible slip, and the four answers at
 * the bottom are what the paragraph beside it says happens: four people, four
 * answers, all defensible. The slips are IN THE MARKUP rather than generated,
 * because they have to be the same every time — a random corruption would be a
 * different argument on every visit, and a weaker one.
 */
.handover {
  list-style: none;
  margin: var(--s-7) 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0;
  max-inline-size: 46rem;
}

.handover__step {
  --in: 0;
  display: grid;
  grid-template-columns: minmax(0, 13rem) minmax(0, 1fr);
  align-items: baseline;
  gap: var(--s-3) var(--s-5);
  padding-block: var(--s-4);
  border-block-end: 1px solid var(--c-line);
  opacity: calc(0.25 + var(--in) * 0.75);
  transition: opacity var(--d-slow) var(--e-out);
}

.handover__step:last-child {
  border-block-end: 0;
}

.handover__where {
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
}

.handover__line {
  font-family: var(--font-mono);
  font-size: var(--t-small);
  color: var(--c-ink-soft);
  font-variant-numeric: tabular-nums;
  /* The glyphs are swapped one at a time, so the box must not resize as they
     land or the whole column would jitter. */
  white-space: pre-wrap;
}

/* The last line before the answers is the one that is visibly wrong. */
.handover__step:nth-last-child(2) .handover__line {
  color: var(--c-clay-on-dark);
}

.handover__answers {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--s-2);
}

.handover__answer {
  padding: var(--s-2) var(--s-4);
  border: 1px solid var(--c-line-strong);
  border-radius: var(--r-pill);
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  color: var(--c-ink-soft);
  opacity: var(--in);
  transform: translateY(calc((1 - var(--in)) * 4px));
  transition:
    opacity var(--d-slow) var(--e-out),
    transform var(--d-slow) var(--e-out);
  transition-delay: calc(var(--answer, 0) * 90ms);
}

.handover__step--end .handover__where {
  color: var(--c-accent);
}

@media (max-width: 700px) {
  .handover__step {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* ------------------------------------------------------------- mission */

/*
 * The sentence resolves as it is reached.
 *
 * `--focus` runs 0 to 1 across the band, written by about-page.js. Blur is
 * expensive to animate, so it is on one short line only and the element is
 * promoted for the duration rather than for the life of the page.
 */
.about__mission-line {
  --focus: 1;
  filter: blur(calc((1 - var(--focus)) * 7px));
  opacity: calc(0.25 + var(--focus) * 0.75);
}

/* ------------------------------------------------------------- values beam */

.values {
  position: relative;
}

/*
 * The beam. It runs down the left edge of the three steps and fills as the
 * section is read, so the order the copy insists on is the order the eye is
 * given. On one column it runs down the side; on three it runs across the top,
 * because that is the reading direction in each case.
 */
.values::before,
.values::after {
  content: '';
  position: absolute;
  pointer-events: none;
}

.values::before {
  inset-block: 0;
  inset-inline-start: -1px;
  inline-size: 2px;
  background: var(--c-line);
}

.values::after {
  inset-block-start: 0;
  inset-inline-start: -1px;
  inline-size: 2px;
  block-size: calc(var(--beam, 0) * 100%);
  background: var(--c-accent);
}

/* A step that the beam has reached. */
.values__item[data-lit='true'] .values__num {
  color: var(--c-accent);
}

.values__num {
  transition: color var(--d-slow) var(--e-out);
}

@media (min-width: 49rem) {
  /* Three across: the beam is the rule above them, drawn left to right. */
  .values::before {
    inset-block: auto;
    inset-block-start: -1px;
    inset-inline: 0;
    inline-size: auto;
    block-size: 2px;
  }

  .values::after {
    inset-block-start: -1px;
    inset-inline-start: 0;
    block-size: 2px;
    inline-size: calc(var(--beam, 0) * 100%);
  }
}

/* -------------------------------------------------------------- refuse */

/*
 * Each refusal strikes itself through as it arrives.
 *
 * The rule is drawn on a pseudo-element rather than with `text-decoration`,
 * because a line-through cannot be animated and this one has to draw. It is
 * also why the heading text is wrapped: the rule has to span the WORDS, not
 * the heading's full column width, or short refusals would trail a rule into
 * empty space.
 */
.refuse__struck {
  position: relative;
  display: inline;
  background-image: linear-gradient(var(--c-accent), var(--c-accent));
  background-repeat: no-repeat;
  background-position: 0 0.62em;
  background-size: calc(var(--struck, 0) * 100%) 1px;
  transition: background-size var(--d-slow) var(--e-out);
}

.refuse__item {
  --struck: 0;
}

/* ------------------------------------------------------------ crew cards */

/*
 * The portraits, as cards that catch the light.
 *
 * The mechanics are ProfileCard's — pointer-tracked position, a real 3D tilt,
 * a damped return to centre on leave. The palette is not. That component is a
 * holographic rainbow built from six saturated hues and colour-dodge, and it
 * would be the only rainbow on a site made of ink, bone and one green — and it
 * tints the photograph, which is the one thing that must not happen here.
 *
 * So: one specular highlight, white, low opacity, tracking the pointer across
 * the surface. No hue is added at any point. The photograph underneath is
 * exactly the photograph.
 */
.crew {
  perspective: 1400px;
}

.crew__card {
  --pointer-x: 50%;
  --pointer-y: 50%;
  --from-centre: 0;
  --rx: 0deg;
  --ry: 0deg;
}

.crew__portrait {
  position: relative;
  transform: rotateX(var(--rx)) rotateY(var(--ry));
  transform-style: preserve-3d;
  will-change: transform;
  box-shadow:
    0 calc(2px + var(--from-centre) * 10px) calc(18px + var(--from-centre) * 26px)
    rgb(0 0 0 / calc(28% + var(--from-centre) * 22%));
  transition:
    transform var(--d-base) var(--e-out),
    box-shadow var(--d-base) var(--e-out);
}

/* No transition while the pointer is actually on the card, or every move would
   be chasing a 200ms tween and the tilt would lag behind the cursor. */
.crew__card[data-active='true'] .crew__portrait {
  transition: none;
}

.crew__portrait img {
  position: relative;
  z-index: 0;
}

/*
 * The highlight. `soft-light` rather than `screen` or `color-dodge`: it lifts
 * what is already there instead of washing it out, so the photograph keeps its
 * own contrast and its own colour.
 */
.crew__sheen {
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  border-radius: inherit;
  mix-blend-mode: soft-light;
  opacity: var(--lit, 0);
  background: radial-gradient(
    38rem 38rem at var(--pointer-x) var(--pointer-y),
    rgb(255 255 255 / 78%) 0%,
    rgb(255 255 255 / 22%) 28%,
    transparent 62%
  );
  transition: opacity var(--d-slow) var(--e-out);
}

/* The cut edge, catching light on the side the pointer is on. This is the only
   part that carries the accent, and it never touches the image: it is a 1px
   inset ring on top of it. */
.crew__edge {
  position: absolute;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  border-radius: inherit;
  opacity: calc(var(--lit, 0) * 0.9);
  box-shadow: inset 0 0 0 1px
    color-mix(in oklab, var(--c-accent-on-dark) 55%, transparent);
  transition: opacity var(--d-slow) var(--e-out);
}

/* ---------------------------------------------------------- reduced motion */

/*
 * Everything arrives, nothing performs.
 *
 * Each piece has a complete resting state that is also its finished state, so
 * this is mostly a matter of pinning the custom properties at 1 — the week is
 * cleared, the order has degraded, the sentence is sharp, the beam is drawn and
 * every refusal is struck. The page still makes every one of its arguments.
 */
@media (prefers-reduced-motion: reduce) {
  .spine__drawn {
    --drawn: 1;
  }

  .week__slot[data-dupe] {
    --cleared: 1;
  }

  .handover__step {
    --in: 1;
  }

  .handover__answer {
    transition: none;
    transition-delay: 0s;
  }

  .about__mission-line {
    --focus: 1;
  }

  .values {
    --beam: 1;
  }

  .refuse__item {
    --struck: 1;
  }

  .refuse__struck {
    transition: none;
  }

  .crew__portrait,
  .crew__sheen,
  .crew__edge {
    transform: none;
    transition: none;
  }

  .crew__sheen,
  .crew__edge {
    opacity: 0;
  }
}


/* ==== assets/css/components/turncard.css ==== */
/* =============================================================================
   Turn card — one object, seen from both sides.

   The storefronts page opens with the sentence "the front of the business,
   built like the back of it". This is that sentence as an object: a card whose
   front is a shop and whose back is the order the shop produced. It rotates on
   its own Y axis as the page is scrolled, so the reader turns it over rather
   than watching two pictures cross-fade.

   Real 3D, not a fake: a perspective on the stage, preserve-3d on the pivot,
   and two faces with backface-visibility so the browser hides whichever one is
   pointing away. Which means the swap happens exactly at the edge, for free,
   at whatever frame rate the compositor is running — there is no moment to get
   wrong.

   Four numbers come from storefront-page.js, and only the first is the scroll:

     --turn    0 → 1, eased and damped. 1 is a half turn.
     --edge    0 → 1 → 0, peaking as the card passes edge-on. Everything that
               should react to "being side-on" reads this rather than
               recomputing the same curve.
     --tilt    a few degrees of rotateX, settling to level as the turn finishes.
     --lift    0 → 1, how far the card has risen into place on arrival.
   ========================================================================== */

.turncard {
  /* Deep enough to be dimensional, shallow enough that the far edge does not
     shear. Origin slightly above centre so the card is read from just above,
     the way something on a desk is. */
  perspective: 1600px;
  perspective-origin: 50% 42%;
  inline-size: 100%;
}

.turncard__inner {
  position: relative;
  /*
   * A grid with both faces in the one cell, rather than a flow front and an
   * absolute back.
   *
   * The card has to be as tall as its TALLER face. With the back positioned
   * absolutely the card took the front's height, and on a phone — where the
   * shop is three small tiles and the record is a header, three rows and a
   * footnote — the record's last line was cut off by the face's own rounding.
   * One cell sizes to the taller and stretches the shorter, at every width,
   * with nothing measured.
   */
  display: grid;
  transform-style: preserve-3d;
  transform:
    translateY(calc((1 - var(--lift, 1)) * 2.5rem))
    rotateX(var(--tilt, 0deg))
    rotateY(calc(var(--turn, 0) * 180deg))
    /* Recedes very slightly as it turns through the edge, which is what sells
       the rotation as an object moving in space rather than a texture being
       squashed horizontally. */
    scale(calc(1 - var(--edge, 0) * 0.055));
  opacity: var(--lift, 1);
}

/*
 * The faces. Both hide their reverse, so exactly one is ever painted — which
 * means the swap happens precisely at the edge, for free, with no moment for
 * the code to get wrong.
 */
.turncard__face {
  grid-area: 1 / 1;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  border-radius: var(--r-md);
  /* Promotes each face to its own layer, which keeps the text on them from
     being re-rasterised every frame of the turn. */
  will-change: transform;
}

.turncard__face--back {
  transform: rotateY(180deg);
  display: flex;
}

/* The shorter face fills the cell rather than sitting short inside it. */
.turncard__face--front > .shopframe {
  block-size: 100%;
}

/*
 * The shadow the card casts, on its own plane behind it.
 *
 * Drawn rather than applied as a box-shadow, because a box-shadow rotates with
 * the element and a card turning edge-on would carry its shadow round with it.
 * This one stays flat on the ground and narrows as the card presents less of
 * itself, which is what a real one does.
 */
.turncard__shadow {
  position: absolute;
  inset-inline: 8%;
  inset-block-end: -6%;
  block-size: 14%;
  transform: translateZ(-60px) scaleX(calc(1 - var(--edge, 0) * 0.55));
  background: radial-gradient(
    50% 50% at 50% 50%,
    rgb(0 0 0 / 55%),
    transparent 70%
  );
  filter: blur(18px);
  opacity: calc((1 - var(--edge, 0) * 0.6) * var(--lift, 1));
  pointer-events: none;
}

/*
 * The sheen.
 *
 * A single band of light that sweeps across whichever face is showing, timed to
 * the turn. It is the detail that makes the card read as a surface with a sheen
 * rather than a flat rectangle being transformed — and it is strongest exactly
 * where the rotation is fastest, which is where the eye is looking.
 */
.turncard__face::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  background: linear-gradient(
    105deg,
    transparent 35%,
    rgb(255 255 255 / 12%) 50%,
    transparent 65%
  );
  background-size: 300% 100%;
  background-position: calc((1 - var(--turn, 0)) * 100%) 0;
  opacity: var(--edge, 0);
}

/* The lit edge. At rest it is invisible; as the card turns it catches the light
   the way a real card's cut edge does. */
.turncard__face--front::before {
  content: '';
  position: absolute;
  inset-block: 0;
  inset-inline-end: 0;
  inline-size: 1px;
  border-radius: inherit;
  background: var(--c-accent);
  opacity: calc(var(--edge, 0) * 0.7);
  pointer-events: none;
}

/* ------------------------------------------------------------ the reverse */

/*
 * The back is the record the shop produced. It is deliberately the same object
 * language as the rail on the home page — a code, a state, rows of fact — so a
 * reader who has come from there recognises it as the same system rather than
 * as a second illustration.
 */
.turnrecord {
  inline-size: 100%;
  display: flex;
  flex-direction: column;
  border: 1px solid var(--c-accent);
  border-radius: var(--r-md);
  background: var(--c-canvas-sunk);
  overflow: hidden;
}

.turnrecord__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  padding: var(--s-4) var(--s-5);
  border-block-end: 1px solid var(--c-accent);
  background: var(--c-accent-soft);
}

.turnrecord__code {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  color: var(--c-accent);
}

.turnrecord__body {
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
  padding: var(--s-5);
  flex: 1;
  justify-content: center;
}

.turnrecord__title {
  font-size: var(--t-lead);
  font-weight: 500;
  letter-spacing: -0.02em;
  margin: 0;
}

.turnrecord__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s-4);
  margin: 0;
  padding-block-end: var(--s-3);
  border-block-end: var(--border);
}

.turnrecord__row:last-of-type {
  border-block-end: 0;
  padding-block-end: 0;
}

.turnrecord__row dt {
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
}

.turnrecord__row dd {
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--t-small);
  font-variant-numeric: tabular-nums;
  text-align: end;
}

.turnrecord__row--key dd {
  color: var(--c-accent);
}

.turnrecord__foot {
  padding: var(--s-4) var(--s-5);
  border-block-start: var(--border);
  color: var(--c-muted);
  font-size: var(--t-small);
}

/* ----------------------------------------------------------- reduced motion */

/*
 * No turn, and no half-turned card frozen at an angle either — that would be a
 * still of an animation rather than a composition. Both faces simply stack, so
 * the reader gets the whole argument in one look, which is what the rotation
 * was there to deliver in the first place.
 */
@media (prefers-reduced-motion: reduce) {
  .turncard {
    perspective: none;
  }

  .turncard__inner {
    transform: none;
    opacity: 1;
    display: flex;
    flex-direction: column;
    gap: var(--s-5);
  }

  .turncard__face,
  .turncard__face--back {
    grid-area: auto;
    transform: none;
    backface-visibility: visible;
    will-change: auto;
  }

  .turncard__face::after,
  .turncard__face--front::before,
  .turncard__shadow {
    display: none;
  }
}


/* ==== assets/css/sections/storefront-page.css ==== */
/* =============================================================================
   Storefronts — the page.

   Mostly dark, with two light sections as relief before the close. The dark is
   not a mood: this page is the first thing a prospect sees of what we can
   build, so it is allowed to be the most composed thing on the site, and the
   light sections are where it stops performing and answers questions.

   Two scroll pieces carry it, and between them they use all three axes:

     the turn    a card rotating on Y, front of the business to back of it
     the needs   a track moving on X, through what a trade buyer actually asks

   Both are scrubbed and damped in storefront-page.js. Nothing here runs on a
   timer.
   ========================================================================== */

/*
 * Typed, because these are arithmetic rather than tokens.
 *
 * An unregistered custom property is a string until it is used, so dividing by
 * one that holds a calc() is invalid at computed-value time and the whole
 * declaration is dropped — which is exactly what happened
 * here: every row painted at opacity 1 and nothing staggered. Registered as
 * <number> they resolve eagerly, the maths holds, and as a bonus they become
 * animatable rather than merely substitutable.
 */
@property --flow {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

@property --flow-n {
  syntax: '<number>';
  inherits: true;
  initial-value: 1;
}

@property --flow-i {
  syntax: '<number>';
  inherits: false;
  initial-value: 0;
}

/* Inherits, because the row computes it but its children are what react to it
   — the tag, the sentence, the label. Registered `inherits: false` the sentence
   read the initial 0 and masked itself out of existence while the row it sits
   in was fully lit. */
@property --lit {
  syntax: '<number>';
  inherits: true;
  initial-value: 0;
}

/* ------------------------------------------------------------------ hero */

.sf-hero {
  position: relative;
  padding-block: 0;
}

/* The runway. Height here rather than on the section, so the section's own
   content is not pushed down by the pin's scroll distance. */
.sf-hero__runway {
  block-size: 320vh;
  position: relative;
}

.sf-hero__pin {
  position: sticky;
  inset-block-start: 0;
  block-size: 100vh;
  display: grid;
  align-items: center;
  overflow: clip;
}

.sf-hero__stage {
  inline-size: min(100% - (var(--grid-gutter) * 2), var(--grid-max));
  margin-inline: auto;
  display: grid;
  grid-template-columns: minmax(0, 0.85fr) minmax(0, 1fr);
  align-items: center;
  gap: clamp(var(--s-6), 6vw, var(--s-9));
}

.sf-hero__say {
  min-inline-size: 0;
}

.sf-hero__say h1 {
  margin: 0;
  max-inline-size: 15ch;
}

.sf-hero__lead {
  margin-block-start: var(--s-5);
  max-inline-size: 42ch;
  color: var(--c-ink-soft);
}

.sf-hero__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-4);
  margin-block-start: var(--s-6);
}

.sf-hero__card {
  position: relative;
  min-inline-size: 0;
}

/*
 * The caption under the card names which side you are looking at.
 *
 * Two labels in one slot, cross-faded by --turn: the front's label leaves as
 * the card passes edge-on and the back's arrives, so the words change at the
 * same instant the object does.
 */
.sf-hero__caption {
  display: grid;
  margin-block-start: var(--s-6);
  min-block-size: 1.4em;
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
  text-align: center;
}

.sf-hero__caption span {
  grid-area: 1 / 1;
}

.sf-hero__caption span:first-child {
  opacity: clamp(0, calc(1 - var(--turn, 0) * 2.4), 1);
}

.sf-hero__caption span:last-child {
  opacity: clamp(0, calc((var(--turn, 0) - 0.62) * 2.8), 1);
  color: var(--c-accent);
}

/* --------------------------------------------------------------- the needs */

.sf-needs {
  position: relative;
  padding-block: 0;
}

.sf-needs__runway {
  /* Written by storefront-page.js from the number of stops. */
  block-size: var(--needs-length, 400vh);
  position: relative;
}

.sf-needs__pin {
  position: sticky;
  inset-block-start: 0;
  block-size: 100vh;
  display: grid;
  grid-template-rows: auto auto;
  align-content: center;
  gap: var(--s-6);
  overflow: clip;
}

.sf-needs__head {
  inline-size: min(100% - (var(--grid-gutter) * 2), var(--grid-max));
  margin-inline: auto;
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--s-5);
  flex-wrap: wrap;
}

.sf-needs__count {
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  color: var(--c-muted);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

.sf-needs__count b {
  color: var(--c-accent);
  font-weight: 500;
}

.sf-needs__viewport {
  display: flex;
  align-items: stretch;
  min-inline-size: 0;
}

/*
 * The track.
 *
 * Padded by the gutter at both ends so the first stop starts where the page's
 * text starts and the last can come to rest there too. Translated rather than
 * scrolled: this is the only thing moving in the section, and it should move on
 * the compositor.
 */
.sf-needs__track {
  display: flex;
  align-items: stretch;
  gap: clamp(var(--s-5), 3vw, var(--s-7));
  padding-inline: var(--grid-gutter);
  min-inline-size: max-content;
  translate: calc(var(--needs-progress, 0) * var(--needs-travel, 0px) * -1) 0;
  will-change: transform;
}

.need {
  inline-size: clamp(17rem, 26vw, 23rem);
  flex: none;
  display: flex;
  flex-direction: column;
  gap: var(--s-4);
  padding: var(--s-6);
  border: var(--border);
  border-radius: var(--r-md);
  background: var(--c-canvas-sunk);
  /* The one not being read recedes rather than disappearing, so the row still
     reads as a sequence you are moving along. */
  opacity: 0.34;
  scale: 0.965;
  transition:
    opacity var(--d-slow) var(--e-out),
    scale var(--d-slow) var(--e-out),
    border-color var(--d-slow) var(--e-out);
}

.need[data-current='true'] {
  opacity: 1;
  scale: 1;
  border-color: var(--c-accent);
}

.need__index {
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  color: var(--c-accent);
}

/* The buyer's question, in their words, and the loudest thing in the card:
   knowing which questions these are is the whole demonstration. */
.need__ask {
  font-size: var(--t-lead);
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.25;
  margin: 0;
}

.need__pair {
  margin: 0;
  padding-block-start: var(--s-4);
  border-block-start: var(--border);
}

.need__pair dt {
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
  margin-block-end: var(--s-2);
}

.need__pair dd {
  margin: 0 0 var(--s-4);
  color: var(--c-ink-soft);
}

.need__pair dd:last-of-type {
  margin-block-end: 0;
  color: var(--c-ink);
}

/* ------------------------------------------------------------- the doors */

.sf-doors {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(var(--s-6), 4vw, var(--s-8));
  margin-block-start: var(--s-7);
}

.sf-door {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  padding-block-start: var(--s-4);
  border-block-start: var(--border-strong);
}

.sf-door__title {
  font-size: var(--t-lead);
  font-weight: 500;
  letter-spacing: -0.02em;
  margin: 0;
}

.sf-door__list {
  list-style: none;
  margin: var(--s-2) 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  color: var(--c-ink-soft);
}

.sf-door__list li {
  display: flex;
  gap: var(--s-3);
}

.sf-door__list li::before {
  content: '';
  flex: none;
  inline-size: 4px;
  block-size: 4px;
  border-radius: 50%;
  background: var(--c-accent);
  margin-block-start: 0.62em;
}

/* -------------------------------------------------------------------- say */

/*
 * One sentence, four languages, resolving in place.
 *
 * The flaps this replaced were the wrong instinct: boxes made the words look
 * like a component, and the words are the whole subject. Set at reading scale
 * with nothing around them, the line is the object and the change of language
 * happens to it rather than inside a grid of cells.
 */
.say {
  margin-block-start: clamp(var(--s-7), 7vw, 5rem);
  min-block-size: 9rem;
}

.say__lang {
  margin: 0 0 var(--s-5);
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
}

.say__lang span {
  color: var(--c-accent);
}

/*
 * Large, and set in the site's own display treatment rather than a new one.
 * The sentence is short enough to hold at this size on one or two lines, and
 * at anything smaller the resolving characters are too fine to read as an
 * effect at all.
 */
.say__line {
  margin: 0;
  /* Two lines' worth, always.
   *
   * The sentences are different lengths, so Dutch wrapped to two lines while
   * English fitted on one — and the block changed height on every switch,
   * taking everything below it with it. Reserving the taller state means the
   * language changes and nothing else moves. */
  min-block-size: 2.3em;
  font-size: clamp(1.5rem, 4.4vw, 3.2rem);
  font-weight: 500;
  line-height: 1.15;
  letter-spacing: -0.035em;
  /* Each glyph is its own inline box, so a settling character can carry colour
     without the line reflowing around it. */
  display: block;
  max-inline-size: 22ch;
}

/* A character that has not landed yet. Accent rather than grey, so the eye
   follows the front of the change across the line. */
.say__char {
  transition: color var(--d-fast) linear;
}

.say__char[data-settling='true'] {
  color: var(--c-accent);
}

/* The list the line is built from — kept in the document, hidden visually. */
.say__source {
  position: absolute;
  inline-size: 1px;
  block-size: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* -------------------------------------------------------------------- web */

/*
 * The field of connections behind each door. Sits under the content and above
 * the cursor light, at an alpha low enough to be furniture.
 */
.sf-door__web {
  position: absolute;
  inset: 0;
  z-index: -1;
  inline-size: 100%;
  block-size: 100%;
  color: var(--c-accent);
  opacity: 0.5;
  pointer-events: none;
  transition: opacity var(--d-slow) var(--e-out);
}

.sf-door[data-active='true'] .sf-door__web {
  opacity: 0.9;
}

/* ------------------------------------------------------------------- term */

/*
 * The training log.
 *
 * A panel, not a card: it is the one place on the page that is meant to look
 * like a machine rather than like a document, so it keeps its border and its
 * fill and everything around it stays typographic.
 */
.term {
  /* Movement is a translate on a custom property, so the panel's layout
     position never changes and nothing below it shifts while it is carried. */
  translate: var(--drag-x, 0) var(--drag-y, 0);
  touch-action: none;
  margin-block-start: clamp(var(--s-6), 4vw, var(--s-7));
  border: 1px solid var(--c-line);
  border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--c-dark) 55%, transparent);
  overflow: hidden;
  position: relative;
  max-inline-size: 46rem;
}

/* Scanlines. Faint, and at 3px so they read as a surface rather than as
   stripes; the whole panel is only a few hundred pixels tall. */
.term::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: repeating-linear-gradient(
    to bottom,
    transparent 0 2px,
    color-mix(in srgb, var(--c-dark-ink) 4%, transparent) 2px 3px
  );
}

/* Picked up here — but only once the module has said so. */
.term[data-drag='true'] .term__bar {
  cursor: grab;
}

.term__bar {
  user-select: none;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-4);
  padding: var(--s-3) var(--s-4);
  border-block-end: 1px solid var(--c-line);
  background: color-mix(in srgb, var(--c-dark-ink) 4%, transparent);
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
}

.term__path {
  /* Truncates rather than wrapping the bar onto two lines on a phone. */
  min-inline-size: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.term__state {
  color: var(--c-accent);
  flex: none;
  display: flex;
  align-items: center;
  gap: 0.6em;
}

/* The spinner. Written by the module as a custom property holding a braille
   frame, so the turning costs one property update rather than a DOM write. */
.term__state::before {
  content: var(--spin, '\2802');
  color: var(--c-accent);
}

.term[data-held='true'] {
  cursor: grabbing;
  box-shadow: 0 18px 50px rgb(0 0 0 / 45%);
}

.term[data-held='true'] .term__bar {
  cursor: grabbing;
}

.term__body {
  margin: 0;
  padding: var(--s-4);
  /* Fixed height, so a log that keeps producing does not keep growing the page.
     It scrolls itself and follows its own output. */
  block-size: 15.5rem;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: 0.35em;
  font-family: var(--font-mono);
  font-size: var(--t-micro);
  line-height: 1.5;
  color: var(--c-dark-muted);
  /* A log is a log: it does not reflow into a paragraph on a narrow screen, it
     scrolls, and this is the one element on the page allowed to. */
  overflow-x: auto;
}

.term__line {
  white-space: pre;
}

.term__line--cmd { color: var(--c-dark-ink); }
.term__line--ok { color: var(--c-accent-on-dark); }
.term__line--work { color: var(--c-ochre); }
.term__line--wait { color: var(--c-muted); }
.term__line--note { color: var(--c-dark-muted); font-style: italic; }

/* The block cursor, on the last line, until the log finishes. */
.term__body > .term__line:last-child::after {
  content: '';
  display: inline-block;
  inline-size: 0.55em;
  block-size: 1em;
  margin-inline-start: 0.15em;
  vertical-align: -0.15em;
  background: var(--c-accent-on-dark);
  animation: term-blink 1.05s steps(1) infinite;
}

.term[data-done='true'] .term__body > .term__line:last-child::after {
  display: none;
}

@keyframes term-blink {
  0%, 50% { opacity: 1; }
  50.01%, 100% { opacity: 0; }
}

/* ---------------------------------------------------------- the assistant */

/*
 * What it learns, listed as the business's own things rather than as features.
 * "Your lead times" is a claim about fitting a company; "natural language
 * understanding" is a claim about a library, and every competitor has one.
 */
.sf-learns {
  display: grid;
  /* Three, explicitly, not auto-fit. Six items across an auto-fit track came
     out five and one, which reads as a mistake in the content rather than a
     consequence of the viewport. Three gives two even rows at any width wide
     enough to hold them. */
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s-6) var(--s-7);
  margin-block-start: var(--s-7);
  padding-block-start: var(--s-6);
  border-block-start: var(--border);
}

.sf-learn {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.sf-learn__what {
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-accent);
}

.sf-learn__why {
  margin: 0;
  color: var(--c-ink-soft);
}

/* Said once, in the section head, and not softened anywhere else. */
.sf-wip {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  padding: 0.25em 0.7em;
  border: 1px solid var(--c-line-strong);
  border-radius: var(--r-pill);
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
}

.sf-wip::before {
  content: '';
  inline-size: 5px;
  block-size: 5px;
  border-radius: 50%;
  background: var(--c-accent);
}

/* ------------------------------------------------------------- the terms */

.sf-terms {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: var(--s-6);
  margin-block-start: var(--s-7);
}

.sf-term {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
  padding-block-start: var(--s-4);
  border-block-start: var(--border-strong);
}

.sf-term__head {
  font-size: var(--t-lead);
  font-weight: 500;
  letter-spacing: -0.02em;
  margin: 0;
}

.sf-term__note {
  margin: 0;
  color: var(--c-ink-soft);
}

/* Set apart from the terms above it: left-aligned directly under the first
   column, it read as a fifth line of "a fixed price". */
.sf-first {
  margin-block-start: var(--s-7);
  padding-block-start: var(--s-5);
  border-block-start: var(--border);
  color: var(--c-muted);
}


/*
 * Choosing between the two.
 *
 * Both doors sit slightly back until the cursor picks one, at which point it
 * resolves and the other softens. The blur is small — 3px on a heading is
 * plenty to read as out of focus and cheap enough to run on two elements — and
 * it is only ever applied to ONE of them, so there is never more than a single
 * blurred layer being composited.
 */
.sf-door {
  position: relative;
  isolation: isolate;
  transition:
    filter var(--d-slow) var(--e-out),
    opacity var(--d-slow) var(--e-out),
    border-color var(--d-slow) var(--e-out);
}

.sf-doors[data-choosing='true'] .sf-door[data-active='false'] {
  filter: blur(3px);
  opacity: 0.42;
}

.sf-doors[data-choosing='true'] .sf-door[data-active='true'] {
  border-block-start-color: var(--c-accent);
}

/*
 * The light under the cursor.
 *
 * Behind the content rather than over it (z-index: -1 inside the door's own
 * isolation), so it warms the panel instead of washing the text. Position comes
 * from the pointer as a percentage; the transition is what makes it trail
 * rather than teleport between pointermove events, which fire at the input
 * device's rate and not the display's.
 */
.sf-door__light {
  position: absolute;
  /* Bleeds vertically only. The horizontal -6% it had was 6% of a door that is
     the full column once these stack, which put it past the edge of the page
     and gave /storefronts/ a scrollbar at 390px. The gradient fades out well
     before its own box anyway, so the softness came from the falloff rather
     than from the bleed. */
  inset: -8% 0;
  z-index: -1;
  border-radius: var(--r-lg);
  background: radial-gradient(
    22rem 16rem at var(--mx, 50%) var(--my, 50%),
    color-mix(in srgb, var(--c-accent) 16%, transparent),
    transparent 68%
  );
  opacity: 0;
  transition:
    opacity var(--d-slow) var(--e-out),
    background-position var(--d-base) linear;
  pointer-events: none;
}

.sf-door[data-active='true'] .sf-door__light {
  opacity: 1;
}

/*
 * No pointer, no choosing. A touch reader gets both doors sharp and lit, which
 * is the resolved state rather than a degraded one.
 */
@media (hover: none), (pointer: coarse) {
  .sf-doors[data-choosing='true'] .sf-door[data-active='false'] {
    filter: none;
    opacity: 1;
  }

  .sf-door__light {
    display: none;
  }
}

/*
 * The sentence arriving in each language in turn.
 *
 * --flow is the section's own read progress, 0 to 1, damped in
 * storefront-page.js. Each row derives its share of it from the --i in its
 * markup, so the stagger is a property of the element and adding a fifth
 * language needs no new rule and no new number here.
 *
 * The sentence is masked in from the left rather than faded: it should read as
 * being written, which is what the section is about. The rows behind stay
 * written, because the point is that all four exist at once.
 */
/* flow × n walks 0→n across the section; subtracting i staggers each row by
   its own place in the list. Multiplication only — no division by a variable,
   which is what made the first version of this invalid. */
.sf-lang {
  --lit: clamp(0, calc(var(--flow) * var(--flow-n) - var(--flow-i) * 0.82), 1);
}

.sf-lang__tag {
  opacity: calc(0.25 + var(--lit) * 0.75);
  transition: opacity var(--d-base) var(--e-out);
}

.sf-lang__say {
  /* A soft edge on the mask, so the line resolves rather than being uncovered
     by a hard vertical line travelling across it. */
  -webkit-mask-image: linear-gradient(
    to right,
    #000 calc(var(--lit) * 118% - 18%),
    transparent calc(var(--lit) * 118%)
  );
  mask-image: linear-gradient(
    to right,
    #000 calc(var(--lit) * 118% - 18%),
    transparent calc(var(--lit) * 118%)
  );
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  translate: calc((1 - var(--lit)) * -0.4rem) 0;
}

/* The rule down the left, drawn as the rows arrive: four separate lines, one
   continuous thing. */
.sf-langs {
  position: relative;
}

.sf-langs::before {
  content: '';
  position: absolute;
  inset-block-start: 0;
  inset-inline-start: 0;
  inline-size: 1px;
  block-size: calc(var(--flow, 0) * 100%);
  background: var(--c-accent);
  opacity: 0.5;
}

/*
 * What it has been taught, arriving one at a time.
 *
 * These carry no .reveal class. The flow IS their entrance, and two systems
 * animating one element's opacity is a fight the more specific one wins at
 * random — .reveal.is-revealed was pinning the first three at 1 while the
 * flow held them at 0.22.
 *
 * Same mechanism as the languages, same reason: an assistant that has learnt
 * six things about a business has learnt all six, so nothing goes out again
 * behind the current.
 */
.sf-learn {
  --lit: clamp(0, calc(var(--flow) * var(--flow-n) - var(--flow-i) * 0.78), 1);
  position: relative;
  padding-inline-start: var(--s-4);
  translate: 0 calc((1 - var(--lit)) * 0.5rem);
  opacity: calc(0.22 + var(--lit) * 0.78);
  transition: translate var(--d-slow) var(--e-out);
}

/* The mark that fills as the current reaches this one. */
.sf-learn::before {
  content: '';
  position: absolute;
  inset-block: 0.15em auto;
  inset-inline-start: 0;
  inline-size: 2px;
  block-size: calc(var(--lit) * 100%);
  min-block-size: 2px;
  background: var(--c-accent);
  border-radius: 1px;
}

.sf-learn__what {
  color: color-mix(in srgb, var(--c-accent) calc(40% + var(--lit) * 60%), var(--c-muted));
}

/* ------------------------------------------------------------------ chain */

/*
 * How many people stand between a client and the work.
 *
 * The honest version of the old "there are three of us": that claim stops being
 * true the day anyone is hired, and this one does not, because it is about the
 * path rather than the payroll. The agency lane fades as the section is read
 * and the direct lane draws — the middle links stay behind as ghosts, because
 * the point is that the coordination still exists somewhere, you are simply not
 * paying for it here.
 */
.chain {
  --lit: clamp(0, calc(var(--flow) * 1.35), 1);
  display: grid;
  gap: clamp(var(--s-6), 5vw, var(--s-8));
  margin-block-start: clamp(var(--s-7), 6vw, 4.5rem);
}

.chain__lane {
  display: grid;
  gap: var(--s-4);
}

.chain__tag {
  font-family: var(--font-mono);
  font-size: var(--t-nano);
  letter-spacing: var(--ls-micro);
  text-transform: uppercase;
  color: var(--c-muted);
}

/*
 * The gap between two nodes IS the connector's width, so the line each node
 * draws exactly fills the space to the next. The first version pulled the line
 * back over the gap with a negative margin and it rendered past the end of the
 * last pill — this way the two numbers cannot disagree, because there is one.
 */
.chain__links {
  --link-gap: clamp(var(--s-4), 2.5vw, var(--s-6));
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s-3) var(--link-gap);
}

.chain__lane--direct .chain__links {
  --link-gap: clamp(3rem, 14vw, 13rem);
}

/*
 * A node and the line that leaves it. The connector is drawn as a pseudo on
 * each node rather than as its own element, so a lane is a list of names and
 * nothing else — the drawing is entirely in the stylesheet.
 */
.chain__node {
  position: relative;
  padding: var(--s-2) var(--s-4);
  border: var(--border);
  border-radius: var(--r-pill);
  background: var(--c-canvas);
  font-size: var(--t-small);
  color: var(--c-ink-soft);
  white-space: nowrap;
}

.chain__node:not(:last-child)::after {
  content: '';
  position: absolute;
  inset-inline-start: 100%;
  inset-block-start: 50%;
  inline-size: var(--link-gap);
  block-size: 1px;
  background: currentColor;
  opacity: 0.4;
}

.chain__node--end {
  border-color: var(--c-line-strong);
  color: var(--c-ink);
}

/* The middle of the agency lane recedes as the section is read: still there,
   still real, no longer between you and the work. */
.chain__lane--via .chain__node:not(.chain__node--end) {
  opacity: calc(1 - var(--lit) * 0.72);
  transition: opacity var(--d-slow) var(--e-out);
}

.chain__lane--via {
  opacity: calc(1 - var(--lit) * 0.45);
}

/* And the direct lane arrives. */
.chain__lane--direct {
  opacity: var(--lit);
  translate: 0 calc((1 - var(--lit)) * 0.75rem);
}

.chain__lane--direct .chain__node {
  border-color: var(--c-accent);
  color: var(--c-accent-deep);
  background: var(--c-accent-soft);
}

/* The one long line, drawn rather than fading in — it is the whole claim. */
.chain__lane--direct .chain__node:not(:last-child)::after {
  background: var(--c-accent);
  opacity: 1;
  scale: var(--lit) 1;
  transform-origin: left center;
}

.chain__cost {
  margin: 0;
  color: var(--c-muted);
  font-size: var(--t-small);
}

@media (min-width: 900px) {
  .chain {
    gap: clamp(var(--s-7), 6vw, 5rem);
  }

}

/* Six pixels short at 320px, which is not worth an ellipsis — the bar simply
   tightens instead, and the path stays whole. */
@media (max-width: 400px) {
  .term__bar {
    gap: var(--s-3);
    padding-inline: var(--s-3);
    font-size: 0.625rem;
  }
}

@media (max-width: 620px) {
  /* Stacked, because six pills on one line at 320px is not a chain, it is a
     paragraph of boxes. The connector turns to run down instead. */
  .chain__links {
    display: grid;
    justify-items: start;
    gap: var(--s-5);
  }

  /* Stacked, so the connector turns and runs down the gap instead. */
  .chain__links,
  .chain__lane--direct .chain__links {
    --link-gap: var(--s-5);
  }

  .chain__node:not(:last-child)::after {
    inline-size: 1px;
    block-size: var(--link-gap);
    inset-inline-start: var(--s-5);
    inset-block-start: 100%;
  }

  .chain__lane--direct .chain__node:not(:last-child)::after {
    scale: 1 var(--lit);
    transform-origin: top center;
  }
}

/* ------------------------------------------------------------------ narrow */

@media (max-width: 960px) {
  .sf-learns {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  .sf-hero__runway {
    block-size: 280vh;
  }

  .sf-hero__stage {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-7);
    align-content: center;
  }

  /* The card keeps a readable size rather than filling the column. */
  .sf-hero__card {
    max-inline-size: 22rem;
    margin-inline: auto;
    inline-size: 100%;
  }

  .sf-doors {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-7);
  }
}

@media (max-width: 620px) {
  .sf-learns {
    grid-template-columns: minmax(0, 1fr);
  }

  .need {
    inline-size: min(78vw, 20rem);
    padding: var(--s-5);
  }

  .sf-lang {
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-2);
  }
}

/* ----------------------------------------------------------- reduced motion */

/*
 * Both runways collapse and both pieces become ordinary blocks. The needs track
 * stops being a track and wraps into a grid, because a horizontal scroller that
 * cannot be scrubbed is content the reader cannot reach.
 */
@media (prefers-reduced-motion: reduce) {
  .sf-hero__runway,
  .sf-needs__runway {
    block-size: auto;
  }

  .sf-hero__pin,
  .sf-needs__pin {
    position: static;
    block-size: auto;
    padding-block: var(--section-y);
    overflow: visible;
  }

  .sf-needs__viewport {
    display: block;
  }

  .sf-needs__track {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(16rem, 1fr));
    min-inline-size: 0;
    padding-inline: 0;
    translate: none;
    will-change: auto;
  }

  .need {
    inline-size: auto;
    opacity: 1;
    scale: 1;
  }

  /* Every current is delivered at 1 by the module, so these are already in
     their finished state — but the mask and the offsets are removed outright
     rather than relied on to resolve, since a mask that never repaints is a
     line that never appears. */
  .sf-lang__say {
    -webkit-mask-image: none;
    mask-image: none;
    translate: none;
  }

  .sf-learn {
    translate: none;
    opacity: 1;
  }

  .sf-door,
  .sf-door__light {
    transition: none;
  }
}


/* ==== assets/css/sections/grounds.css ==== */
/* =============================================================================
   Section grounds and shared life.

   The page previously ran fifteen sections on one flat canvas, broken only by
   two dark bands. Read end to end that is a very long beige scroll, whatever is
   happening inside each section.

   This file does two things:

     1. Gives sections an alternating ground, so scrolling has a pulse. Colour
        is not decoration here — clay marks the BEFORE (the scattered stack, the
        mess, the manual step), green marks the AFTER (connected, tracked,
        resolved), and neutral carries the explanatory sections in between.

     2. Holds the small shared motion pieces that make static blocks feel
        alive: the accent underline that draws itself under each section
        heading, and the hairline that wipes in across section rules.

   Section-specific styling still lives in each section's own file; this is only
   the ground and the shared behaviour.
   ========================================================================== */

/* ------------------------------------------------------- alternating grounds */

/* BEFORE: the sections about the mess as it stands today. */
.friction {
  background:
    radial-gradient(
      70ch 45ch at 78% 12%,
      color-mix(in srgb, var(--c-clay-soft) 70%, transparent),
      transparent 68%
    ),
    var(--c-canvas);
}

/* AFTER: the sections about work running properly. */
.warehouse {
  background: var(--c-tint-green);
}

.catalogue {
  background: var(--c-canvas);
}

.factory {
  background:
    radial-gradient(
      60ch 40ch at 15% 90%,
      color-mix(in srgb, var(--c-tint-clay) 80%, transparent),
      transparent 70%
    ),
    var(--c-canvas);
}

/* The closing argument sits in a recess rather than on the page's own canvas,
   which gives it weight without colour. It was --c-tint-clay: the only
   clay-grounded section on a page that otherwise runs green and canvas, and it
   read as belonging to a different site. */
.economics {
  background: var(--c-canvas-sunk);
}

.proof {
  background: var(--c-canvas-sunk);
}

.faq {
  background: var(--c-canvas);
}

/* --------------------------------------------------- heading underline draw */

/* A short accent rule that draws itself beneath every section heading as it
   arrives. One small gesture, used consistently, does more for the page's sense
   of life than a different flourish per section — and because it is tied to the
   reveal it never animates for a reader who has already scrolled past. */
.title,
.headline {
  position: relative;
}

.section-head .title::after,
.section-head .headline::after {
  content: '';
  position: absolute;
  inset-block-end: -0.32em;
  inset-inline-start: 0;
  block-size: 2px;
  inline-size: 3.5rem;
  background: var(--c-accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform var(--d-slow) var(--e-out) 220ms;
}

.section-head .reveal.is-revealed.title::after,
.section-head .reveal.is-revealed.headline::after {
  transform: scaleX(1);
}

/* Centred heads (the calculator) need the rule centred too. */
.stack-section .section-head .title::after {
  inset-inline: 0;
  margin-inline: auto;
  transform-origin: center;
}

/* --------------------------------------------------------- eyebrow accent */

/* The eyebrow's leader line grows in rather than appearing at full length. */
.eyebrow::before {
  transform: scaleX(0.35);
  transform-origin: left;
  transition: transform var(--d-slow) var(--e-out);
}

.eyebrow.is-revealed::before,
.is-revealed .eyebrow::before {
  transform: scaleX(1);
}

/* ------------------------------------------------------------- link hovers */

/* Body links get an underline that sweeps rather than switching on. */
.prose a,
.foundation__source a {
  text-decoration: none;
  background-image: linear-gradient(currentcolor, currentcolor);
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 100% 1px;
  transition: background-size var(--d-base) var(--e-out);
}

.prose a:hover,
.foundation__source a:hover {
  background-size: 100% 2px;
}

/* ------------------------------------------------------------ reduced motion */

@media (prefers-reduced-motion: reduce) {
  .section-head .title::after,
  .section-head .headline::after {
    transform: scaleX(1);
    transition: none;
  }

  .eyebrow::before {
    transform: scaleX(1);
    transition: none;
  }
}
