/*
 * ROOTS APAC / KOON APAC — DATUM, Version 2
 * Design tokens. Single source of truth for values.
 *
 * Governing rule: five brand colours exist and nothing else. The V1
 * monochrome-only rule is superseded by the client's updated branding
 * (01_Project_Inputs/04_Updated_Branding/). Greys are gone; where V1 used
 * an ink ramp, V2 uses alpha variants of the brand colours. Any other
 * colour is a build failure — tools/qa.mjs enforces it.
 *
 * V2.1 (client "Palette" guide, 24 Aug 2026) restates the two principal
 * colours. Forest Green becomes CANOPY #3f5b4a, and the paper the site is
 * printed on stops being pure white: RICE PAPER #f3efe6 is now the ground
 * under every light surface. The retired Forest Green value is deliberately
 * not written anywhere below — the palette guard in tools/qa.mjs reads hex
 * literals out of the BUILT sheet without stripping comments, so naming a
 * dead colour in prose would fail the build. That is the intended
 * behaviour: the only way to mention a colour here is to be using it. Signal White survives, but only as a
 * FOREGROUND — text and strokes sitting on Canopy or Charcoal. That split
 * is why --paper and --white are two tokens rather than one: they used to
 * be the same value doing two jobs, and only one of the two jobs moved.
 *
 * Photography and client logos remain the only source of any other colour,
 * exactly as V1.
 *
 * Derived from the approved discovery questionnaire and the updated brand:
 *   palette: Canopy, Sage Mist, Rice Paper, Signal White, ROOTS Charcoal
 *   Q24 typeface: Roboto              Q27 iconography: none
 *   signature device: Infinity Ring   Q25 photography: case-study driven
 */

@font-face {
  font-family: "Roboto";
  src: url("/assets/fonts/Roboto-Variable.ttf") format("truetype");
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
}

:root {
  /* ---------------------------------------------------------------
   * 1. Palette — the only five colours that exist
   *
   *   --canopy    Canopy            #3f5b4a   rgb(63, 91, 74)
   *   --sage      Sage Mist         #bcc8b8   Pantone 5595 C
   *   --paper     Rice Paper        #f3efe6   rgb(243, 239, 230)
   *   --white     Signal White      #ffffff   foreground only
   *   --charcoal  ROOTS Charcoal    #1c211d   Pantone Black 3 C
   *
   * --paper and --white are NOT interchangeable. --paper is every light
   * GROUND on the site: the page itself, the plate mounts behind
   * photography, the logo cells, the sticky header and the drawer.
   * --white is a FOREGROUND only — text and strokes on Canopy or on
   * Charcoal, where Rice Paper's warmth would read as an off-white
   * mistake rather than a choice. A --white background is a bug.
   *
   * Contrast matrix (WCAG 2.1 relative luminance, recomputed for the V2.1
   * palette — do not re-derive):
   *
   *   pair                  ratio    verdict
   *   charcoal on paper     14.24:1  body text, light context
   *   charcoal on sage       9.41:1  body text on sage panels
   *   sage on charcoal       9.41:1  footer text
   *   white on canopy        7.48:1  body text on Canopy sections
   *   white on charcoal     16.35:1  body text, dark context
   *   paper on charcoal     14.24:1  available, unused — sage is the floor's
   *                                  own text colour
   *   canopy on paper        6.52:1  accent text, links
   *   sage on canopy         4.31:1  BELOW AA — decorative only, never text
   *   canopy on sage         4.31:1  BELOW AA — decorative only, never text
   *   sage on paper          1.51:1  decorative only, never text
   *   canopy on charcoal     2.18:1  decorative only, never text
   *
   * House rule taken from that matrix: the canopy/sage pair, in either
   * direction, carries NO text anywhere on this site. It is used for
   * hairlines, ring tracks and grounds only. Under V2 that pair sat at
   * 4.69:1 and cleared AA by a hair, so the rule was a preference; at
   * 4.31:1 it no longer clears AA at all, and the rule is now load-bearing.
   * Nothing in the sheet relies on it — `.band--sage` already sets its
   * accent to charcoal for exactly this reason — but any new component
   * putting sage type on Canopy, or Canopy type on sage, is a failure.
   * Every text pair actually in use is 4.6:1 or better.
   * ------------------------------------------------------------- */
  --canopy: #3f5b4a;
  --sage: #bcc8b8;
  --paper: #f3efe6;
  --white: #ffffff;
  --charcoal: #1c211d;

  /* ---------------------------------------------------------------
   * 2. Alpha variants — what replaces the V1 ink ramp
   *
   * Every value below is one of the four brand colours at an alpha, so
   * the palette guard passes and the composite is always a blend of two
   * brand colours. Suffix = alpha percentage. Composited ratios are
   * given against the ground each variant is actually used on.
   *
   *   charcoal alphas — used over paper and over sage
   *     72%  secondary text   6.03:1 on paper   4.80:1 on sage
   *     65%  disabled / placeholder   4.83:1 on paper
   *     28%  strong hairline on sage
   *     18%  strong hairline on paper / hairline on sage
   *     12%  hairline on paper
   *
   *   white alphas — foreground strokes over canopy and over charcoal
   *     78%  secondary text   5.32:1 on canopy
   *     70%  disabled         4.64:1 on canopy
   *     24%  strong hairline on canopy and charcoal
   *     14%  hairline on canopy and charcoal
   *
   *   paper alpha — the one light GROUND on a dark band
   *      8%  raised ground (mounts, cells) on canopy and charcoal. Rice
   *          Paper rather than Signal White so a plate mount carries the
   *          same warmth on a dark band that it carries on the page. At
   *          8% the two are within a hair of each other; the point is that
   *          no background anywhere in the sheet reads --white.
   *
   *   sage alphas — used over charcoal
   *     78%  secondary text   6.26:1 on charcoal
   *     68%  disabled         5.10:1 on charcoal
   *
   * The four ratios that moved did so because the ground moved: Canopy is
   * a little lighter than Forest Green and Rice Paper a little darker than
   * Signal White, so every composited pair loses a few tenths. All of them
   * still clear AA for normal text; --white-70 at 4.64:1 is the thinnest
   * and it is a disabled state, which WCAG does not require to pass at all.
   *
   * Hairlines sit far below 3:1 by design. They are decorative dividers,
   * not UI components or meaningful graphics, so WCAG 1.4.11 does not
   * apply — no information is carried by a rule alone. Any rule that
   * later becomes the sole indicator of state must be re-checked.
   * ------------------------------------------------------------- */
  --charcoal-72: rgb(28 33 29 / 0.72);
  --charcoal-65: rgb(28 33 29 / 0.65);
  --charcoal-28: rgb(28 33 29 / 0.28);
  --charcoal-18: rgb(28 33 29 / 0.18);
  --charcoal-12: rgb(28 33 29 / 0.12);

  --white-78: rgb(255 255 255 / 0.78);
  --white-70: rgb(255 255 255 / 0.7);
  --white-24: rgb(255 255 255 / 0.24);
  --white-14: rgb(255 255 255 / 0.14);

  --paper-08: rgb(243 239 230 / 0.08);

  --sage-78: rgb(188 200 184 / 0.78);
  --sage-68: rgb(188 200 184 / 0.68);

  /* ---------------------------------------------------------------
   * 3. Semantic roles
   * Set per ground, not globally. A page alternates grounds, so the
   * ground classes at the foot of this file carry the roles rather than
   * :root alone. :root is the paper default — Rice Paper.
   *
   * Ground rhythm: Rice Paper is the paper, sage is the panel, Canopy is
   * the statement, charcoal is the floor.
   * ------------------------------------------------------------- */
  --bg: var(--paper);
  --bg-raised: var(--paper);
  --fg: var(--charcoal);
  --fg-muted: var(--charcoal-72);
  --fg-disabled: var(--charcoal-65);
  --accent: var(--canopy);
  --focus: var(--charcoal);
  --rule: var(--charcoal-12);
  --rule-strong: var(--charcoal-18);

  /* ---------------------------------------------------------------
   * 4. Type
   * Roboto only. Weights 400 / 500 / 700. Light is never used at
   * display size — it is the single clearest tell of the old direction.
   * Headings cap at 56px. Photography carries scale, not type.
   *
   * The updated wordmark artwork uses its own geometric face inside the
   * SVG paths. That is artwork, not a font choice; it is not chased here.
   * ------------------------------------------------------------- */
  --font: "Roboto", Arial, Helvetica, sans-serif;

  --type-title: clamp(2.125rem, 1.55rem + 2.4vw, 3.5rem); /* 34 → 56 */
  --type-section: clamp(1.75rem, 1.3rem + 1.9vw, 2.75rem); /* 28 → 44 */
  --type-sub: clamp(1.375rem, 1.15rem + 0.95vw, 1.75rem); /* 22 → 28 */
  --type-lead: clamp(1.125rem, 1.05rem + 0.3vw, 1.25rem); /* 18 → 20 */
  --type-body: 1.0625rem; /* 17 */
  --type-small: 0.9375rem; /* 15 */
  --type-label: 0.75rem; /* 12 */
  --type-meta: 0.6875rem; /* 11 */

  --lh-title: 1.05;
  --lh-section: 1.08;
  --lh-sub: 1.15;
  --lh-body: 1.6;
  --lh-label: 1.2;

  --tr-title: -0.02em;
  --tr-section: -0.015em;
  --tr-sub: -0.01em;
  --tr-body: 0;
  --tr-label: 0.14em;
  --tr-meta: 0.16em;

  --wt-regular: 400;
  --wt-medium: 500;
  --wt-bold: 700;

  /* ---------------------------------------------------------------
   * 5. Grid and baseline
   * 12 columns. Everything snaps to an 8px baseline. The grid is now
   * invisible: the client asked for the vertical column hairlines to go
   * (2026-08-19), so the 12 columns survive as the measuring system that
   * places every component while nothing draws them. Horizontal rules —
   * fact tables, footer, logo cells, section dividers — are unaffected.
   * Component spans are unchanged; with no rules to land on, the old
   * "spans must be multiples of 3" discipline no longer applies.
   * ------------------------------------------------------------- */
  --cols: 12;
  --gutter: 1.5rem; /* 24 */
  --margin: clamp(1.25rem, 5.5vw, 5rem); /* 20 → 80 */
  --container: 90rem; /* 1440 */
  --measure: 34rem; /* ~68 characters at 17px */

  --base: 0.5rem; /* 8 — the datum */
  --sp-1: 0.5rem;
  --sp-2: 1rem;
  --sp-3: 1.5rem;
  --sp-4: 2rem;
  --sp-6: 3rem;
  --sp-8: 4rem;
  --sp-12: 6rem;
  --sp-16: 8rem;
  --sp-20: 10rem;

  --band: clamp(4rem, 8vw, 8rem); /* vertical band padding */

  --hair: 1px;

  /* ---------------------------------------------------------------
   * 6. The Infinity Ring system  (V1 --oo-* renamed to --ring-*)
   *
   * Source artwork, measured directly:
   *   01_Project_Inputs/04_Updated_Branding/2. Infinity Ring/2. SVG/
   *   viewBox 0 0 1828.51 1080
   *     outer radius        540
   *     inner radius        412.1     (stroke 127.9)
   *     centres             (540, 540) and (1288.51, 540)
   *     centre separation   748.51
   *
   * Proportions against the V1 OO symbol:
   *     separation / outer   1.38613   (V1 1.38614)
   *     inner / outer        0.76315   (V1 0.76310)
   *     stroke / outer       0.23685   (V1 0.23689)
   *
   * Identical construction, rescaled. The VALUES below are therefore the
   * V1 numbers, unchanged: they express the same geometry at the scale
   * the ring is actually drawn at on screen. templates.mjs scales the
   * artwork by 31.73 / 127.9 = 0.2480844, which gives outer 133.97
   * against the 133.94 here — the same ring to four significant figures.
   *
   * The intersection of the two outer circles is a vesica. At artwork
   * scale it measures 331.49 x 778.545; rotated to landscape that is an
   * aspect of 2.34864, against the 2.3486 below. The house crop ratio
   * carries over unchanged.
   *
   * The mask-ellipse percentages that drew the vesica as a shape are
   * gone (2026-08-19): the client asked for the oval "lens" crop and the
   * cross-document ring wipe to be removed, and those were the only two
   * consumers. The measurements that survive are the ones the ratio and
   * the drawn header ring still need.
   * ------------------------------------------------------------- */
  --ring-r-outer: 133.94;
  --ring-r-inner: 102.21;
  --ring-separation: 185.66;
  --ring-stroke-ratio: 0.2369; /* (outer - inner) / outer */

  --ring-lens-w: 193.1;
  --ring-lens-h: 82.22;
  --ring-lens-aspect: 2.3486;

  /* THE SYMBOL IN THE HOME HERO (client, 25 Aug 2026 — V2.2.1).
   *
   * The hero used to carry a hairline outline of the ring. It now carries
   * the artwork above, inlined verbatim by `heroRing()` in templates.mjs and
   * placed by styles.css §28, at two thirds of the band's width with the
   * headline running partially over it. The client asked for it "slightly
   * translucent"; these are that word, as a number, and they are the one
   * line to change if it wants to be quieter or stronger.
   *
   * THE CEILING IS LEGIBILITY, NOT TASTE, which is why the arithmetic is
   * written down rather than the intention. Signal White at 0.22 lightens
   * Canopy to a mid grey-green, about rgb 105 127 114 — written out rather
   * than as a hex, because the palette guard in tools/qa.mjs reads comments
   * too and is right to. On that ground the white h1 runs 4.29:1 — past
   * AA for large text (3:1) with room — and the --white-78 lead runs 3.31:1
   * where an arc of the mark actually passes under a line, which on the wide
   * layout is the right portion of the type column only. Going much above
   * 0.22 puts white text on white and is the failure this number stops
   * short of.
   *
   * The narrow value is a step down rather than a different idea. Below
   * 64rem the type column is full width and the mark is a full-bleed
   * watermark behind ALL of it, so the lead meets an arc on every line
   * instead of on some of them: 0.12 takes that lead to 4.17:1 and the
   * headline to 5.50:1. Same mark, one step apart, both applied in §28. */
  --hero-ring-opacity: 0.22;
  --hero-ring-opacity-narrow: 0.12;

  /* ---------------------------------------------------------------
   * 7. Image ratios
   * Two ratios only. Every image is cropped to one of them and sits
   * inside a passe-partout with a hairline.
   *
   * The house ratio is the vesica's own 2.3486:1. This is the quiet
   * version of the idea: the Infinity Ring governs the crop of every
   * photograph on the site even though the lens shape itself is never
   * drawn any more.
   * ------------------------------------------------------------- */
  --ratio-house: var(--ring-lens-w) / var(--ring-lens-h); /* 2.3486 */
  --ratio-primary: 3 / 2;

  /* ---------------------------------------------------------------
   * 8. Motion
   *
   * Interface motion (hover, drawer, focus) uses --dur-fast / --dur /
   * --dur-slow. Scroll entry is its own, slower band: the client asked
   * for a noticeably more animated arrival (2026-08-19), so sections now
   * bring their children in on a stagger rather than fading as one
   * block.
   *
   *   --dur-reveal      620ms  a single decisive movement, not a drift
   *   --reveal-stagger   60ms  per child, capped in app.js at 8 steps so
   *                            a 24-cell logo wall finishes in ~1.1s
   *   --reveal-rise      20px  the distance travelled, transform only
   *   --reveal-scale     1.03  photographs settle to 1 from just over
   *   --reveal-floor     0.45  ENTRY OPACITY, NEVER 0
   *
   * The floor is the load-bearing number. Text starts at 45% opacity and
   * rises to 100%; it is legible the whole way, so a fast scroll to any
   * point in the document — or a jump to a #hash — can never show a
   * blank screen or an empty section waiting to be observed.
   *
   * --ease is the single easing curve for everything: a decisive
   * ease-out that lands without overshoot. Nothing bounces, nothing
   * spins (Q17, quietly confident).
   * ------------------------------------------------------------- */
  --dur-fast: 160ms;
  --dur: 240ms;
  --dur-slow: 420ms;
  --ease: cubic-bezier(0.22, 1, 0.36, 1);

  --dur-reveal: 620ms;
  --reveal-stagger: 60ms;
  --reveal-rise: 20px;
  --reveal-scale: 1.03;
  --reveal-floor: 0.45;

  /* ---------------------------------------------------------------
   * 8b. The scroll-motion package (revision pass 7, 20 Aug 2026)
   *
   * Four systems as written, THREE OF THEM LEFT since 31 Aug 2026 — the ring
   * draw-on is retired below. The heading's count is not corrected, because
   * "four" is what this package was and the entry that went says so where it
   * stood. All of them are governed by the same rule as the entry above:
   * transform and opacity only, no layout property is ever animated, the
   * 0.45 floor holds for every piece of text, and every one of them is
   * completely inert under prefers-reduced-motion. None of them scrubs,
   * smooths or otherwise touches native scrolling.
   *
   * PARALLAX — a full-bleed photograph drifts inside its clipped frame.
   *   --parallax-scale  1.08  overscan, so the drift has somewhere to go
   *   --parallax-shift    4%  travelled either side of centre, so the full
   *                           traversal is 8% of the image's own height
   * The frame never moves; only the image inside it does, and only on the
   * vertical axis. At the moment a reader is actually looking at a plate —
   * roughly centred in the viewport — the shift is ~0, and the visible crop
   * is the middle 92.6% of the file. The one-edge extreme is only reached
   * when the frame is leaving the viewport entirely.
   *
   * WORD RISE — a headline arrives one word at a time.
   *   --dur-word        480ms  per word
   *   --word-stagger     40ms  between words, capped in app.js
   *   --word-rise       0.6em  travelled, transform only, scales with type
   * The words carry BOTH legs of the entry (rise and fade) and the heading
   * itself is pinned, because opacity composites: a 0.45 word inside a 0.45
   * block would render at 0.20 and break the floor.
   *
   * RING DRAW-ON — RETIRED 31 AUG 2026 (V2.6). Three tokens went with it:
   * --dur-draw (900ms per circle), --draw-offset (180ms, the second circle
   * following the first) and --ring-draw-length (841.76 — 2π x 133.97, the
   * outer circumference at the scale the outline was drawn at). The system
   * drew the Infinity Ring on as an outline; the hero stopped being an outline
   * on 25 Aug 2026 and the footer, its last possible target, took the brand
   * guide's filled symbol on 31 Aug 2026. A filled mark has no stroke to dash,
   * so there is nothing left on the site these three numbers could describe.
   * Deleted rather than carried, the test §8's neighbours are held to: a token
   * with no consumer is a number nobody can change with confidence, because
   * nothing will tell them if they break it. Its rules are gone from
   * styles.css §28 and §30, and app.js's `initRingDraw()` with them.
   *
   * COUNT-UP — a small integer counts from zero.
   *   --dur-count       500ms  mirrored in app.js as COUNT_MS; both must
   *                            move together. Declared here so the value
   *                            lives with the rest of the motion system and
   *                            is auditable from the built stylesheet.
   * ------------------------------------------------------------- */
  --parallax-scale: 1.08;
  --parallax-shift: 4%;

  --dur-word: 480ms;
  --word-stagger: 40ms;
  --word-rise: 0.6em;

  --dur-count: 500ms;

  /* ---------------------------------------------------------------
   * 8c. The hero map entrance (V2.2, 24 Aug 2026)
   *
   * The map's six call-outs arrive one region at a time, in the order
   * `mapRegions` lists them, as the block enters. It obeys every rule the
   * package above obeys — nothing that costs layout, the text floor
   * untouched, completely inert under prefers-reduced-motion, no scroll
   * interception — and it adds no JavaScript at all: the whole system is
   * gated on the `.is-in` class the section entry already sets on the
   * block. See styles.css §31.
   *
   * ENTRANCE, per region, all three starting together on that region's
   * own delay:
   *   --map-stagger      120ms  between regions, so the six read as a
   *                             sequence rather than a flash. Six regions
   *                             put the last one 600ms behind the first.
   *   --dur-map-line     600ms  the leader line draws from the dot out
   *                             towards its label. Every line is declared
   *                             `pathLength="1"` in the markup, so one
   *                             dash value drives all six regardless of
   *                             their real lengths.
   *   --dur-map-dot      320ms  the dot scales up from nothing about its
   *                             own centre. Shorter than the line on
   *                             purpose: the dot lands, then the line
   *                             finishes reaching for the label.
   *   --dur-map-label    480ms  the label rises --map-label-rise into
   *                             place. TRANSFORM ONLY — the map block is
   *                             itself a `.reveal` and carries the fade,
   *                             and opacity composites, so a label fading
   *                             from 0.45 inside a block at 0.45 would
   *                             render at 0.20 and break the floor. Same
   *                             rule, same reason, as the overlay settle
   *                             in styles.css §6b and the word rise in §27.
   *   --map-label-rise    0.4em travelled, so it scales with the label's
   *                             own type size at both map breakpoints.
   *
   * RADAR PULSE — the one continuous motion on the site. A second circle
   * behind each dot expands out of it and fades, forever.
   *   --dur-map-pulse   3500ms  one cycle. Slow enough to read as a
   *                             signal rather than a blink.
   *   --map-pulse-delay 1400ms  before the FIRST pulse, measured against
   *                             the entrance it must not interrupt: the
   *                             last region starts at 600ms and its line
   *                             finishes 600ms later, so 1400ms lands
   *                             clear of 1200ms with room to spare. The
   *                             section's own --reveal-delay is added on
   *                             top in the stylesheet, so a map that
   *                             entered late still waits.
   *   --map-pulse-stagger 580ms between regions, deliberately NOT the
   *                             120ms of the entrance: six pulses 580ms
   *                             apart spread almost exactly across one
   *                             3500ms cycle, so no two ever beat
   *                             together and the group never reads as a
   *                             single throb.
   *   --map-pulse-scale    3.2  the dot's radius at its widest.
   *   --map-pulse-opacity 0.28  the pulse at its brightest, fading to
   *                             zero. Under the 0.3 ceiling the client
   *                             set, and a hairline at 28% over the
   *                             artwork is a suggestion, not a target.
   * ------------------------------------------------------------- */
  --map-stagger: 120ms;
  --dur-map-line: 600ms;
  --dur-map-dot: 320ms;
  --dur-map-label: 480ms;
  --map-label-rise: 0.4em;

  --dur-map-pulse: 3500ms;
  --map-pulse-delay: 1400ms;
  --map-pulse-stagger: 580ms;
  --map-pulse-scale: 3.2;
  --map-pulse-opacity: 0.28;

  /* ---------------------------------------------------------------
   * 8d. The hero ring — bloom and colour drift (V2.3, 25 Aug 2026)
   *
   * The sixth motion system, and the second (after the map entrance in §8c)
   * whose entrance adds no JavaScript: it is gated on the same `.is-in` class
   * the section observer already sets. It obeys every rule the five above
   * obey — transform and opacity only, nothing that costs layout, the text
   * floor untouched because nothing here is text, completely inert under
   * prefers-reduced-motion, native scrolling never intercepted.
   *
   * THE BLOOM — the mark arrives once, as the hero reveals.
   *   --dur-hero-ring   1200ms  opacity 0 → 1 on the wrapper and scale
   *                             --hero-ring-bloom → 1 with it, both on the
   *                             house --ease. Nearly twice --dur-reveal's
   *                             620ms on purpose: this is the largest object
   *                             on the page and the only one a visitor is
   *                             meant to notice arriving. At 620ms a mark two
   *                             thirds of the viewport wide reads as a flash;
   *                             at 1200ms it reads as a bloom, which is the
   *                             word the gesture is named for.
   *   --hero-ring-bloom   1.06  the scale it settles FROM. Larger than the
   *                             plate settle's 1.03 (§8) for the same reason
   *                             the duration is longer — 3% across 950px is
   *                             28px of travel nobody would see. It grows
   *                             DOWNWARD to rest, so the mark never exceeds
   *                             its final box after the entrance, and the
   *                             6% it does exceed it by during the entrance
   *                             is clipped by the band (styles.css §28).
   *
   * THE DRIFT — the tint moves Signal White → Sage Mist on scroll. It is not
   * a duration: a scroll-driven animation's progress IS the scroll position,
   * so the only numbers it needs are the ones it starts and ends at, and
   * those are the --hero-ring-opacity pair in §6 that the mark already rests
   * at. Written as a second stacked copy of the artwork in Sage Mist whose
   * OPACITY the scroll drives, from 0 to that same resting value, over a
   * white copy that never moves — an opacity crossfade between two layers is
   * compositor work, where animating `fill` would repaint the largest object
   * on the page on every frame.
   *
   * The range is the ring's own `exit` phase (styles.css §28b): the drift
   * starts when the mark's top edge reaches the top of the viewport and
   * finishes when its bottom edge leaves, which is precisely "as the user
   * scrolls through the hero" and needs no measurement to say so. There is
   * therefore no token here — a scroll range is geometry, not a value.
   * ------------------------------------------------------------- */
  --dur-hero-ring: 1200ms;
  --hero-ring-bloom: 1.06;

  /* ---------------------------------------------------------------
   * 9. Frames
   *
   * No shadow and no gradient — declared so the absence stays explicit
   * and reviewable rather than accidental.
   *
   * --radius stays 0 and remains the site's answer for every box: bands,
   * cells, fields, buttons, the drawer. ONE exception exists, added by
   * the client on 24 Aug 2026 (V2.2), and it is a picture frame rather
   * than a corner style — the photography on the work index and the case
   * studies is mounted in a Charcoal frame with a soft corner, opted into
   * per plate as `.plate--framed` (styles.css §6). It is deliberately a
   * pair of tokens of its own rather than a value assigned to --radius:
   * nothing else on the site is meant to inherit it, and a --radius that
   * stopped being 0 would quietly round every one of those other boxes.
   *
   * The border is heavier than --hair. A hairline frame already exists —
   * it is the passe-partout every unframed plate carries — and at 1px in
   * Charcoal the difference from it would read as an inconsistency rather
   * than as a frame.
   *
   * 2px → 3px ON 25 AUG 2026 (V2.2.1), when the client asked for the light
   * mat inside the frame to go and the photograph became flush against the
   * border. The frame used to be read at 10px wide — 2px of border plus
   * --sp-1 of mount — and with the mount gone a 2px line was carrying all
   * of that weight against a photograph instead of against paper. 3px is
   * the frame again without becoming a mount. The radius is unchanged: the
   * inner curve is now 11px and it is the photograph's own corner, clipped
   * by `.plate`'s overflow (styles.css §6).
   * ------------------------------------------------------------- */
  --radius: 0;
  --shadow: none;

  --frame-width: 3px;
  --frame-radius: 14px;

  color-scheme: light;
}

/* -----------------------------------------------------------------
 * Ground contexts.
 *
 * A page alternates these; they re-point the semantic roles rather than
 * redefining the palette. Order matters: `.on-surface` is the generic
 * "not paper" marker and templates apply it alongside a ground modifier,
 * so `.band--sage` and `.band--green` are declared AFTER it and win at
 * equal specificity. Every one of them re-declares the full role set, so
 * no role can leak in from the ground it is layered on.
 * --------------------------------------------------------------- */

/* Rice Paper — the paper. The default, restated so it can be nested
 * inside any darker ground and reset cleanly. */
.on-paper {
  --bg: var(--paper);
  --bg-raised: var(--paper);
  --fg: var(--charcoal);
  --fg-muted: var(--charcoal-72);
  --fg-disabled: var(--charcoal-65);
  --accent: var(--canopy);
  --focus: var(--charcoal);
  --rule: var(--charcoal-12);
  --rule-strong: var(--charcoal-18);
  background: var(--bg);
  color: var(--fg);
  color-scheme: light;
}

/* Charcoal — the floor. The footer, and any band that asks for the
 * darkest ground. Text is sage (9.41:1), per the brand's own footer
 * treatment; white is equally available at 16.35:1 where a component
 * needs maximum weight. */
.on-surface {
  --bg: var(--charcoal);
  --bg-raised: var(--paper-08);
  --fg: var(--sage);
  --fg-muted: var(--sage-78);
  --fg-disabled: var(--sage-68);
  --accent: var(--sage);
  --focus: var(--sage);
  --rule: var(--white-14);
  --rule-strong: var(--white-24);
  background: var(--bg);
  color: var(--fg);
  color-scheme: dark;
}

/* Sage — the panel. Galleries and fact panels. Charcoal text at 9.41:1.
 * The accent stays charcoal here rather than Canopy: Canopy on sage is
 * 4.31:1, which under V2.1 no longer clears AA at all, and the components
 * that would carry it are 12px uppercase labels. Canopy appears on sage
 * only as a hairline or a ring track, never as text. */
.band--sage {
  --bg: var(--sage);
  --bg-raised: var(--paper);
  --fg: var(--charcoal);
  --fg-muted: var(--charcoal-72);
  --fg-disabled: var(--charcoal-72);
  --accent: var(--charcoal);
  --focus: var(--charcoal);
  --rule: var(--charcoal-18);
  --rule-strong: var(--charcoal-28);
  background: var(--bg);
  color: var(--fg);
  color-scheme: light;
}

/* Canopy — the statement. One band per page. Signal White text at 7.48:1;
 * secondary at white 78% for 5.32:1. This is the one place Signal White
 * still appears, and it appears only as foreground. Sage is never used
 * for text here (4.31:1) — only for grounds and decoration.
 *
 * The class keeps its `--green` name: it is a markup contract shared with
 * templates.mjs and tools/audit.mjs, and renaming it would be a churn in
 * three files to say the same thing. The COLOUR it points at is Canopy. */
.band--green {
  --bg: var(--canopy);
  --bg-raised: var(--paper-08);
  --fg: var(--white);
  --fg-muted: var(--white-78);
  --fg-disabled: var(--white-70);
  --accent: var(--white);
  --focus: var(--white);
  --rule: var(--white-14);
  --rule-strong: var(--white-24);
  background: var(--bg);
  color: var(--fg);
  color-scheme: dark;
}

/*
 * ROOTS APAC / KOON APAC — DATUM, Version 2
 * Site stylesheet.
 *
 * tokens.css is prepended to this file at build time, so every custom
 * property it declares is available here. Edit tokens for values;
 * edit this file for structure.
 *
 * Rule: five brand colours only — Canopy, Sage Mist, Rice Paper, Signal
 * White, ROOTS Charcoal, plus alpha variants of those five. Nothing here
 * may introduce a sixth colour; tools/qa.mjs fails the build if it does.
 * All other colour on the site comes from photography and from client
 * logos, which keep the colour they were supplied in.
 *
 * Rice Paper is the ground and Signal White is a foreground. Every
 * `var(--white)` below is text or a stroke on a dark ground — both of
 * them are on `.skip-link`, which carries its own charcoal ground — and
 * every light background reads --bg, --bg-raised or --paper instead.
 * A `background: var(--white)` anywhere in this file is a bug.
 *
 * Colour is otherwise never referenced directly. Components read the
 * semantic roles (--bg, --fg, --fg-muted, --accent, --focus, --rule,
 * --rule-strong) and the ground classes in tokens.css re-point those
 * roles, so one component is correct on Rice Paper, sage, Canopy and
 * charcoal without a per-ground override.
 */

/* -----------------------------------------------------------------
 * 1. Base
 * --------------------------------------------------------------- */

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

html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  scroll-padding-top: 6rem;
}

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

body {
  margin: 0;
  font-family: var(--font);
  font-size: var(--type-body);
  font-weight: var(--wt-regular);
  line-height: var(--lh-body);
  letter-spacing: var(--tr-body);
  /* Rice Paper by name rather than by role. The page ground is the one
   * surface that must never be inherited from somewhere else: `body` sits
   * outside every ground class, so if a band ever failed to paint, what
   * shows through has to be the paper the site is printed on and not the
   * browser's own white. --bg resolves to the same value at :root; this
   * says so out loud. */
  background: var(--paper);
  color: var(--fg);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

body.menu-open {
  overflow: hidden;
}

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

figure,
figcaption,
p,
h1,
h2,
h3,
address,
blockquote {
  margin: 0;
}

address {
  font-style: normal;
}

a {
  color: inherit;
  text-decoration: none;
}

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

ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

/* Selection inverts whatever ground it lands on. Custom properties are
 * inherited by the originating element, so one rule covers white, sage,
 * green and charcoal — the V1 `.on-surface ::selection` override is no
 * longer needed. Every resulting pair is 8.15:1 or better. */
::selection {
  background: var(--fg);
  color: var(--bg);
}

/* Focus is never currentColor: muted and disabled text would draw a
 * translucent ring. --focus is a solid brand colour chosen per ground —
 * charcoal on white and sage, white on green, sage on charcoal — so the
 * ring is at least 8.15:1 against every ground on the site. */
:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 4px;
}

/* -----------------------------------------------------------------
 * 2. Utilities
 * --------------------------------------------------------------- */

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

.skip-link {
  position: fixed;
  z-index: 100;
  top: 0.5rem;
  left: 0.5rem;
  padding: 0.75rem 1rem;
  background: var(--charcoal);
  color: var(--white);
  font-size: var(--type-label);
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-label);
  text-transform: uppercase;
  transform: translateY(-200%);
  transition: transform var(--dur) var(--ease);
}

.skip-link:focus {
  transform: translateY(0);
}

/* The skip link carries its own charcoal ground while the page around it
 * is white, so it needs its own focus colour or the ring disappears. */
.skip-link:focus-visible {
  outline-color: var(--white);
}

/* -----------------------------------------------------------------
 * 3. Shell, grid, bands
 * --------------------------------------------------------------- */

.shell {
  width: 100%;
  max-width: var(--container);
  margin-inline: auto;
  padding-inline: var(--margin);
}

.grid {
  display: grid;
  grid-template-columns: repeat(var(--cols), 1fr);
  gap: var(--gutter);
}

/* The visible column grid — a `.rules` overlay drawing hairlines at
 * columns 1, 4, 7 and 10 — was removed at the client's request on
 * 2026-08-19, markup and styles together. The 12-column grid above is
 * still what places every component; nothing draws it any more.
 * Horizontal hairlines are a separate device and all stay: fact tables,
 * the footer rules, logo-cell borders, `.rule` and `.rule--tick`. */

.band {
  position: relative;
  padding-block: var(--band);
}

.band > .shell {
  position: relative;
  z-index: 1;
}

/* UNCALLED SINCE 31 AUG 2026 (V2.6), and kept anyway — the one rule on this
 * sheet currently allowed to be. The contact page's offices section was its
 * last caller and went with the client's revision (§21). It stays because it is
 * a MODIFIER OF THE BAND PRIMITIVE above, one declaration wide and in the same
 * family as `.band--sage` and `.band--green`: the vocabulary any band is
 * written in, not a component that belonged to the markup that left. Deleting a
 * ground modifier because the last band wearing it was removed would narrow the
 * primitive on the strength of one page's content. If a later round wants it
 * gone, that is a decision about the band system, taken there. */
.band--tight {
  padding-block: var(--sp-8);
}

/* The ground modifiers --band--sage and --band--green live in tokens.css
 * with the other ground contexts, so all four grounds are declared in one
 * place and their cascade order is fixed. They set only colour roles; the
 * structural band rules above are shared. */

/* -----------------------------------------------------------------
 * 4. Type roles
 * --------------------------------------------------------------- */

.title {
  font-size: var(--type-title);
  font-weight: var(--wt-medium);
  line-height: var(--lh-title);
  letter-spacing: var(--tr-title);
  text-wrap: balance;
}

.section-title {
  font-size: var(--type-section);
  font-weight: var(--wt-medium);
  line-height: var(--lh-section);
  letter-spacing: var(--tr-section);
  text-wrap: balance;
}

.sub {
  font-size: var(--type-sub);
  font-weight: var(--wt-medium);
  line-height: var(--lh-sub);
  letter-spacing: var(--tr-sub);
}

.lead {
  font-size: var(--type-lead);
  line-height: 1.5;
  max-width: var(--measure);
}

.body {
  font-size: var(--type-body);
  line-height: var(--lh-body);
  max-width: var(--measure);
  color: var(--fg-muted);
}

.label {
  font-size: var(--type-label);
  font-weight: var(--wt-medium);
  line-height: var(--lh-label);
  letter-spacing: var(--tr-label);
  text-transform: uppercase;
}

.meta {
  font-size: var(--type-meta);
  font-weight: var(--wt-medium);
  line-height: var(--lh-label);
  letter-spacing: var(--tr-meta);
  text-transform: uppercase;
  color: var(--fg-muted);
}

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

/* -----------------------------------------------------------------
 * 5. The wordmark and the Infinity Ring device
 * Ring geometry is the symbol's own construction. See tokens.css §6.
 * --------------------------------------------------------------- */

/* The header brand link. Charcoal on white; the mark takes its fill from
 * currentColor so the same paths serve the footer on charcoal. */
.brand,
.brand-link {
  display: flex;
  align-items: center;
  color: var(--fg);
}

/* Inline wordmark SVG, viewBox 0 0 956.88 380.36 (2.516:1). Sized by
 * height so the aspect is carried by the artwork.
 *
 * Enlarged 20% for V2.1 (client, 24 Aug 2026): 26 → 33px becomes
 * 31.2 → 39.6px. Every term of the clamp is multiplied by the same 1.2,
 * the preferred term included (1.2rem + 1.5vw → 1.44rem + 1.8vw), so the
 * two viewport widths at which the mark starts and stops growing are
 * unchanged at 453px and 920px — the curve is the same shape, 20% bigger,
 * rather than a different curve that happens to pass through the new
 * endpoints.
 *
 * 39.6px still clears the header's 4rem (64px) minimum with 12px of air
 * either side, and the nav beside it is the taller of the two elements at
 * ~46px, so the header keeps its height. See §8. */
.brand-mark,
.brand-logo {
  display: block;
  height: clamp(1.95rem, 1.44rem + 1.8vw, 2.475rem); /* 31.2 → 39.6 */
  width: auto;
  fill: currentColor;
}

.oo {
  display: block;
  height: 1.375rem;
  width: auto;
}

.oo circle {
  fill: none;
  stroke: currentColor;
  stroke-width: 31.73;
}

.oo .oo-track {
  opacity: 0.28;
}

/* Rotation is carried by the SVG transform attribute alone. A CSS
 * transform-origin here re-anchors the same property and throws the
 * arc outside the viewBox.
 *
 * Circumference = 2π x 118.10 = 742.06, the mid-radius of the Infinity
 * Ring scaled into the header. The same number is in app.js and in
 * templates.mjs; all three must move together. */
.oo .oo-progress {
  stroke: var(--accent);
  opacity: 1;
  stroke-dasharray: 742.06;
  stroke-dashoffset: 742.06;
  transition: stroke-dashoffset 120ms linear;
}

.marker {
  display: flex;
  align-items: center;
  gap: 0.75rem;
}

.marker .oo {
  height: 0.75rem;
  flex: none;
}

/* The drawn lens — a vesica-shaped mask cut from the intersection of the
 * two outer rings — was removed at the client's request on 2026-08-19.
 * Every photograph it held is now a plain rectangular plate on one of
 * the two house ratios, and the --ring-mask-* tokens that shaped it are
 * gone from tokens.css with it.
 *
 * `.media-slot` is what replaces `.lens-slot`: the same five-column
 * position beside a text column, holding an ordinary mounted image. */
.media-slot {
  position: relative;
  z-index: 1;
  grid-column: 1 / -1;
  margin-top: var(--sp-6);
}

@media (min-width: 64rem) {
  .media-slot {
    grid-column: 8 / span 5;
    margin-top: 0;
    align-self: end;
  }
}

/* -----------------------------------------------------------------
 * 6. Plates — the passe-partout. Images are mounted, never bled raw.
 * --------------------------------------------------------------- */

.plate {
  border: var(--hair) solid var(--rule-strong);
  padding: var(--sp-1);
  background: var(--bg-raised);
  /* Clips the 3% overscan of the scroll-entry settle (§14) and the 8%
   * parallax overscan (§26). Declared twice for the same reason as
   * `.band--green` (§28b): `hidden` alone is a scroll container, which
   * anchored the §26 `view()` timeline to a box that never scrolls and
   * left the parallax inert on Chromium. `clip` clips identically — the
   * rounded frames included — without becoming a scroll container; the
   * `hidden` line stays as the pre-`clip` browsers' guarantee. */
  overflow: hidden;
  overflow: clip;
}

.plate img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.plate--house img {
  aspect-ratio: var(--ratio-house);
}

.plate--primary img {
  aspect-ratio: var(--ratio-primary);
}

/* The Charcoal frame (client, 24 Aug 2026; FLUSH since 25 Aug 2026).
 *
 * Opted into per plate through `plate(..., { framed: true })`, and carried by
 * two runs of photography: the work rows on the home page and every
 * photograph on a case study — hero, the wide plate under the stats, both
 * gallery cells, the next-case teaser. The capability galleries and the
 * programme disclosure panels keep the plain mount, because they illustrate a
 * summary rather than being the thing on show. A blanket rule on `.plate`
 * would have taken those with it and left no way to say which was intended.
 *
 * IT WAS THREE RUNS FOR FIVE DAYS. V2.4 added the About page's six How We
 * Work photographs on 26 Aug 2026 and V2.5 removed that section on 31 Aug,
 * so the count is back where it started. Before V2.4 the About page had no
 * plate on it at all — its one photograph sat in `.reserved__frame`, a
 * separate component in what was §20 of this sheet, and that markup, that
 * photograph and that whole section of the stylesheet are gone; see the note
 * where §20 used to be. The About page carries no photography at all now.
 *
 * THE MAT IS GONE. The first cut of this frame kept `.plate`'s --sp-1 of
 * padding, so --sp-1 of Rice Paper sat between the Charcoal border and the
 * photograph — a mount with a light window-line inside it. The client's
 * words on seeing it were "the entire border to be in charcoal... the white
 * part completely gone or replaced with black". So `padding: 0` overrides
 * the mount and the photograph is now flush against the border, clipped to
 * the frame's own curve.
 *
 * `background: var(--charcoal)` is the second half of that, and it is not
 * decoration: at a rounded corner the border's inner edge and the image's
 * clipped edge are two antialiased curves a fraction of a pixel apart, and
 * whatever is painted between them shows through as a hairline. Under
 * `.plate`'s --bg-raised that hairline was Rice Paper — the mat coming back
 * one pixel at a time in exactly the four places the client was looking. In
 * Charcoal the seam is the frame's own colour and there is nothing to see.
 *
 * It REPLACES the passe-partout hairline rather than adding to it: `.plate`
 * declares `border`, `padding` and `background` above and this overrides all
 * three. Both selectors weigh 0-1-0, so this block wins only by sitting
 * below it — keep them in this order.
 *
 * Charcoal by name, not by role. --fg is charcoal on paper and on sage but
 * SAGE on a dark ground, and a frame that changed colour with its band would
 * be a different object on the case pages (sage panel) than on the home page
 * (paper). The client asked for one frame. It is 14.24:1 on Rice Paper and
 * 9.41:1 on Sage Mist, so it is emphatic on both.
 *
 * THE RADIUS AND THE CLIP ARE ON THE SAME ELEMENT, which is the whole reason
 * this needs no other rule. `.plate` already carries `overflow: hidden` for
 * the 3% entry settle, and a rounded box clips its overflow to the curve —
 * so the 8% parallax overscan (§26) on the case hero is cut by the corner
 * rather than squaring it off. That mattered less when the mat held the
 * image 8px clear of the arc; flush, the image corner IS the arc, and the
 * clip is now the only thing standing between the overscan and four square
 * corners. Deleting the `overflow` above would break it silently.
 *
 * --frame-width went 2px → 3px in the same pass, and the mat is why. The
 * frame used to be read at 10px wide — 2px of border plus 8px of mount —
 * and losing the mount left a 2px line doing all of that work against a
 * photograph rather than against paper. 3px restores the weight without
 * becoming a mount again. --frame-radius stays 14px; the inner curve is now
 * 11px and it is the photograph's own corner. */
.plate--framed {
  border: var(--frame-width) solid var(--charcoal);
  border-radius: var(--frame-radius);
  padding: 0;
  background: var(--charcoal);
}

.caption,
.media-caption {
  margin-top: var(--sp-1);
  font-size: var(--type-meta);
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-meta);
  text-transform: uppercase;
  color: var(--fg-muted);
}

.media-caption {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--sp-2);
  margin-top: var(--sp-2);
}

/* -----------------------------------------------------------------
 * 6b. Hero map — the region drawing
 *
 * Replaces the hero motion loop (`.hero-media` / `.hero-media__video`),
 * removed with its markup on 2026-08-24. The home hero now carries a
 * drawn map of the region instead of a video: a base image with a vector
 * overlay of connector hairlines, city dots and city labels.
 *
 * It IS the module root — it carries `.reveal` itself and there is no
 * wrapper around it, so it takes the slot's own margin rather than
 * inheriting one. `.hero__media` used to supply that; on the home page
 * `.hero__media` is gone and `.hero-map` stands in its place as a sibling
 * of the hero `.grid` inside `.shell`, full width, --sp-8 below the type
 * column. (`.hero__media` itself survives in §9 — every case study hero
 * still uses it for its full-bleed photograph.)
 *
 * No passe-partout: like the loop before it, the map is a brand device
 * rather than a photograph, so it is not mounted. No fixed aspect ratio
 * either — the base image supplies the height, which is what keeps the
 * overlay in register with it at every width without a single
 * measurement. The artwork is 2400x1350, exactly the overlay's 1600x900
 * viewBox aspect, so the two cannot disagree.
 *
 * Rice Paper is the ground under the base image, and the artwork's own
 * ground is the same Rice Paper with a Sage Mist landmass — so what shows
 * in the gap before the JPEG decodes is the colour the JPEG is about to
 * paint there anyway. There is no flash of a different surface.
 *
 * THE BAND UNDER IT WENT CANOPY ON 24 AUG 2026 (V2.2), so the block is now
 * a Rice Paper rectangle on a dark ground rather than paper on paper. Two
 * consequences, both deliberate:
 *
 *   - The `background` above stops being belt-and-braces and starts being
 *     load-bearing. Without it the undecoded gap would show Canopy and
 *     then flip to Rice Paper, which is the one flash this rule exists to
 *     prevent. It is not a leftover; do not remove it as redundant.
 *   - There IS a seam at the edges of the block now, and it is the frame.
 *     No border, no mount, no radius was added: the map is a brand device
 *     rather than a photograph — the reason it was never given a
 *     passe-partout in the first place — and its own ground reads as a
 *     deliberate plate against Canopy without help. Adding the house
 *     hairline here would put a --rule-strong line (white at 24%) between
 *     a light rectangle and a dark band, where it would be invisible
 *     against the artwork and pointless against the ground.
 *
 * Nothing drawn on the map moved: the overlay is `inset: 0` over the base,
 * so every hairline, dot and label still sits ON the artwork's own Sage
 * Mist landmass at the ratios given below, and none of them ever meets
 * Canopy.
 * --------------------------------------------------------------- */

.hero-map {
  position: relative;
  width: 100%;
  margin-top: var(--sp-8);
  background: var(--paper);
  /* Clips the 1% settle of the overlay entry below. At rest the overlay
   * is exactly its own box, so nothing is cropped by this. */
  overflow: hidden;
}

/* Insurance for the case where the map is later moved inside the hero
 * `.grid` rather than sitting beside it: without a span it would take a
 * single 1/12 column. Costs one rule and removes a whole class of
 * silent breakage. */
.grid > .hero-map {
  grid-column: 1 / -1;
}

.hero-map__base {
  display: block;
  width: 100%;
  height: auto;
}

/* The overlay is stretched over the base rather than sized: the SVG's
 * own viewBox (0 0 1600 900) does the fitting, so every line, dot and
 * label keeps its position relative to the map at any width. It is
 * decoration over an image that already carries the alt text, so it
 * never takes a pointer. */
.hero-map__overlay {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}

/* Hairlines in Canopy. Stroke width is in viewBox units, so it scales
 * with the block exactly as the ring in §28 does — 2 units renders at
 * about 1.8px against a 1440px container and thins from there. Below the
 * breakpoint it is drawn a shade heavier, for the reason given on the
 * label rule below: at 360px a 2-unit stroke lands at 0.45px and
 * antialiases away to almost nothing.
 *
 * Canopy on the artwork's Sage Mist landmass is 4.31:1, which the house
 * rule in tokens.css §1 permits for exactly this and nothing else: a
 * hairline. No text is ever drawn in this pair. */
.hero-map__line {
  fill: none;
  stroke: var(--canopy);
  stroke-width: 2.6;
  stroke-linecap: round;
  opacity: 0.7;
}

/* A charcoal dot inside a Rice Paper collar, so a city sitting on a
 * dark passage of the base artwork still reads as a dot and not as a
 * smudge. `paint-order` puts the stroke behind the fill, which makes
 * the collar a 2-unit ring OUTSIDE the dot rather than a 2-unit bite
 * taken out of it. */
.hero-map__dot {
  fill: var(--charcoal);
  stroke: var(--paper);
  stroke-width: 5;
  paint-order: stroke fill;
}

/* SVG text scales with the box, so a label that is comfortable on a
 * desktop is unreadable on a phone. The size is therefore set the other
 * way round from every other rule in this file: the SMALL screen is the
 * base at 26 viewBox units, and the desktop steps DOWN to 20 once the
 * block is wide enough to carry it. Same min-width idiom as the rest of
 * the sheet, and the value that ships without any media query at all is
 * the legible one.
 *
 * No `text-transform`. The labels are written uppercase in the markup,
 * deliberately: `text-transform` on an SVG <text> element is late and
 * uneven across engines, and a label that silently rendered as "Bangkok"
 * in one browser and "BANGKOK" in another would be a typographic bug
 * nobody would think to look for. The casing is content here, not style.
 * The tracking still is style, and --tr-label (0.14em) is the same 12px
 * uppercase tracking the rest of the site's labels use.
 *
 * Charcoal on the artwork's Sage Mist landmass is 9.41:1. */
.hero-map__label {
  font-family: var(--font);
  font-size: 26px;
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-label);
  fill: var(--charcoal);
}

/* One breakpoint, one step down, for all three: the label to its 20-unit
 * design size, the hairline to 2 and the dot collar to 4. Above 720px the
 * block is wide enough that the drawn values are the rendered ones. */
@media (min-width: 45rem) {
  .hero-map__label {
    font-size: 20px;
  }

  .hero-map__line {
    stroke-width: 2;
  }

  .hero-map__dot {
    stroke-width: 4;
  }
}

/* The gentle reveal. The map block itself arrives on the house entry
 * (§14) like any other `.reveal`; what is added here is a settle on the
 * overlay alone — the annotation layer eases the last 1% into register
 * with the base underneath it. It is the same move `.plate img` makes at
 * 3%, at a third of the distance because a map's labels reading as
 * "landing" is a smaller gesture than a photograph settling into a mount.
 *
 * TRANSFORM ONLY, AND DELIBERATELY NO OPACITY. The overlay is a child of
 * whatever `.reveal` holds it, and opacity composites down a subtree: an
 * overlay entering at the 0.45 floor inside a block entering at 0.45
 * would render at 0.20 and break the floor rule, exactly as a word inside
 * a faded heading would (§27). The parent carries the fade; the child
 * carries the movement, and the two never multiply. */
.js .reveal .hero-map__overlay,
.js [data-reveal-stagger] .hero-map__overlay {
  transform: scale(1.01);
  transition: transform var(--dur-reveal) var(--ease) var(--reveal-delay, 0ms);
}

.js .reveal.is-in .hero-map__overlay,
.js [data-reveal-stagger].is-in .hero-map__overlay {
  transform: none;
}

/* -----------------------------------------------------------------
 * 7. Rules and ticks — the datum lines everything measures from
 * --------------------------------------------------------------- */

.rule {
  border: 0;
  border-top: var(--hair) solid var(--rule-strong);
  margin: 0;
}

.rule--tick {
  position: relative;
}

/* The tick is the one place the accent appears as pure geometry: green
 * on white, white on green, sage on charcoal. */
.rule--tick::before {
  content: "";
  position: absolute;
  top: -3px;
  left: 0;
  width: var(--hair);
  height: 7px;
  background: var(--accent);
}

/* -----------------------------------------------------------------
 * 8. Header
 * --------------------------------------------------------------- */

.site-header {
  position: sticky;
  top: 0;
  z-index: 40;
  background: var(--bg);
  border-bottom: var(--hair) solid var(--rule);
}

/* 4rem is a shared constant, not a local one: `.mobile-menu` opens from
 * `inset: 4rem 0 0` and `.section-index` sticks at `top: 4rem`, so the
 * three move together or not at all. The V2.1 20% enlargement was sized to
 * fit inside it rather than to push it — the tallest thing in the bar is
 * the nav link at ~46px (14.4px of type on 1.2 line-height, 0.875rem of
 * padding either side, plus its 1px underline), and the 39.6px wordmark
 * is shorter than that. Nothing here had to move. */
.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-3);
  min-height: 4rem;
}

.header-right {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
}

.nav {
  display: none;
  gap: var(--sp-3);
}

@media (min-width: 56rem) {
  .nav {
    display: flex;
  }
}

/* Idle nav is charcoal at 72% (6.03:1 on Rice Paper); hover and the
 * current page take the accent. The current page also keeps its
 * underline, so state is never carried by colour alone.
 *
 * Enlarged 20% for V2.1 alongside the wordmark. Written as a calc against
 * --type-label rather than as a flat 0.9rem so the nav stays tied to the
 * label scale: move the token and the nav moves with it, still 20% ahead.
 *
 * The first link became RECENT WORK on 24 Aug 2026 (V2.2), which is the
 * longest label the bar has carried, so the fit was re-measured rather than
 * assumed. Rendered widths at 896px — the 56rem breakpoint itself, where
 * the nav first appears and the header is at its tightest:
 *
 *   RECENT WORK   120px  (49px as WORK)     ABOUT      57px
 *   CAPABILITIES  115px                     CONTACT    79px
 *
 * 371px of type plus three --sp-3 gaps is a 443px bar, up from 372px. The
 * header's inner box is 881px there and also carries the 99px wordmark and
 * the 37px scroll ring with a --sp-3 either side of the group, so 627px is
 * spoken for and 254px is left over. The gap is therefore UNCHANGED — the
 * 71px the longer label costs is absorbed by air that was already there,
 * and the type size is never the thing that moves. Header height is 65px
 * at every width, before and after.
 *
 * `white-space: nowrap` is set anyway, because a wrapped nav link would
 * grow the header and drag `.mobile-menu` and `.section-index` out of
 * register with it. Cheaper to forbid than to detect. */
.nav a {
  display: block;
  font-size: calc(var(--type-label) * 1.2); /* 12 → 14.4 */
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-label);
  text-transform: uppercase;
  white-space: nowrap;
  padding-block: 0.875rem;
  color: var(--fg-muted);
  border-bottom: var(--hair) solid transparent;
  transition:
    color var(--dur-fast) var(--ease),
    border-color var(--dur-fast) var(--ease);
}

.nav a:hover {
  color: var(--accent);
}

.nav a[aria-current="page"] {
  color: var(--accent);
  border-bottom-color: var(--accent);
}

/* Menu toggle — two hairlines, no icon set. Q27 answered "none". */
.menu-toggle {
  display: inline-flex;
  flex-direction: column;
  justify-content: center;
  gap: 5px;
  width: 2.75rem;
  height: 2.75rem;
  margin-right: -0.75rem;
}

@media (min-width: 56rem) {
  .menu-toggle {
    display: none;
  }
}

/* Bars are targeted by class, never by `span` or child position — the
 * button's accessible name is an aria-label precisely so no text node
 * can be mistaken for a bar. */
.menu-toggle__bar {
  display: block;
  width: 1.25rem;
  height: var(--hair);
  margin-inline: auto;
  background: var(--fg);
  transition: transform var(--dur) var(--ease);
}

.menu-toggle[aria-expanded="true"] .menu-toggle__bar:first-child {
  transform: translateY(3px) rotate(45deg);
}

.menu-toggle[aria-expanded="true"] .menu-toggle__bar:last-child {
  transform: translateY(-3px) rotate(-45deg);
}

/* Mobile drawer. 44px minimum targets. */
.mobile-menu {
  position: fixed;
  inset: 4rem 0 0;
  z-index: 39;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  border-top: var(--hair) solid var(--rule);
  transform: translateY(-100%);
  visibility: hidden;
  transition:
    transform var(--dur-slow) var(--ease),
    visibility var(--dur-slow);
}

.mobile-menu[data-open="true"] {
  transform: translateY(0);
  visibility: visible;
}

@media (min-width: 56rem) {
  .mobile-menu {
    display: none;
  }
}

.mobile-menu__inner {
  width: 100%;
  max-width: var(--container);
  margin-inline: auto;
  padding: var(--sp-4) var(--margin) var(--sp-6);
}

/* NOT enlarged with the desktop nav. The V2.1 instruction was to take the
 * nav up 20% "and the mobile menu links if they share it" — they do not:
 * the drawer has always run on --type-sub (22 → 28px), a scale of its own
 * that is already twice the desktop nav's, because a drawer link is a
 * full-width row rather than an item in a bar. Scaling it too would take
 * it to 33px and start wrapping "Capabilities" on a 360px phone.
 *
 * The drawer prints its labels as written rather than uppercasing them, so
 * the V2.2 rename reads "Recent Work" here and RECENT WORK in the bar
 * above. It is the longest row the drawer carries and it does not wrap:
 * measured on a 360px phone, all four rows render at the same 60px height,
 * which is the check that matters — a wrapped row would be taller than its
 * neighbours long before it overflowed anything. */
.mobile-menu a {
  display: flex;
  align-items: baseline;
  gap: var(--sp-2);
  min-height: 2.75rem;
  padding-block: 0.75rem;
  border-bottom: var(--hair) solid var(--rule);
  font-size: var(--type-sub);
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-sub);
}

.mobile-menu a[aria-current="page"] {
  color: var(--accent);
}

.mobile-menu a span {
  font-size: var(--type-meta);
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-meta);
  color: var(--fg-muted);
}

.mobile-menu__email {
  margin-top: var(--sp-4);
  font-size: var(--type-body) !important;
  letter-spacing: 0 !important;
  border-bottom: 0 !important;
}

/* -----------------------------------------------------------------
 * 9. Page hero
 * --------------------------------------------------------------- */

.hero__type {
  position: relative;
  z-index: 1;
  grid-column: 1 / -1;
}

@media (min-width: 64rem) {
  .hero__type {
    grid-column: 1 / span 7;
  }
}

.hero__title {
  margin-top: var(--sp-3);
}

.hero__rule {
  margin-block: var(--sp-4);
  max-width: 22rem;
}

/* THE ORPHANED HALF OF THAT MARGIN (V2.3, 25 Aug 2026).
 *
 * `.hero__rule` is the datum line between a heading and the paragraph under
 * it, and --sp-4 either side is the right distance when there IS a paragraph.
 * Two blocks on the site end on the rule instead — the home page's "Activation
 * Across Markets" header and About's "Long Relationships. Regional Trust." —
 * and in both the lower --sp-4 was 32px of nothing, sitting on top of the
 * --sp-8 the next module already declares. That is half of the dead gap the
 * client saw above the first work row; the other half is in §16.
 *
 * A margin whose job is to separate two things should not be paid when there
 * is only one thing, and the module below always brings its own top margin —
 * so the rule keeps its full spacing wherever it is doing that job, and gives
 * back only the half that is spacing it from nothing. Scoped to a direct child
 * of `.hero__type` so no future rule elsewhere is caught by it. */
.hero__type > .hero__rule:last-child {
  margin-bottom: 0;
}

.hero__lead {
  color: var(--fg-muted);
}

/* On the Canopy hero the lead sits partially over the translucent Infinity
 * Ring. At the muted 78% white it measures 3.31:1 where a ring arc crosses;
 * at full Signal White it holds 4.29:1 over the lightened ground — AA for
 * the lead's large size — so the hero lead alone gives up the muting. */
.band--green .hero__lead {
  color: var(--white);
}

.hero__cta {
  margin-top: var(--sp-4);
}

/* Registration index — the fact column in the hero's right-hand columns.
 *
 * REMOVED FROM THE HOME HERO on 2026-08-24: the home page's Est./Markets/
 * Entities block is gone and the hero there is now a single `.hero__type`
 * column, which needs nothing from this file to degrade — `.hero__type`
 * spans 1/span 7 whether or not anything sits beside it, so columns 8–12
 * simply stay empty exactly as they do in `.hero--compact`.
 *
 * THE RULES BELOW ARE STILL LIVE, and the removal was home-page only —
 * confirmed by the architect on 2026-08-24. Every case study hero uses
 * `.hero__index` for its Client / Markets / Format / Dates table
 * (templates.mjs, renderProject), so deleting them would leave five pages
 * with a fact table auto-placed into a single 1/12 column. If the case
 * heroes ever lose theirs too, this block goes with them. */
.hero__index {
  position: relative;
  z-index: 1;
  grid-column: 1 / -1;
  margin-top: var(--sp-6);
  align-self: end;
}

@media (min-width: 64rem) {
  .hero__index {
    grid-column: 10 / span 3;
    margin-top: 0;
  }
}

.hero__media {
  position: relative;
  z-index: 1;
  margin-top: var(--sp-8);
}

/* Inner pages get a compact hero: no index block, less air. */
.hero--compact {
  padding-bottom: var(--sp-8);
}

/* -----------------------------------------------------------------
 * 10. Market rail — removed
 *
 * The home region ticker (`.rail`, `.rail__inner`, `.rail__item`) — the
 * six-across strip of market names that sat between the hero and the
 * first sage panel — was removed with its markup on 2026-08-24. The
 * regions it listed are now carried by the hero map above it (§6b), which
 * shows them rather than spelling them, so the strip was saying the same
 * thing twice in a weaker form. Nothing replaces it: the hero now runs
 * straight into the work panel.
 *
 * The section number is kept so §11 onward do not all shift by one.
 * --------------------------------------------------------------- */

/* -----------------------------------------------------------------
 * 11. Fact tables, stats, galleries
 * All laid on the 12-column grid so separators fall on structural
 * lines rather than arbitrary fractions.
 * --------------------------------------------------------------- */

.facts {
  width: 100%;
  border-collapse: collapse;
  text-align: left;
}

.facts th,
.facts td {
  padding: 0.75rem 0;
  border-top: var(--hair) solid var(--rule);
  vertical-align: baseline;
}

.facts tr:last-child th,
.facts tr:last-child td {
  border-bottom: var(--hair) solid var(--rule);
}

.facts th {
  width: 38%;
  font-size: var(--type-meta);
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-meta);
  text-transform: uppercase;
  color: var(--fg-muted);
}

.facts td {
  font-size: var(--type-small);
  font-weight: var(--wt-regular);
}

/* Fact tables carry the enquiry address, including inside the green
 * statement band. The underline means the link is not signalled by
 * colour alone on any ground. */
.facts a {
  color: var(--accent);
  border-bottom: var(--hair) solid currentColor;
}

.stats {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--gutter);
  margin-top: var(--sp-8);
  border-top: var(--hair) solid var(--rule);
}

.stats__item {
  padding: var(--sp-3) 0;
  border-bottom: var(--hair) solid var(--rule);
}

@media (min-width: 64rem) {
  .stats {
    grid-template-columns: repeat(var(--cols), 1fr);
  }

  .stats__item {
    grid-column: span 3;
    border-bottom: 0;
    border-left: var(--hair) solid var(--rule);
    padding-left: var(--sp-2);
  }

  .stats__item:first-child {
    border-left: 0;
    padding-left: 0;
  }
}

/* Tabular figures are what make the count-up (§29) free of any movement: a
 * value that counts 0 → 5 rewrites this element about thirty times, and with
 * proportional digits every rewrite would be a different width. Tabular gives
 * every digit the same advance, and the element is a block box in a fixed grid
 * cell, so neither the number nor its label shifts while it counts. */
.stats__value {
  font-size: var(--type-sub);
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-sub);
  font-variant-numeric: tabular-nums;
}

.stats__label {
  margin-top: 0.25rem;
  font-size: var(--type-meta);
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-meta);
  text-transform: uppercase;
  color: var(--fg-muted);
}

.gallery {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--gutter);
  margin-top: var(--sp-8);
}

@media (min-width: 64rem) {
  .gallery {
    grid-template-columns: repeat(var(--cols), 1fr);
  }

  .gallery > * {
    grid-column: span 6;
  }
}

/* -----------------------------------------------------------------
 * 12. Links
 * Green on white (8.15:1), white on green, charcoal on sage, sage on
 * charcoal. The underline is always present, so the link is legible as
 * a link without relying on the colour shift.
 * --------------------------------------------------------------- */

.link {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  min-height: 2.75rem;
  padding-block: 0.5rem;
  color: var(--accent);
  font-size: var(--type-label);
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-label);
  text-transform: uppercase;
  border-bottom: var(--hair) solid currentColor;
  transition: gap var(--dur-fast) var(--ease);
}

.link:hover {
  gap: 0.875rem;
}

.link__arrow {
  font-size: 1em;
  line-height: 1;
}

/* -----------------------------------------------------------------
 * 13. Footer — the charcoal floor, sage text (9.41:1)
 * --------------------------------------------------------------- */

/* `position: relative` and `overflow: hidden` are here for the ring
 * watermark at the foot of this section — the same pair `.band--green`
 * takes in §28 for the same reason, and for the same reason it costs the
 * footer nothing: the floor has no sticky children and nothing that
 * overhangs its edges. */
.site-footer {
  position: relative;
  overflow: hidden;
  padding-block: var(--band) var(--sp-6);
}

/* The footer is not a `.band`, so it does not inherit the
 * `.band > .shell` promotion that lifts content above the ring. It needs
 * its own, or the watermark paints over the addresses. */
.site-footer > .shell {
  position: relative;
  z-index: 1;
}

.footer-lead {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: var(--sp-3);
  padding-bottom: var(--sp-6);
  /* border-bottom removed 1 Sep 2026: the client's tricolour divider artwork
   * (`.footer-divider`, below) took the rule's place. */
}

/* The client's divider artwork under the tagline (1 Sep 2026): three flat
 * segments — soft charcoal-grey, Canopy, Clay — extracted verbatim from the
 * supplied Lines-FA.ai into assets/brand/lines-divider.svg. The colours live
 * in the asset (supplied-artwork rule, as with the client logos): nothing
 * here names them, so the stylesheet stays on the five-colour palette. The
 * SVG carries preserveAspectRatio="none", so stretching it to the shell
 * keeps the 60/30/10 split at any width. 3px matches the charcoal frame
 * weight (§6) — the artwork's own 15/1920 proportion would be ~10px at this
 * measure, far heavier than this floor's hairline vocabulary. */
.footer-divider {
  display: block;
  width: 100%;
  height: 3px;
}

.footer-tagline {
  font-size: var(--type-sub);
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-sub);
}

.footer-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-6) var(--gutter);
  margin-top: var(--sp-6);
}

/* SINCE 31 AUG 2026 the three columns no longer stretch across the full
 * shell (span 4 of the 12 tracks each): that parked the Enquiries column
 * directly under the ring watermark's arc. Client direction: tighten the
 * spacing between SINGAPORE, MALAYSIA and ENQUIRIES so the ring gets clear
 * space, without shrinking the ring. The columns now size to their content
 * and pack left with a clamped gap, leaving the floor's right side to the
 * mark. The gap floor keeps the columns readable as three blocks; the ceiling
 * stops wide screens from re-stretching them back under the ring.
 *
 * Three-across is a WIDE-SCREEN arrangement: below the threshold the shell is
 * narrow enough that even a left-packed third column lands under the arc, so
 * the mid-width grid runs two across — Singapore beside Malaysia, Enquiries
 * on its own row at the left edge, the whole right side left to the mark.
 * The gap and the threshold move together: when the client asked for more
 * air between the columns (31 Aug, same evening) the gap went 3vw → 4vw and
 * the three-across floor rose 80rem → 88rem, because wider gaps push the
 * third column right and the clearance to the arc has to survive that. */
@media (min-width: 48rem) {
  .footer-grid {
    grid-template-columns: repeat(2, minmax(0, auto));
    justify-content: start;
    column-gap: clamp(2.25rem, 4vw, 4rem);
  }
}

@media (min-width: 88rem) {
  .footer-grid {
    grid-template-columns: repeat(3, minmax(0, auto));
  }
}

.footer-entity {
  font-size: var(--type-label);
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-label);
  text-transform: uppercase;
}

.footer-grid address {
  margin-top: var(--sp-2);
  font-size: var(--type-small);
  color: var(--fg-muted);
}

/* The enquiry address steps up from sage-78 to solid sage and takes an
 * underline, so it reads as the one actionable line in the block. */
.footer-grid address a {
  color: var(--accent);
  border-bottom: var(--hair) solid currentColor;
}

/* `.footer-legal` — the registered-entity row that used to close the
 * footer with a rule above it — was removed with its markup on
 * 2026-08-24. Nothing takes its place: `.footer-grid` is now the last
 * block in the floor and the footer's own --sp-6 bottom padding closes
 * it, which is why no spacing had to be moved anywhere to compensate. */

/* The footer wordmark is a <use> reference to the header's paths and
 * inherits the footer's sage currentColor. Held smaller than the header
 * mark so the floor stays quiet — and smaller again since 2026-08-19,
 * because the `.footer-entity` line below it now carries the name in
 * readable text. The mark is a brand device here, not the label. */
.footer-logo {
  height: 1.125rem;
  width: auto;
  margin-bottom: var(--sp-2);
}

/* The footer ring — the brand symbol, on the floor.
 *
 * THE FILLED MARK SINCE 31 AUG 2026 (V2.6). It was a hairline outline of the
 * ring until then, and the argument for keeping it one — a floor is drawn in
 * the same hairline as the rules and fact tables above it — has been overtaken
 * by the brand guide, which supplies the Infinity Ring in ONE form only: the
 * filled interlocked symbol, in Charcoal, Signal White and Sage Mist. There is
 * no outline colourway to be faithful to. The client has directed the footer
 * carry the guide-compliant mark, so it now emits the same artwork the hero
 * does (templates.mjs) in Signal White, the guide's pairing with a charcoal
 * ground.
 *
 * NOT ONE DECLARATION IN THIS BLOCK CHANGED WITH IT, which is the useful part.
 * The wrapper sizes the SVG by width and lets height follow, and the two
 * artworks' aspects are within 0.6% of each other (1828.51:1080 against the
 * outline box's 457.62:271.93, which was the same 453.62 x 267.93 artwork plus
 * a 2-unit hairline inset). Same clamp, same translate, same 20%: the mark
 * lands in the same place at the same size, and only its ink changed.
 *
 * It is anchored to the bottom-right corner. UNTIL 31 AUG 2026 (LATER THE
 * SAME DAY AS V2.6) it was cropped hard by both edges — translate 22% 34% —
 * so it read as a corner device rather than a whole object. The client then
 * supplied a reference (`01_Project_Inputs/04_Updated_Branding/5. Footer
 * Logo/` — at the project root until the 31 Aug tidy, the ROOTS & KOON email
 * signature) directing a mostly-whole mark cut only at the bottom and the
 * right — and then pointed at the working file itself, `logo.ai`, as the
 * authority. The translate below is MEASURED FROM THAT FILE, not eyeballed:
 * the artwork's PDF form (object 30) places the pair's bounding box at
 * x 370.387..621.995 on a 600pt-wide band and y -32.772..115.839 on a
 * 120pt-tall one, so the client's own composition bleeds 21.995pt of the
 * mark's 251.61pt width off the right (8.74%) and 32.772pt of its 148.61pt
 * height off the bottom (22.05%). No crop on the top or the left. The same
 * extraction proved the file's ring IS the site's inlined artwork — every
 * Bezier control point matches at 7.267x — so only the composition needed
 * to move, not the geometry. The file inks the rings in Rice Paper at 7%
 * alpha over Canopy; on this Charcoal floor the established Signal White
 * at the 0.2 below lands the same visual weight, so the ink stays.
 *
 * `translate` carries the crop rather than negative offsets, so nothing is
 * asked to lay out beyond the footer's box and the element cannot widen
 * the page — it is out of flow from the first frame and clipped by the
 * `overflow: hidden` above. It adds no height to the footer and moves no
 * content: aria-hidden decoration, pointer-events off.
 *
 * THE 20% IS A LEGIBILITY NUMBER, NOT A TASTE ONE, and it carries more weight
 * now than it did under a hairline: a filled mark inks the area the outline
 * only edged. Signal White at 0.2 composites on the Charcoal floor to about
 * rgb 73 77 74 — written out in words rather than as a colour literal, because
 * the palette guard in tools/qa.mjs reads comments too and is right to — and
 * the sage the addresses are set in reads 4.95:1 against THAT. Still clear of
 * 4.5:1, and only ever asked of the sliver of the mark the columns overlap.
 * Raise this value and that sum has to be redone.
 *
 * Its fill is in §28, where the ring system lives, rather than split between
 * here and there. */
/* MOBILE-FIRST SINCE 1 SEP 2026 (client): on phones the footer stacks to one
 * column and the enquiries email is the last block, so the desktop-size mark
 * ran underneath it. The client's direction: on mobile the ring should sit
 * BESIDE the email, not behind it. The base width below is that phone size —
 * small enough that the mark occupies the corner to the right of the email
 * with clear space between them; the desktop clamp returns at the same
 * 48rem breakpoint the footer grid itself changes at. Crop and opacity are
 * shared by both sizes — only the width steps. */
.footer-ring {
  position: absolute;
  z-index: 0;
  right: 0;
  bottom: 0;
  width: 10rem;
  translate: 8.74% 22.05%;
  opacity: 0.2;
  pointer-events: none;
}

@media (min-width: 48rem) {
  .footer-ring {
    width: clamp(18rem, 34vw, 30rem);
  }
}

.footer-ring svg {
  display: block;
  width: 100%;
  height: auto;
}

/* -----------------------------------------------------------------
 * 14. Scroll transitions — the staggered section entry
 *
 * Replaces the V1/V2 single-block fade. As each section reaches the
 * viewport its children arrive one after another on a short rise, and
 * photographs settle the last 3% of a scale. The client asked for
 * noticeably more animation (2026-08-19); the discipline that keeps it
 * quietly confident rather than busy is in the numbers, all of which
 * live in tokens.css §8:
 *
 *   duration  620ms   one decisive movement per element
 *   stagger    60ms   per child, capped at 8 steps in app.js
 *   distance   20px   upward, transform only
 *   easing     cubic-bezier(0.22, 1, 0.36, 1) — ease-out, no overshoot,
 *                     nothing bounces and nothing spins
 *
 * Four rules this system may never break:
 *
 * 1. TRANSFORM AND OPACITY ONLY. Both are composited off the main
 *    thread, so a stagger across 24 logo cells costs no layout and no
 *    paint. Nothing here animates height, margin, top or width, so
 *    scroll position can never shift under the reader's finger — every
 *    element occupies its final box from first paint.
 * 2. NO BLANK SCREENS. Entry opacity is --reveal-floor (0.45), never 0.
 *    Text is legible from the first frame at any scroll speed; a reader
 *    who lands mid-document, jumps to a #hash, or scrolls faster than
 *    the observer fires still sees content, never an empty band.
 * 3. NOTHING DEPENDS ON JAVASCRIPT. Every rule below is scoped to `.js`,
 *    which app.js sets on the documentElement before first paint. With
 *    JavaScript off nothing is transformed or faded at all.
 * 4. REDUCED MOTION REMOVES IT ENTIRELY — see the block at the end of
 *    this section. Not a shorter animation: no animation, content shown
 *    in its final state.
 *
 * `--reveal-delay` is the per-element stagger offset. app.js sets it
 * inline the moment an element enters; it defaults to 0ms so any element
 * the observer never reaches still transitions correctly.
 * --------------------------------------------------------------- */

.js .reveal {
  opacity: var(--reveal-floor);
  transform: translateY(var(--reveal-rise));
  transition:
    opacity var(--dur-reveal) var(--ease) var(--reveal-delay, 0ms),
    transform var(--dur-reveal) var(--ease) var(--reveal-delay, 0ms);
}

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

/* Collections stagger their own children instead of arriving as one
 * block: the logo wall, the stat row, the capability index, the values
 * list, the capability galleries. The container itself is pinned static
 * so a child is never moved twice — 20px inside a parent that is also
 * travelling 20px reads as a lurch. */
.js .reveal[data-reveal-stagger] {
  opacity: 1;
  transform: none;
  transition: none;
}

.js [data-reveal-stagger] > * {
  opacity: var(--reveal-floor);
  transform: translateY(var(--reveal-rise));
  transition:
    opacity var(--dur-reveal) var(--ease) var(--reveal-delay, 0ms),
    transform var(--dur-reveal) var(--ease) var(--reveal-delay, 0ms);
}

.js [data-reveal-stagger].is-in > * {
  opacity: 1;
  transform: none;
}

/* The photographic settle. A mounted image starts 3% over and eases back
 * into its mount; the mount, its hairline and the layout never move.
 * `.plate` clips only so the overscan cannot spill across the
 * passe-partout mid-transition — at rest the image is exactly its own
 * box, so the static rendering is unchanged. The opacity leg keeps the
 * work-row hover fade alive, which a bare transform list would drop. */
.js .reveal .plate img {
  transform: scale(var(--reveal-scale));
  transition:
    transform var(--dur-reveal) var(--ease) var(--reveal-delay, 0ms),
    opacity var(--dur) var(--ease);
}

.js .reveal.is-in .plate img,
.js [data-reveal-stagger].is-in .plate img {
  transform: none;
}

/* Set only for the length of the transition, cleared in app.js on
 * transitionend: a permanent will-change would hold a compositor layer
 * for every plate on the page. */
.js .is-transitioning,
.js [data-reveal-stagger].is-transitioning > * {
  will-change: opacity, transform;
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.001ms !important;
    transition-delay: 0s !important;
  }

  /* Everything the scroll system touches is returned to its finished
   * state — visible, unmoved, unscaled — whether or not the observer
   * ever ran. Nothing is left hidden waiting for a class. */
  .js .reveal,
  .js [data-reveal-stagger] > *,
  .js .reveal .plate img {
    opacity: 1;
    transform: none;
  }

  .js .is-transitioning,
  .js [data-reveal-stagger].is-transitioning > * {
    will-change: auto;
  }
}

/* -----------------------------------------------------------------
 * 15. Section index
 * Sticky sub-navigation on Capabilities and About. Sits directly under
 * the header, so its offset must track the header's 4rem height.
 * --------------------------------------------------------------- */

.section-index {
  position: sticky;
  top: 4rem;
  z-index: 30;
  background: var(--bg);
  border-top: var(--hair) solid var(--rule);
  border-bottom: var(--hair) solid var(--rule);
}

.section-index__inner {
  display: flex;
  overflow-x: auto;
  scrollbar-width: none;
}

.section-index__inner::-webkit-scrollbar {
  display: none;
}

/* ONE CHILD SINCE 25 AUG 2026 (client, V2.3). Each entry used to be a
 * `<span>0N</span>` followed by the label, and the `gap: 0.5rem` here was the
 * air between them. The number span is gone from `sectionIndex()` in
 * templates.mjs and `number` is gone from both source arrays, so the anchor
 * now holds a single text run — a gap between one item and nothing is a value
 * that renders nowhere and that nobody could later delete with confidence, so
 * it goes with the span it spaced. `display: flex` stays: it is what gives the
 * row its 2.75rem minimum target height, which is a WCAG obligation rather
 * than a consequence of having had two children. */
.section-index a {
  display: flex;
  align-items: center;
  flex: none;
  min-height: 2.75rem;
  padding: 0.875rem var(--sp-2) 0.875rem 0;
  margin-right: var(--sp-3);
  font-size: var(--type-label);
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-label);
  text-transform: uppercase;
  color: var(--fg-muted);
  border-bottom: 2px solid transparent;
  transition:
    color var(--dur-fast) var(--ease),
    border-color var(--dur-fast) var(--ease);
}

.section-index a:hover {
  color: var(--accent);
}

.section-index a[aria-current="true"] {
  color: var(--accent);
  border-bottom-color: var(--accent);
}

/* `.section-index a span` — the "01".."04" that led each entry — was removed
 * with its markup on 2026-08-25 (V2.3). It is worth recording what it cost to
 * get right, because the number itself was never the difficult part: V1 set it
 * in --ink-500 at 4.13:1 on paper, which failed AA for normal text, and it had
 * to be moved onto --fg-muted at 6.46:1. Nothing in the index carries a
 * secondary type size any more. */

/* -----------------------------------------------------------------
 * 16. Work index
 * A list, not cards. Repetition is the point: every row is identical
 * so the work, not the layout, supplies the variation.
 * --------------------------------------------------------------- */

/* --sp-6 RATHER THAN --sp-8 SINCE 25 AUG 2026 (client, V2.3): the second half
 * of closing the dead gap above the first work row. The full sequence was 32px
 * of orphaned rule margin (§9) + 64px here + the row's own 48px of top padding
 * = 145px between the tick rule and the first row's meta line, which is most
 * of a phone screen of nothing between a section's header and the section.
 * It is 97px now, and the row padding is the larger part of that — which is
 * correct, because that padding is the list's internal rhythm and every row
 * gets it, header or no header.
 *
 * The list keeps --sp-6 rather than dropping further because the border-top
 * below is a real edge: run the header much closer and the rule and the border
 * read as two lines looking for each other. --sp-8 stays the house distance
 * for a module that follows a header block ending in a PARAGRAPH — the
 * capability index, the values list and the logo walls all still take it, and
 * all of them sit under a block whose last line is body copy rather than a
 * hairline. */
.work-list {
  margin-top: var(--sp-6);
  border-top: var(--hair) solid var(--rule);
}

.work-row {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--gutter);
  padding-block: var(--sp-6);
  border-bottom: var(--hair) solid var(--rule);
}

@media (min-width: 64rem) {
  .work-row {
    grid-template-columns: repeat(var(--cols), 1fr);
    align-items: start;
  }

  .work-row__text {
    grid-column: 1 / span 5;
  }

  .work-row__media {
    grid-column: 7 / span 6;
  }
}

.work-row__title {
  margin-top: var(--sp-2);
}

.work-row__cta {
  margin-top: var(--sp-3);
}

.work-row__media img {
  transition: opacity var(--dur) var(--ease);
}

.work-row a:hover .work-row__media img,
.work-row__media a:hover img {
  opacity: 0.88;
}

/* -----------------------------------------------------------------
 * 17. Capability index and sections
 * --------------------------------------------------------------- */

.capability-index {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--gutter);
  margin-top: var(--sp-8);
  border-top: var(--hair) solid var(--rule);
}

.capability-index__item {
  padding: var(--sp-3) 0;
  border-bottom: var(--hair) solid var(--rule);
}

@media (min-width: 64rem) {
  .capability-index {
    grid-template-columns: repeat(var(--cols), 1fr);
  }

  .capability-index__item {
    grid-column: span 3;
    border-bottom: 0;
    border-left: var(--hair) solid var(--rule);
    padding-left: var(--sp-2);
  }

  .capability-index__item:first-child {
    border-left: 0;
    padding-left: 0;
  }
}

/* `.capability-index__number` — the "01".."04" that opened each cell — was
 * removed with its markup on 2026-08-25 (V2.3), alongside the same removal in
 * the sticky capability index (§15) and the section markers. The title is now
 * the cell's first line, and it loses the 0.25rem that separated it from the
 * number: that margin existed to hold two lines apart, and with nothing above
 * it, it was pushing the title off `.capability-index__item`'s own --sp-3
 * padding edge and out of horizontal-rule alignment with the cell borders
 * beside it. The cell is otherwise untouched — same grid, same span 3, same
 * left rules on desktop. */
.capability-index__title {
  font-size: var(--type-body);
  font-weight: var(--wt-medium);
}

.capability-index__summary {
  margin-top: var(--sp-1);
  font-size: var(--type-small);
  color: var(--fg-muted);
}

.capability {
  border-top: var(--hair) solid var(--rule);
  padding-block: var(--band);
}

.capability:first-of-type {
  border-top: 0;
}

.capability__examples {
  grid-column: 1 / -1;
  margin-top: var(--sp-4);
}

@media (min-width: 64rem) {
  .capability__examples {
    grid-column: 8 / span 5;
    margin-top: 0;
  }
}

.capability__examples li {
  padding-block: 0.625rem;
  border-top: var(--hair) solid var(--rule);
  font-size: var(--type-small);
  color: var(--fg-muted);
}

.capability__examples li:last-child {
  border-bottom: var(--hair) solid var(--rule);
}

/* Selected-programme disclosure.
 *
 * Everything below applies only after app.js has upgraded a programme into
 * a button — `.is-interactive`, `.programmes__toggle`, `.programmes__panel`
 * and `.programmes__sign` do not exist in the served HTML. With no script
 * the list keeps exactly the rules above it and reads as it always did.
 *
 * The row's padding MOVES from the <li> onto the button so the whole row is
 * the target rather than a text-sized strip inside it: 0.625rem either side
 * of a 15px line is a ~42px target, comfortably over the WCAG 2.5.8 minimum
 * of 24px. The <li> keeps its hairline and its colour, which the button
 * inherits, so an upgraded row is pixel-identical to a plain one at rest. */
.capability__examples li.is-interactive {
  padding-block: 0;
}

.programmes__toggle {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-2);
  width: 100%;
  padding-block: 0.625rem;
  text-align: left;
  transition: color var(--dur) var(--ease);
}

/* --fg on --bg is 16.35:1 on paper; the resting --fg-muted is 5.74:1. The
 * hover state darkens rather than tints, so no fifth colour appears. */
.programmes__toggle:hover {
  color: var(--fg);
}

/* A plus that becomes a minus, drawn from two hairlines so it matches the
 * rules around it exactly. Only the vertical stroke moves, and it moves by
 * transform — no layout, nothing for the reduced-motion block to undo
 * beyond killing the duration it already kills globally. */
.programmes__sign {
  position: relative;
  flex: none;
  width: 0.6875rem;
  height: 0.6875rem;
}

.programmes__sign::before,
.programmes__sign::after {
  content: "";
  position: absolute;
  background: currentColor;
}

.programmes__sign::before {
  top: 50%;
  right: 0;
  left: 0;
  height: var(--hair);
  margin-top: calc(var(--hair) / -2);
}

.programmes__sign::after {
  top: 0;
  bottom: 0;
  left: 50%;
  width: var(--hair);
  margin-left: calc(var(--hair) / -2);
  transition: transform var(--dur) var(--ease);
}

.programmes__toggle[aria-expanded="true"] .programmes__sign::after {
  transform: scaleY(0);
}

/* The panel is shown and hidden with the `hidden` attribute — no height
 * animation, so opening one can never shift the page under a reader. The
 * padding is on the panel rather than the row above it, so a closed
 * programme adds nothing at all to the list's resting height. */
.programmes__panel {
  padding-bottom: var(--sp-2);
}

/* -----------------------------------------------------------------
 * 18. Charter and values
 * --------------------------------------------------------------- */

/* The purpose statement: label, statement, supporting line.
 *
 * This class used to serve purpose AND methodology, printed one after the
 * other in the same block — the questionnaire stated both ("Cultivating
 * Success" / "Empowering Potential") and treated the second as how the
 * first is delivered. The methodology half was removed with its markup on
 * 2026-08-24, so the pattern now runs once rather than twice and
 * `.charter__label` — which existed only to space the second label away
 * from the first line's supporting text — has gone with it. Nothing else
 * used it. The rules below are unchanged: they always described a single
 * statement and simply appeared twice. */
.charter__purpose {
  margin-top: var(--sp-2);
  font-size: var(--type-section);
  font-weight: var(--wt-medium);
  line-height: var(--lh-section);
  letter-spacing: var(--tr-section);
}

/* `.charter__note` — TOMBSTONE, second time. The paragraph under the purpose
 * statement and the class that spaced it are off the page again as of
 * 2026-09-02 (V2.7): the client removed the purpose body a second time, and
 * the rule follows its only caller out, exactly as in V2.3.
 *
 * The full round trip, kept because it is this block's best documentation:
 * deleted 2026-08-25 (V2.3) when the client took the first supporting
 * paragraph off the page — a rule with exactly one caller and no second
 * pattern to serve is a rule nobody can later change with confidence, the
 * same test `.charter__label` was held to a day earlier; restored 2026-08-31
 * (V2.5) when the client moved the Our Culture body into the slot, carrying
 * one declaration and nothing more — margin-top: --sp-3, the gap above
 * itself; deleted again 2026-09-02 (V2.7) with that paragraph's removal.
 * Both retired wordings are on the record in the charter block of
 * site-data.mjs.
 *
 * Nothing replaces the spacing, same as V2.3: the --sp-3 was the paragraph's
 * OWN top margin (every gap in the charter sequence is declared by the
 * element BELOW it), so the block ends on `.charter__purpose`, which
 * declares no bottom margin. `.values` further down still opens on its own
 * --sp-8, untouched by any of the three rounds. If a paragraph ever returns
 * to this slot, the class returns with it carrying that same single
 * declaration — and no typography, because the markup wears `.body` (§4),
 * the site-wide body role; a `.charter__note` that redeclared size, measure
 * and colour would be a second, quieter body role that could drift from the
 * first. */

/* Three values on a 12-column grid. They span 3, not 4: at span 4 the
 * dividers land on columns 5 and 9, which are not rule positions, so
 * they read as unrelated lines sitting between the structure. At span 3
 * they fall exactly on the column 4 and 7 rules. Columns 10–12 stay
 * empty, matching the hero's quiet right-hand side.
 *
 * A ul since 14 Sep 2026 (client, V2.8) — the dl's bold terms are off the
 * page and each description stands alone as an li at the terms' own
 * body-size medium weight (the site's emphasis weight throughout;
 * --wt-bold has no callers anywhere in this sheet, deliberately). The
 * list-style and padding zeroes below are the ul's UA defaults being
 * cancelled; the dl needed neither. */
.values {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--gutter);
  margin-top: var(--sp-8);
  padding: 0;
  border-top: var(--hair) solid var(--rule);
  list-style: none;
}

.values > * {
  padding-block: var(--sp-3);
  border-bottom: var(--hair) solid var(--rule);
}

@media (min-width: 48rem) {
  .values {
    grid-template-columns: repeat(var(--cols), 1fr);
  }

  .values > * {
    grid-column: span 3;
    border-bottom: 0;
    border-left: var(--hair) solid var(--rule);
    padding-left: var(--sp-2);
  }

  .values > *:first-child {
    border-left: 0;
    padding-left: 0;
  }
}

.values li {
  font-size: var(--type-body);
  font-weight: var(--wt-medium);
}

/* -----------------------------------------------------------------
 * 19. Client logo field
 * Logos keep the colour they were supplied in. Recolouring a partner's
 * mark is a brand-compliance decision that is not ours to make, and the
 * updated branding changes nothing about that.
 *
 * Each cell is explicitly Rice Paper rather than --bg-raised, so a
 * supplied light-ground mark still reads if the wall is ever placed on
 * a sage, Canopy or charcoal band. The logo wall is the one component
 * that does not follow its ground. (It was Signal White until V2.1; the
 * cell is a background, and under the new palette every background is
 * Rice Paper. The marks themselves are untouched — a client's artwork
 * keeps the colour it was supplied in, which is the whole point of this
 * section.)
 * --------------------------------------------------------------- */

/* Two columns across the whole wall, four from the tablet breakpoint up
 * (client request, 2026-08-19). Twenty-four marks divide exactly both
 * ways — 4 x 6 on desktop, 2 x 12 on a phone — so the wall is always a
 * complete rectangle with no orphan cell on the last row. Four cells to
 * a 1440px container gives each mark a 320px cell — the cell the
 * per-brand optical sizes below are calibrated against.
 *
 * NO PAGE RENDERS THIS DRESS AS OF 24 AUG 2026 (V2.2): the home wall took
 * the borderless thirty-mark treatment in §19b and the two client walls
 * became one wall. The block stays because it is the base every rule in
 * §19b modifies — the grid, the cell, the equal-area sizing and the
 * intrinsic dimensions are all declared here and inherited there. What
 * §19b switches off is four border properties and the column counts;
 * delete this and the plain wall has nothing to be plain about. */
.logo-field {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  margin-top: var(--sp-8);
  border-top: var(--hair) solid var(--rule);
  border-left: var(--hair) solid var(--rule);
}

@media (min-width: 48rem) {
  .logo-field {
    grid-template-columns: repeat(4, 1fr);
  }
}

/* Cell proportion follows the column count. At two columns a cell is
 * narrow, so 3:2 keeps it from becoming a letterbox; at four columns a
 * cell is ~320px wide and 3:2 would make a 1280px-tall wall, so the cell
 * takes the house ratio instead and the whole wall lands near 800px.
 * The mark inside is centred either way, and sized per-brand by the rule
 * below rather than by the cell. */
.logo-field__cell {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 3 / 2;
  padding: var(--sp-2);
  border-right: var(--hair) solid var(--rule);
  border-bottom: var(--hair) solid var(--rule);
  background: var(--paper);
}

@media (min-width: 48rem) {
  .logo-field__cell {
    aspect-ratio: var(--ratio-house);
  }
}

/* Optical sizing, not uniform sizing.
 *
 * A single capped height across marks running from 1.07:1 to 8.42:1 makes
 * rendered area track aspect ratio, so Shell and AWS read tiny while Thales
 * and Gigamon read enormous. Each mark now carries its own `--logo-size` —
 * a rendered width derived from equal area, computed in site-data.mjs and
 * printed on the element as a static value. Nothing is measured in the
 * browser.
 *
 * The width is a percentage of the cell's content box, so the whole wall
 * scales together and the equal-area relationship survives every breakpoint;
 * `height: auto` keeps each mark on its natural ratio, so no client's
 * artwork is ever distorted. The 55%-of-cell height ceiling is enforced when
 * the numbers are generated rather than by a `max-height` here, which with a
 * set width could only be honoured by squashing the mark.
 *
 * `--logo-cap` is the retina ceiling, present only on marks whose supplied
 * file is too small to reach the target at 2x — Montran alone today. */
.logo-field__cell img {
  width: var(--logo-size, 100%);
  max-width: var(--logo-cap, 100%);
  height: auto;
  object-fit: contain;
}

/* -----------------------------------------------------------------
 * 19b. The plain logo field — About, and since V2.2 the home page too
 *
 * The same wall with the grid taken off it. The ruling read as a
 * spreadsheet under "Long relationships. Regional trust.", so this variant
 * drops it (client, 24 Aug 2026).
 *
 * BOTH CLIENT WALLS ARE NOW THIS ONE (client, same day, second pass). The
 * home page's "Trusted by Global Brands." section carried the ruled
 * twenty-four; it now calls exactly what About calls, thirty marks and no
 * rules. Two pages showing two different sets of client marks read as two
 * different client lists — the wall is the same evidence in both places, so
 * it is the same wall. Nothing in this section had to change to take the
 * second caller: the variant was always independent of the array it was
 * handed, and it is the same array.
 *
 * The base wall draws its grid from FOUR properties on TWO elements —
 * border-top and border-left on the container, border-right and
 * border-bottom on every cell, which is the standard trick for a ruled
 * grid with no doubled lines and no trailing edge. All four have to be
 * neutralised or the wall keeps two of its outer edges and looks broken
 * rather than plain.
 *
 * Thirty marks rather than the home wall's twenty-four, so the column
 * counts are the ones that divide 30: six across on a desktop for five
 * complete rows, three on a tablet for ten, two on a phone for fifteen.
 * No breakpoint leaves an orphan cell on the last row.
 *
 * NOTE FOR THE ARCHITECT: the brief asked for six columns with "the same
 * responsive fallbacks as the base grid", which steps 2 → 4 at 48rem. Six
 * columns at 48rem would give each mark a ~114px cell — with the house
 * ratio that is 48px of height, and a mark held at 55% of it renders about
 * 26px tall, which is below reading size for a wordmark. The 3-column step
 * is inserted for that reason and for no other; delete the 48rem block and
 * change 64rem to 48rem to get the literal reading back. */
.logo-field--plain {
  border-top: 0;
  border-left: 0;
}

.logo-field--plain .logo-field__cell {
  border-right: 0;
  border-bottom: 0;
}

@media (min-width: 48rem) {
  .logo-field--plain {
    grid-template-columns: repeat(3, 1fr);
  }
}

@media (min-width: 64rem) {
  .logo-field--plain {
    grid-template-columns: repeat(6, 1fr);
  }
}

/* -----------------------------------------------------------------
 * 20. Reserved / pending content modules — REMOVED 26 AUG 2026 (V2.4)
 *
 * This section styled the one pattern the site used where the client had not
 * yet supplied approved content: a visible, honest reservation rather than
 * invented filler. `.reserved`, `.reserved__row`, `.reserved__slot`,
 * `.reserved__frame` and its label pair, and `.content-status`.
 *
 * It has no callers left. The About page's team module was the last one, and
 * the client dropped it whole on 26 Aug 2026 — the section, the reserved
 * frame, the AI-generated group photograph inside it and the "Content status"
 * line under it. Nothing else on the site was ever reserved: "Our Community",
 * the other module built on these rules, went on 24 Aug 2026.
 *
 * DELETED RATHER THAN LEFT STANDING, which is the test `.charter__label` and
 * `.charter__note` were held to in the two rounds before this one: a rule with
 * no caller and no second pattern to serve is a rule nobody can later change
 * with confidence, because nothing will tell them if they break it. Two of
 * these were already in that state and had been carried anyway —
 * `.reserved__row` and `.reserved__slot` were the three-across portrait
 * variant, unused since 19 Aug 2026 when the client confirmed individual
 * portraits would never be supplied. Their own comment argued they were "part
 * of the markup contract"; there is no markup left to contract with.
 *
 * NOTHING REPLACES THE SPACING and nothing needs to. Every rule here applied
 * to markup that no longer exists, so there is no orphaned margin anywhere —
 * the About page's sections now space themselves through `.band`, `.values`
 * and `.logo-field` like every other page. (`.gallery` stood in that list
 * from 26 to 31 Aug 2026, while the How We Work section was on the page; it
 * went with that section in V2.5 and the point is unchanged.)
 *
 * If a reservation is ever needed again, write it against the module that
 * needs it. The pattern was five selectors and a `:has(img)` fallback; it is
 * recoverable from this file's history in an afternoon, and a new one would
 * want its own shape anyway.
 *
 * The numbering of the sections below is left alone — §21 is still §21. The
 * numbers are addresses used by comments throughout this sheet and by
 * ARCHITECTURE.md, and renumbering twelve sections to close a gap would
 * invalidate every one of those references to save nothing.
 * --------------------------------------------------------------- */

/* -----------------------------------------------------------------
 * 21. Contact
 * --------------------------------------------------------------- */

/* THE OFFICE CARDS — REMOVED 31 AUG 2026 (V2.6)
 *
 * `.office-grid` and its 48rem four-column step, `.office__logo`, the
 * `.office address, .office p` secondary-text pair, `.office .footer-entity`
 * and `.office a` all went with the three-card block that closed the contact
 * page: a Singapore headquarters card, a Malaysia office card and a general
 * enquiries card. The client removed the section because the FOOTER DIRECTLY
 * BELOW IT already carried the same two entities, the same two addresses and
 * the same enquiries email — the page said it twice, a screen apart.
 *
 * DELETED RATHER THAN LEFT STANDING, the test §20 was held to: every one of
 * these selectors named `.office`, nothing else on the site is an office card,
 * and there is no second pattern for them to serve.
 *
 * `.footer-entity` IS NOT HERE, and that is the point of this note. It is §13's
 * rule and the footer's own — three entity lines on ten pages — and the office
 * cards merely borrowed it. What went with them is only the `.office`-scoped
 * override that un-borrowed it: the cards' `.office p` rule was one specificity
 * point heavier and would otherwise have muted the entity names to secondary
 * text. With no `.office` left there is nothing to override, and `.footer-entity`
 * reads on the floor exactly as it always did.
 *
 * NOTHING REPLACES THE SPACING. Every rule here applied to markup inside a band
 * that is itself gone, so no margin is orphaned: the contact page now ends on
 * the enquiry band's own `padding-block: var(--band)`, the same distance to the
 * footer that the eight pages V2.5 shortened already run.
 *
 * The `.band--tight` modifier the section wore is NOT deleted — it belongs to
 * the band primitive, not to this component. See §3.
 * --------------------------------------------------------------- */

/* The demoted enquiry subheader.
 *
 * The section's visible h2 ("A useful first conversation starts with a
 * little context") was a `.section-title` — 28 → 44px, the weight of a
 * page title — for what is really a line of instruction above a form. It
 * is now a paragraph, with the h2 kept as `.sr-only` so the document
 * outline and the band's aria-labelledby survive the demotion. This class
 * styles that paragraph, between `.sub` (22 → 28px) and body (17px) on
 * --type-lead, the 18 → 20px step already in the scale, so no new size is
 * invented for one element.
 *
 * IT IS APPLIED ALONGSIDE `.sub` — the markup is `class="enquiry-subhead
 * sub"`. Both selectors weigh 0-1-0, so this one wins only because §21
 * sits below §4 in the sheet. That is the whole mechanism: this block
 * must stay after the type roles or the subhead silently jumps back to
 * 28px. Every property `.sub` sets is restated here rather than left to
 * cascade, so what this element renders at is readable in one place.
 *
 * Full-strength charcoal rather than --fg-muted: it is a heading in all
 * but name, and 14.24:1 is what separates it from the body copy that
 * follows at 6.03:1. No rule and no tick — `.hero__rule` belonged to the
 * h2, and a demoted line still carrying the datum rule would be louder
 * than the thing it was demoted from.
 *
 * The "Project enquiry" marker above it went at the same time. Nothing
 * orphans: `.marker` and its ring are used by every other section on the
 * site.
 *
 * The top margin is the demoted counterpart of the --sp-3 that
 * `.hero__title` gave the h2. */
.enquiry-subhead {
  margin-top: var(--sp-2);
  font-size: var(--type-lead);
  font-weight: var(--wt-medium);
  line-height: var(--lh-sub);
  letter-spacing: var(--tr-sub);
  color: var(--fg);
}

/* Form fields are hairline-underlined, never boxed. */
.enquiry-form {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-4) var(--gutter);
  margin-top: var(--sp-6);
}

@media (min-width: 48rem) {
  .enquiry-form {
    grid-template-columns: repeat(2, 1fr);
  }

  .field--full {
    grid-column: 1 / -1;
  }

  .enquiry-form__actions {
    grid-column: 1 / -1;
  }
}

.field label {
  display: block;
  font-size: var(--type-meta);
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-meta);
  text-transform: uppercase;
  color: var(--fg-muted);
}

.field input,
.field textarea {
  display: block;
  width: 100%;
  margin-top: 0.375rem;
  padding: 0.5rem 0;
  font-family: inherit;
  font-size: var(--type-body);
  color: var(--fg);
  background: transparent;
  border: 0;
  border-bottom: var(--hair) solid var(--rule-strong);
  border-radius: 0;
  transition: border-color var(--dur-fast) var(--ease);
}

.field textarea {
  resize: vertical;
}

/* Placeholders are the one "disabled" role in the system: charcoal at
 * 65%, 5.11:1 on white — clearly subordinate to entered text at 16.35:1
 * but still legible, where V1's --ink-600 sat at 4.64:1 doing both jobs. */
.field input::placeholder,
.field textarea::placeholder {
  color: var(--fg-disabled);
  opacity: 1;
}

.field input:focus,
.field textarea:focus {
  border-bottom-color: var(--accent);
}

.button-line {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  min-height: 2.75rem;
  padding-block: 0.5rem;
  color: var(--accent);
  font-size: var(--type-label);
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-label);
  text-transform: uppercase;
  border-bottom: var(--hair) solid currentColor;
  transition: gap var(--dur-fast) var(--ease);
}

.button-line:hover {
  gap: 0.875rem;
}

/* Held while an endpoint submission is in flight, so the form cannot be
 * posted twice. --fg-disabled is 5.11:1 on paper, so the button stays
 * legible while it is unavailable. */
.button-line[disabled] {
  color: var(--fg-disabled);
  cursor: default;
}

.button-line[disabled]:hover {
  gap: 0.5rem;
}

/* The honeypot. Removed from the visual and the accessibility tree both
 * — off-screen, aria-hidden and out of the tab order — so only a bot
 * filling every field it finds will put anything in it. app.js aborts
 * the submission silently when it is non-empty. It keeps a real <label>
 * so the form still passes the QA gate's labelling check. */
.field--trap {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.form-status {
  margin-top: var(--sp-2);
  font-size: var(--type-small);
  color: var(--fg-muted);
}

/* -----------------------------------------------------------------
 * 22. Case study
 * --------------------------------------------------------------- */

.case__media {
  position: relative;
  z-index: 1;
  margin-top: var(--sp-8);
}

.next-project {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--gutter);
  margin-top: var(--sp-8);
  padding-top: var(--sp-6);
  border-top: var(--hair) solid var(--rule);
}

@media (min-width: 64rem) {
  .next-project {
    grid-template-columns: repeat(var(--cols), 1fr);
    align-items: start;
  }

  .next-project__text {
    grid-column: 1 / span 5;
  }

  .next-project__media {
    grid-column: 7 / span 6;
  }
}

/* -----------------------------------------------------------------
 * 23. Contact band — REMOVED 31 AUG 2026 (V2.5)
 *
 * This section laid out the repeated closing action: the green statement
 * band that ended the home page, capabilities, about and all five case
 * studies. `.contact-band__statement` held the statement off the marker
 * above it; `.contact-band__meta` placed the Singapore / Malaysia /
 * Enquiries fact table full-width on narrow and in columns 9–12 beside the
 * statement from 64rem.
 *
 * The client removed the band itself, so both classes have no caller left —
 * `contactBand()` is gone from templates.mjs and no other markup ever used
 * them. DELETED RATHER THAN LEFT STANDING, which is the test `.charter__label`
 * (24 Aug), `.charter__note` (25 Aug) and the whole of §20 (26 Aug) were held
 * to: a rule with no caller and no second pattern to serve is a rule nobody
 * can later change with confidence, because nothing will tell them if they
 * break it.
 *
 * NOTHING REPLACES THE SPACING and nothing needs to. Both rules applied to
 * markup that no longer exists, and the pages the band used to close each
 * still end on a full `.band` — its own `padding-block: var(--band)` is the
 * distance to the footer now, the same distance the contact band itself sat
 * behind. `.band--green` stays exactly where it is, in tokens.css with the
 * other grounds: the home hero is still Canopy and still needs it, along with
 * everything §28/§28b hang off that ground for the Infinity Ring.
 *
 * The numbering below is left alone — §24 is still §24 — for the reason the
 * §20 note gives: these numbers are addresses used by comments throughout
 * this sheet and by ARCHITECTURE.md.
 * --------------------------------------------------------------- */

/* -----------------------------------------------------------------
 * 24. Page transition — removed
 *
 * The cross-document Infinity Ring wipe (@view-transition plus the
 * ::view-transition-* rules and the oo-wipe-in / oo-wipe-out keyframes)
 * was removed at the client's request on 2026-08-19. Navigation is now
 * an ordinary instant page load in every browser, which is also what
 * every non-Chromium browser was already doing. Nothing replaces it: the
 * animated arrival now happens inside the page, in §14.
 * --------------------------------------------------------------- */

/* -----------------------------------------------------------------
 * 25. Section placeholder
 * Any remaining Phase 2 stub. qa.mjs counts these so none can ship
 * unnoticed.
 * --------------------------------------------------------------- */

.pending {
  margin-top: var(--sp-6);
  padding: var(--sp-4) 0;
  border-top: var(--hair) solid var(--rule);
  border-bottom: var(--hair) solid var(--rule);
  font-size: var(--type-meta);
  font-weight: var(--wt-medium);
  letter-spacing: var(--tr-meta);
  text-transform: uppercase;
  color: var(--fg-muted);
}

/* -----------------------------------------------------------------
 * 26. Scroll parallax — the drift inside a plate
 *
 * A full-bleed photograph is overscanned by --parallax-scale and drifts
 * --parallax-shift either side of centre as its frame crosses the viewport.
 * The mount, its hairline and the page never move: the only thing that
 * changes is which 92.6% of the file is visible through a frame that is
 * already `overflow: hidden` (§6).
 *
 * Two mechanisms, never both. Where the browser has scroll-driven
 * animations the timeline IS the scroll position — no listener, no script,
 * no main-thread work at all, and `view()` gives exactly the mapping wanted
 * (0% as the frame's top edge reaches the viewport bottom, 100% as its
 * bottom edge leaves the top). Where it does not, app.js writes
 * --parallax-y from one passive rAF-throttled scroll listener, and it
 * feature-detects the same condition so the two can never both be live.
 *
 * `translate` and `scale` are used rather than a `transform` string
 * deliberately: `.js .reveal .plate img` (§14) already owns `transform` for
 * the entry settle, and the individual properties compose with it instead
 * of fighting it — the settle's 1.03 multiplies the 1.08 overscan for the
 * length of the entry and then releases, exactly as it does on any other
 * plate. Nothing rotates, nothing moves horizontally.
 *
 * Which plates drift is a content decision made in templates.mjs: the case
 * hero and the home feature slot, both house-ratio and both full width.
 * A photograph whose composition lives in the outer 4% opts out there
 * (the Dulux stage reveal does) rather than being cropped by motion.
 * --------------------------------------------------------------- */

.js .plate--drift img {
  scale: var(--parallax-scale);
  translate: 0 var(--parallax-y, 0%);
}

@supports (animation-timeline: view()) {
  .js .plate--drift img {
    /* The custom property is left at its default here: with a timeline
     * driving the animation, app.js never runs its fallback and never sets
     * one. Duration is `auto` — the timeline supplies the progress. */
    animation: media-drift linear both;
    animation-timeline: view();
    animation-range: cover 0% cover 100%;
  }
}

@keyframes media-drift {
  from {
    translate: 0 calc(var(--parallax-shift) * -1);
  }

  to {
    translate: 0 var(--parallax-shift);
  }
}

/* -----------------------------------------------------------------
 * 27. Headline word rise
 *
 * app.js splits an `h1.title` or an `h2.section-title` into one span per
 * word and the words arrive in reading order, each 40ms behind the one
 * before it. The split is layout-neutral by construction — inline-block
 * words with the original spaces left as text nodes between them, so lines
 * break exactly where they broke before — and app.js verifies that by
 * comparing offsetHeight before and after, reverting the whole thing if the
 * two ever disagree. Under reduced motion no heading is split at all.
 *
 * THE HEADING IS PINNED, ITS WORDS ARE NOT. This is the floor rule doing
 * real work. Opacity composites down a subtree: a word entering at 0.45
 * inside a block entering at 0.45 renders at 0.20, which is the blank-screen
 * failure the floor exists to prevent. So a heading that carries a word rise
 * takes no fade and no movement of its own, and its parent is promoted to a
 * `data-reveal-stagger` group by app.js so the marker, rule, lead and CTA
 * around it still arrive on the house stagger. Every element in the chain
 * above a word is at opacity 1; the word's own 0.45 is therefore the real
 * on-screen value.
 * --------------------------------------------------------------- */

.js [data-reveal-stagger] > [data-word-rise],
.js .reveal[data-word-rise] {
  opacity: 1;
  transform: none;
  transition: none;
}

/* --reveal-delay is set on the heading by app.js as an ordinary stagger
 * child and inherits down to the words, so a headline low in a block still
 * waits its turn before its first word moves. --word-delay is the per-word
 * offset, written once at split time. */
.js .word {
  display: inline-block;
  opacity: var(--reveal-floor);
  translate: 0 var(--word-rise);
  transition:
    opacity var(--dur-word) var(--ease)
      calc(var(--reveal-delay, 0ms) + var(--word-delay, 0ms)),
    translate var(--dur-word) var(--ease)
      calc(var(--reveal-delay, 0ms) + var(--word-delay, 0ms));
}

.js .is-in [data-word-rise] .word,
.js [data-word-rise].is-in .word {
  opacity: 1;
  translate: none;
}

/* -----------------------------------------------------------------
 * 28. The Infinity Ring — the brand symbol in the home hero
 *
 * REWRITTEN 25 AUG 2026 (V2.2.1). Until this pass the hero carried
 * `.band-ring--hero`: a two-circle HAIRLINE OUTLINE of the mark, about a
 * fifth of the band wide, sitting in the empty right-hand columns and
 * drawing itself on as it arrived. The client's note was that the hero
 * should carry the ACTUAL symbol — the thick interlocked band — and that it
 * should "cover two thirds of the entire block, slightly translucent with
 * the text overlay on it partially". That is a different object with
 * different obligations, so `.band-ring`, `.band-ring--hero`, their circle
 * strokes and their whole draw-on are gone rather than adapted; nothing
 * else referenced them. tools/audit.mjs was moved onto the new selectors in
 * the same pass, and it now asserts a regression guard that the old ones do
 * not come back.
 *
 * The geometry is the supplied artwork inlined verbatim by
 * `heroRing()` in templates.mjs — viewBox 0 0 1828.51 1080, two filled
 * paths, nothing re-measured. The numbers in tokens.css §6 are measurements
 * OF that artwork, so they and this cannot disagree.
 *
 * THE BAND IS THE CONTAINING BLOCK, which is the whole trick. `.band` is
 * `position: relative` (§3) and the ring is emitted as its direct child, so
 * `width: 66vw` is two thirds of the FULL-BLEED element — literally two
 * thirds of the block the client is pointing at. The old ring was anchored
 * inside `.shell` and could only ever have been two thirds of the content
 * column.
 *
 * TEXT OVER IT COMES FOR FREE. `.band > .shell` is `z-index: 1` (§3) and
 * this is `z-index: 0`, so every word in the hero paints over the mark with
 * no per-element z-index anywhere — and so does the hero map plate below,
 * which is opaque Rice Paper (§6b) and therefore completely unaffected by a
 * watermark that can only ever be behind it.
 *
 * SLIGHTLY TRANSLUCENT — AND THE CEILING IS LEGIBILITY, NOT TASTE. The
 * client asked for "slightly translucent"; --hero-ring-opacity ships at
 * 0.22 and is one line in tokens.css §6. That is the judgement call, so
 * here is the arithmetic behind it. Signal White at 0.22 over Canopy
 * lightens the ground to a mid grey-green (rgb 105 127 114 — written out
 * rather than as a hex, because the palette guard in tools/qa.mjs reads
 * comments too and is right to), on which the white headline runs 4.29:1 —
 * past AA for large text (3:1) with room, which is what the h1 is.
 * The muted lead (--white-78) over that same lightened ground is 3.31:1,
 * and only where an arc of the mark actually passes under a line: the
 * symbol is an open band, and on the wide layout it reaches only the right
 * portion of the seven-column type block. Raising the value much past 0.22
 * starts taking white text onto white and is the failure this number exists
 * to stay short of. Lower it, never raise it, without re-running those two
 * contrasts.
 *
 * It is art, not information: aria-hidden in the markup, pointer-events
 * none here, out of flow from the first frame so nothing it does can move
 * the page, and clipped by the band.
 *
 * TWO LAYERS SINCE 25 AUG 2026 (client, V2.3). The mark now drifts from
 * Signal White towards Sage Mist as the visitor scrolls through the hero, and
 * that is drawn as a SECOND copy of the artwork in Sage Mist sitting exactly
 * on the first. This section places and colours both; §28b is what moves
 * them. The split is the same one §6b and §31 make for the map — what a thing
 * IS, then what it DOES — and it is worth keeping because everything below
 * this line is true with JavaScript off.
 *
 * THE SECOND COPY IS A `<use>` OF THE FIRST (templates.mjs), so there is one
 * set of path data in the document and the two layers cannot fall out of
 * register: alignment is a property of the construction rather than of two
 * copies staying equal. Both `<svg>` elements declare the artwork's own
 * viewBox; the white copy is in flow at `width: 100%; height: auto` and gives
 * the wrapper its height, and the sage copy is stretched over it at
 * `inset: 0` with an explicit `width`/`height` — the same idiom
 * `.hero-map__overlay` uses over its base image, and explicit for the same
 * reason: an absolutely positioned SVG with `inset: 0` and auto dimensions
 * resolves against its own intrinsic size, not the inset box.
 *
 * ENTRANCE. The wrapper carries `.reveal` and nothing else, so the house
 * observer is still what decides WHEN. §28b overrides only what happens: the
 * mark blooms rather than rising. With no JS the floor is never applied at
 * all and the white copy renders at full strength on the first frame.
 * --------------------------------------------------------------- */

/* THE CLIP, AND WHY IT IS DECLARED TWICE (V2.3, 25 Aug 2026).
 *
 * The band has always had to clip: the mark is 132vw wide on narrow and bleeds
 * -16vw off each edge, so this rule is the only thing between it and a
 * horizontal scrollbar. `overflow: hidden` did that and still does.
 *
 * `overflow: clip` is added after it because of what `hidden` ALSO does, which
 * is establish a scroll container. A scroll container is what `view()` resolves
 * against, so with `hidden` alone the sage layer's scroll timeline (§28b) was
 * anchored to a box that never scrolls: the timeline was inactive, the
 * animation held its `from` value, and the drift silently never ran. Measured
 * rather than reasoned about — the sage copy's computed opacity read exactly 0
 * at every scroll offset from 0 to 800px. `clip` clips identically and is NOT
 * a scroll container, so the timeline resolves against the viewport, which is
 * what it was always meant to mean.
 *
 * BOTH DECLARATIONS SHIP, in this order, and that is not belt-and-braces — it
 * is the fallback. A browser without `overflow: clip` (pre-Safari 16) drops
 * the second declaration and keeps `hidden`, so the clip guarantee holds
 * everywhere and only the drift degrades, to the same static white every other
 * no-support path lands on. Deleting the `hidden` line would trade a
 * guaranteed clip for an animation.
 *
 * `.plate` (§6) had the same `hidden`-only clip and therefore the same
 * silently-inert `view()` timeline, which had disabled the §26 media
 * parallax on Chromium since it shipped (the rAF fallback never engages
 * there because the feature test passes). The architect applied the same
 * declared-twice pair to `.plate` in the same review that landed this rule,
 * restoring the parallax to the behaviour §26 documents. */
.band--green {
  overflow: hidden;
  overflow: clip;
}

/* NARROW FIRST. Below 64rem the type column is full width, so there is no
 * "beside the headline" to compose against and the mark becomes what a
 * watermark is: full bleed behind the text, symmetric — 132vw wide with
 * -16vw of it off each edge, cut by the band's `overflow: hidden` above.
 * Nothing overflows the page; the clip is the guarantee, not the intention.
 *
 * `top: var(--band)` is the band's own top padding, which is exactly where
 * the first line of the headline starts, and the -10% lift then straddles
 * the mark across it. Anchoring to the band's midpoint instead would have
 * centred the ring on a box that includes the map, dropping most of the
 * symbol behind an opaque plate. */
.hero-ring {
  position: absolute;
  z-index: 0;
  top: var(--band);
  right: -16vw;
  width: 132vw;
  translate: 0 -10%;
  pointer-events: none;

  /* THE RESTING TRANSLUCENCY, UNDER ONE NAME. Both art layers need it — the
   * white copy holds it as a constant, the sage copy animates up to it — and
   * the value differs by breakpoint. Aliasing the two --hero-ring-opacity
   * tokens onto one inherited custom property here means the breakpoint is
   * declared once, on the wrapper, instead of once per layer and again inside
   * the drift keyframes. It is a local alias, not a token: tokens.css §6 is
   * still where the numbers live and still the only place to change them. */
  --hero-ring-rest: var(--hero-ring-opacity-narrow);
}

.hero-ring__art {
  display: block;
  width: 100%;
  height: auto;
}

/* SIGNAL WHITE BY NAME. The supplied file said `fill: #fff` in a `<style>`
 * block; a hex literal anywhere in this codebase is a palette-guard failure
 * (tools/qa.mjs), and --white IS that colour, so the fill lives here and the
 * mark is described in the one vocabulary the site has.
 *
 * The opacity sits on the ART rather than on the wrapper so the wrapper is
 * free to carry the entrance's own 0 → 1 (§28b). Two opacities on one element
 * would be one of them lost — the §27 compositing rule, applied to a mark
 * instead of to a word.
 *
 * THIS LAYER NEVER MOVES AND NEVER CHANGES. It is the floor the drift plays
 * over: whatever the sage copy is doing, the white mark is underneath it at
 * exactly the resting value, so the ring can never be dimmer than the token
 * says and the headline's contrast can never drop below the number §6 was
 * calculated against. */
.hero-ring__art--white {
  fill: var(--white);
  opacity: var(--hero-ring-rest);
}

/* SAGE MIST, STACKED ON TOP, AND INVISIBLE AT REST. This is the whole of the
 * colour drift: `opacity` is the only property the scroll drives, which makes
 * a tint change into compositor work. Animating `fill` between two brand
 * colours would repaint the largest object on the page on every frame, and
 * would interpolate through values that are not in the palette — a fifth
 * colour existing for 200ms at a time is still a fifth colour.
 *
 * `--hero-ring-drift` is 0 at rest and runs to 1, and the default in the
 * `var()` below is what makes every no-motion path correct without a single
 * extra rule: with no JavaScript, with no scroll-timeline support and no
 * script to fall back to, or before the fallback's first frame, the property
 * is unset, the calc resolves to 0, and the sage copy is simply not there.
 * Static white — which is the state this layer is REQUIRED to degrade to.
 *
 * Sage Mist on Canopy is 4.31:1 and carries no text anywhere on this site
 * (tokens.css §1). This is the permitted use of that pair: a decorative mark
 * on a ground, at a fraction of full strength, with the white copy under it
 * carrying the contrast the headline is measured against. */
.hero-ring__art--sage {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  fill: var(--sage);
  opacity: calc(var(--hero-ring-rest) * var(--hero-ring-drift, 0));
}

/* -----------------------------------------------------------------
 * MAP CLEARANCE (client, 25 Aug 2026 — V2.3)
 *
 * The map was touching the ring, and below the 64rem breakpoint it was
 * overlapping it outright: measured at 768px, the mark's bottom edge sat 65px
 * PAST the map's top edge, and the worst case is just under 1024px, where the
 * narrow ring is at its largest (132vw is 1350px wide, so 797px tall) against
 * a type column wide enough to have gone short. The mark ran under an opaque
 * Rice Paper plate and was cut in half.
 *
 * THE FIX IS A FLOOR UNDER THE TYPE COLUMN, NOT A MARGIN ON THE MAP. A margin
 * cannot solve this: the map's top is `type column height + --sp-8`, and the
 * type column's height is whatever the copy reflows to at that width — a
 * number no stylesheet can name. Any fixed margin big enough for the worst
 * case would be a crater at every other width.
 *
 * So the constraint is stated where it is actually knowable, in the RING's own
 * geometry. Both numbers below are the ring's bottom edge measured from this
 * element's own top edge, and they are arithmetic on the values in §28 rather
 * than a fitted guess:
 *
 *   the artwork's aspect                    1828.51 / 1080 = 1.693065
 *   narrow   width 132vw  height 132 / 1.693065      = 77.964vw
 *            `top: var(--band)` puts the ring's top exactly at this
 *            element's top (the band's padding is --band on both), and
 *            `translate: 0 -10%` lifts it by 10% of its own height:
 *            bottom = 77.964 - 7.796                 = 70.17vw
 *   wide     width 66vw   height 66 / 1.693065       = 38.982vw
 *            translate -8%:  38.982 - 3.118          = 35.87vw
 *
 * Hold this block to at least that height and the map — which follows it with
 * `.hero-map`'s own --sp-8 — cannot start less than 64px below the mark's
 * bottom edge, at ANY width, because both sides of the comparison are now the
 * same vw. The clearance IS that --sp-8; there is no second number to keep in
 * step, which is the whole reason the min-height stops at the ring's bottom
 * instead of including the gap.
 *
 * Verified in the browser rather than trusted to this comment — ring bottom
 * against map top at 1440 / 1024 / 768 / 375, before and after.
 *
 * IT ONLY BINDS WHERE IT IS NEEDED. Where the copy already runs taller than
 * the mark — every phone width, and 1024 — the natural height wins and
 * nothing moves. The class is carried by the home hero's grid alone
 * (templates.mjs); no other grid on the site has a ring hanging beside it. */
.hero-ring-clear {
  min-height: 70.17vw;
}

@media (min-width: 64rem) {
  .hero-ring-clear {
    min-height: 35.87vw;
  }
}

/* From 64rem the composition the client described exists: two thirds of the
 * band, whole rather than cropped, its right edge flush with the band's own
 * and its left third running under the headline and lead. At 1440 that is
 * 950px of mark against a 736px type column, so roughly 330px of text sits
 * over the symbol — "partially", which is the word that was used.
 *
 * No bleed here, deliberately. The narrow case bleeds because a watermark
 * behind text should have no edges; this one is MET, and the first mark on
 * the site earns being seen whole. */
@media (min-width: 64rem) {
  .hero-ring {
    right: 0;
    width: 66vw;
    translate: 0 -8%;

    /* The full value. Below this breakpoint the mark is behind ALL of the
     * type rather than the right of it, so the lead would meet an arc on
     * every line — --hero-ring-opacity-narrow takes the muted lead from
     * 3.31:1 to 4.17:1 and the headline to 5.50:1 for exactly that reason.
     * Same mark, same token pair, one step apart.
     *
     * ONE DECLARATION MOVES BOTH LAYERS AND THE DRIFT. The step used to sit
     * on the SVG's own `opacity`; re-pointing the wrapper's alias instead
     * carries the white copy, the sage copy's calc and the keyframes' `to`
     * value together, so the two copies cannot end up a breakpoint apart. */
    --hero-ring-rest: var(--hero-ring-opacity);
  }
}

/* -----------------------------------------------------------------
 * 28b. The Infinity Ring — the bloom and the colour drift (V2.3)
 *
 * The sixth scroll-motion system, added 25 Aug 2026 at the client's request:
 * the hero mark should ARRIVE rather than simply be there, and its tint
 * should move from Signal White towards Sage Mist as the hero is scrolled
 * through. Two layers of motion on one object, and they are deliberately
 * driven by two different clocks — the bloom is an entrance and happens once,
 * the drift is a position and is true at every scroll offset.
 *
 * It owes everything the other five owe (tokens.css §8b/§8c): transform and
 * opacity only, nothing that costs layout, completely inert under
 * prefers-reduced-motion with the content left in a finished state, and
 * native scrolling never touched.
 *
 * 1. THE BLOOM — opacity 0 → 1 and scale --hero-ring-bloom → 1 on the
 *    WRAPPER, over --dur-hero-ring on the house easing curve.
 *
 *    IT REUSES THE REVEAL'S GATING AND OVERRIDES ONLY ITS VALUES. The wrapper
 *    carries `.reveal`, so app.js's existing observer is what adds `.is-in` —
 *    no new script decides when the hero ring arrives, and the mark inherits
 *    the reveal system's rootMargin, its no-IntersectionObserver behaviour
 *    (everything marked in immediately) and its mid-visit reduced-motion
 *    switch for free. What §14 would otherwise do here is wrong for this
 *    object twice over: a 20px rise is invisible on a mark 950px wide, and
 *    the 0.45 floor would multiply with the art's own translucency.
 *
 *    THE SPECIFICITY IS EQUAL AND THE ORDER IS LOAD-BEARING. `.js .hero-ring`
 *    and `.js .reveal` both weigh 0-2-0, as do `.js .hero-ring.is-in` and
 *    `.js .reveal.is-in` at 0-3-0. These rules win only because §28b sits
 *    below §14 in the sheet. Moving this section above it silently returns
 *    the mark to the 20px rise. The same mechanism, and the same warning,
 *    as `.plate--framed` in §6.
 *
 *    FROM 0, NOT FROM THE FLOOR, AND THAT IS ALLOWED HERE. The no-blank-screen
 *    rule (§14) is about TEXT: a reader who lands mid-document must never
 *    meet an empty band. This is aria-hidden decoration that the hero is
 *    required to read identically without, so starting it at 0 costs nobody
 *    a word. Every element in the band that carries meaning still enters at
 *    --reveal-floor.
 *
 *    AND IT RESTS AT EXACTLY 1, which is the §27 compositing rule doing real
 *    work. Opacity multiplies down a subtree, so the art's translucency is
 *    the wrapper's value times the layer's own; the tokens in §6 are only the
 *    truth if the wrapper's resting value is exactly 1. It is — so the mark
 *    lands on --hero-ring-opacity to the digit, and the contrast arithmetic
 *    §28 wrote down still describes what is on screen.
 *
 *    `scale` and `transform: none` rather than a `transform` string, for the
 *    same reason §26 uses the individual properties: the wrapper already owns
 *    `translate` for its own placement (§28), and the individual properties
 *    compose with it instead of overwriting it. A `transform: scale(1.06)`
 *    here would throw the mark's position away for the length of the
 *    entrance.
 *
 * 2. THE DRIFT — the sage copy's opacity from 0 to the resting value, driven
 *    by scroll position rather than by time.
 *
 *    TWO MECHANISMS, NEVER BOTH, exactly as the parallax in §26. Where the
 *    browser has scroll-driven animations the timeline IS the scroll
 *    position: no listener, no script, no main-thread work. Where it does
 *    not, app.js writes --hero-ring-drift from one passive rAF-throttled
 *    scroll listener, and `initHeroRingDrift` feature-detects the SAME
 *    condition this `@supports` tests, so the two can never both be live on
 *    the layer. The base rule in §28 reads that custom property and defaults
 *    it to 0, which is what the no-script and no-support paths land on.
 *
 *    THE RANGE IS THE MARK'S OWN EXIT. `exit 0%` is the moment the ring's top
 *    edge reaches the top of the viewport and `exit 100%` the moment its
 *    bottom edge leaves — so the drift begins as the mark starts to go and
 *    completes as it does, which is "as the user scrolls through the hero"
 *    expressed as geometry rather than as a pixel count that would need
 *    re-tuning at every width. Before the range the animation is clamped to
 *    its `from` by `both`, so the ring is white for the whole of the first
 *    screen.
 * --------------------------------------------------------------- */

.js .hero-ring {
  opacity: 0;
  transform: none;
  scale: var(--hero-ring-bloom);
  transition:
    opacity var(--dur-hero-ring) var(--ease) var(--reveal-delay, 0ms),
    scale var(--dur-hero-ring) var(--ease) var(--reveal-delay, 0ms);
}

.js .hero-ring.is-in {
  opacity: 1;
  scale: none;
}

@supports (animation-timeline: view()) {
  .js .hero-ring__art--sage {
    /* The custom property is left at its default: with a timeline driving
     * the animation, app.js never runs its fallback and never sets one.
     * Duration is `auto` — the timeline supplies the progress. */
    animation: hero-ring-drift linear both;
    animation-timeline: view();
    animation-range: exit 0% exit 100%;
  }
}

/* `var(--hero-ring-rest)` inside a keyframe resolves against the element being
 * animated, so the `to` value is whichever of the two --hero-ring-opacity
 * tokens the wrapper's breakpoint alias currently holds. One set of keyframes
 * therefore serves both layouts, and the drift can never end at a different
 * strength from the white copy it is stacked on. */
@keyframes hero-ring-drift {
  from {
    opacity: 0;
  }

  to {
    opacity: var(--hero-ring-rest);
  }
}

/* Reduced motion — both layers, and the entrance, removed rather than
 * shortened. The state this lands on is the one the mark is required to
 * degrade to everywhere else: STATIC WHITE.
 *
 * BOTH LAYERS ARE NAMED HERE ON PURPOSE, even though only one of them
 * animates. The white copy's `opacity` is a constant and would be correct
 * without this rule; restating it is what makes the pair legible as a pair,
 * so the next person to read this block can see what the mark looks like
 * under the setting without holding §28 in their head at the same time. The
 * `.is-in` selector is listed alongside the resting one so the cancellation
 * holds whether the observer has fired or not, and whether the setting was on
 * at load or switched on halfway down the page — the same belt §31 wears. */
@media (prefers-reduced-motion: reduce) {
  .js .hero-ring,
  .js .hero-ring.is-in {
    opacity: 1;
    scale: none;
    transition: none;
  }

  .hero-ring__art--white {
    opacity: var(--hero-ring-rest);
  }

  .js .hero-ring__art--sage,
  .hero-ring__art--sage {
    animation: none;
    opacity: 0;
  }
}

/* THE FOOTER RING'S COLOURWAY — the filled mark, in Signal White.
 *
 * Same pattern as the hero above, and for the same reason: the supplied SVG
 * declared `fill: #fff` in a `<style>` block, a hex literal anywhere in this
 * codebase is a palette-guard failure (tools/qa.mjs), and --white IS that
 * colour. The paths ship with no fill attribute and this is where they get one.
 * Signal White is the guide's colourway for the mark on a dark ground, which is
 * what the charcoal floor is.
 *
 * Declared on the paths rather than on the `<svg>` so this rule and §13's
 * `.footer-ring svg` geometry rule cannot be mistaken for one another; the
 * shape of the mark is §13's business and its ink is this section's.
 *
 * The translucency stays in §13 with the placement it belongs to — the
 * legibility sum in that block is about where the mark sits, not what it is
 * made of.
 *
 * WHAT WAS HERE UNTIL 31 AUG 2026 (V2.6): `.footer-ring circle` (fill none, a
 * 1.6 sage stroke) and the three-rule gated draw-on —
 * `.js .footer-ring[data-ring-draw] circle` with a dasharray and dashoffset of
 * --ring-draw-length transitioning over --dur-draw, its `:nth-of-type(2)`
 * sibling offset by --draw-offset, and the `.is-drawn` rule that ran it to
 * zero — plus its reduced-motion cancellation in §30.
 *
 * ALL OF IT IS DELETED, and this time the switch argument does not save it.
 * That machinery was kept through V2.2.1 and V2.3 on the reasoning that
 * deleting a switch is not the same decision as deciding not to flip it: the
 * draw-on was one attribute away from working, and the rules were what kept it
 * honest. The mark it drew no longer exists. There are no `<circle>` elements
 * left in the footer to dash, --ring-draw-length was the circumference of a
 * geometry this file no longer contains, and a switch wired to nothing is not a
 * switch — it is three rules that can never match and a reduced-motion
 * obligation for motion that cannot occur. The tokens went with them
 * (tokens.css §8b) and so did app.js's `initRingDraw()`.
 *
 * NOTHING REPLACES THE DRAW-ON. A filled symbol has no stroke to draw, and the
 * client's direction was the mark, not a way of introducing it. The footer ring
 * is now static BY CONSTRUCTION rather than by an unflipped switch, which is
 * the stronger version of what it always shipped as — and it is why this
 * section no longer appears in §30. There is no motion here to cancel. */
.footer-ring path {
  fill: var(--white);
}

/* -----------------------------------------------------------------
 * 29. Stat count-up
 *
 * Entirely in app.js — a small integer counts from zero to its rendered
 * value over --dur-count on the house easing curve. There is no CSS state
 * to declare: the served HTML already contains the final number, so with no
 * script, with no IntersectionObserver, or under reduced motion, the value
 * that ships is the value that shows. The only stylesheet obligation is the
 * one already met on `.stats__value` in §11 — tabular figures, so counting
 * cannot change the element's width.
 * --------------------------------------------------------------- */

/* -----------------------------------------------------------------
 * 30. Reduced motion — the scroll-motion package, removed
 *
 * Not shortened: removed. Each system is returned to the state it would
 * have had if it had never run, and in every case that state is the
 * finished one — image at its natural crop, headline fully visible and
 * unmoved, numbers at their final values (which app.js guarantees by not
 * touching them at all). §14's block does the same for the section entry;
 * this one is scoped to what pass 7 added.
 *
 * The ring draw-on was the fourth entry on that list until 31 Aug 2026 (V2.6)
 * and is no longer anywhere on the site — see the note where its rule stood.
 * --------------------------------------------------------------- */

@media (prefers-reduced-motion: reduce) {
  /* Parallax: no timeline, no overscan, no drift. The photograph is exactly
   * its frame, which is a tighter composition than the animated one ever
   * shows. */
  .js .plate--drift img {
    animation: none;
    scale: none;
    translate: none;
  }

  /* Word rise: app.js does not split a heading under reduced motion, so
   * these rules are the belt for a setting switched on mid-visit, when the
   * spans already exist. */
  .js .word {
    opacity: 1;
    translate: none;
    transition: none;
  }

  /* NO RING RULE HERE SINCE 31 AUG 2026 (V2.6), and neither ring is an
   * omission.
   *
   * THE HERO RING has motion of its own — a bloom and a scroll-linked colour
   * drift, both added by V2.3 — and it IS cancelled, at the foot of §28b rather
   * than here. It sits there for the reason §31's cancellation sits at the foot
   * of §31: a system and the block that removes it belong in one place, where
   * neither can be edited without the other in view.
   *
   * THE FOOTER RING no longer has any motion to cancel. What stood here was the
   * outline's draw-on — `.js .footer-ring[data-ring-draw] circle` and its
   * `:nth-of-type(2)` sibling pinned to `stroke-dashoffset: 0` with the
   * transition off — cancelled here so that flipping the switch on would stay a
   * one-attribute change rather than a one-attribute change plus a
   * reduced-motion obligation someone had to remember. The switch, the stroke
   * and the circles it dashed are all deleted (§28): the footer now carries the
   * brand guide's filled mark as a static watermark. A reduce rule for a system
   * that cannot animate is a rule that hides the fact it has no system.
   *
   * The census in tools/audit.mjs moved with this — the package is FIVE systems
   * under reduced motion now, not six, and the footer ring is instead asserted
   * to declare no transition or animation at all, which is a stronger claim
   * than being cancelled here would be. */

  /* Hero map: the overlay is in register with its base from the first
   * frame. app.js never touches this one — the settle is pure CSS — so
   * this rule is the whole of its reduced-motion behaviour, and the state
   * it lands on is the finished one.
   *
   * The map's CALL-OUTS are a separate system (§31) and their cancellation
   * is at the foot of that section rather than here, following §14's
   * pattern: a system and the block that removes it in one place. */
  .js .reveal .hero-map__overlay,
  .js [data-reveal-stagger] .hero-map__overlay,
  .js .reveal.is-in .hero-map__overlay,
  .js [data-reveal-stagger].is-in .hero-map__overlay {
    transform: none;
    transition: none;
  }
}

/* -----------------------------------------------------------------
 * 31. The hero map entrance — the region call-outs arrive (V2.2)
 *
 * The map block itself enters on the house scroll transition (§14) like
 * any other `.reveal`, and the overlay settles the last 1% into register
 * with the base under it (§6b). This section is what happens INSIDE that
 * overlay: the six call-outs draw themselves on, one region at a time, in
 * the order `mapRegions` lists them. Per region, all three starting
 * together on that region's own delay:
 *
 *   the leader line draws out of the dot towards its label
 *   the dot scales up from nothing about its own centre
 *   the label rises --map-label-rise into place
 *
 * and then, once the whole entrance has landed, a radar pulse leaves each
 * dot on a slow loop. Timings are all in tokens.css §8c.
 *
 * NO JAVASCRIPT. Not "a little": none. The trigger is the `.is-in` class
 * the section observer already sets on the block, so this system inherits
 * an observer that was going to run anyway, inherits its rootMargin, and
 * inherits its behaviour when there is no IntersectionObserver (everything
 * is marked `is-in` immediately). app.js was not touched for any of it.
 * Consequences worth stating plainly:
 *
 *   - WITH NO SCRIPT, `.js` is never set and every rule below is dead. The
 *     served SVG is what renders: six lines at full length, six dots at
 *     full size, six labels in place. The undrawn state cannot outlive the
 *     thing that undoes it, which is the trap a `.js`-scoped system exists
 *     to avoid.
 *   - UNDER REDUCED MOTION the block at the foot of this section returns
 *     every one of them to that same finished state and stops the pulse
 *     outright. Not shortened — removed.
 *
 * ONE NUMBER DRIVES THE STAGGER. Each call-out is wrapped in a `<g>`
 * carrying `--map-index` (0-5, written by templates.mjs), and --map-delay
 * below turns it into a time that every part of that region shares. The
 * section's own --reveal-delay is added in, exactly as the word rise adds
 * it in §27, so a map that entered late in a stagger still waits its turn
 * before its first line moves.
 *
 * THE FLOOR RULE, AGAIN, AND IT IS WHY THE LABEL DOES NOT FADE. `.hero-map`
 * is itself the `.reveal` element, so it carries the block's fade from
 * --reveal-floor to 1. Opacity composites down a subtree: a label entering
 * at 0.45 inside a block entering at 0.45 renders at 0.20, which is the
 * blank-screen failure the floor exists to prevent. So the parent carries
 * the fade and the label carries movement only — the same split the overlay
 * settle makes in §6b for the same reason. The region names are legible
 * from the first frame at the block's own opacity, and the only thing the
 * entrance does to them is lift them.
 *
 * The lines and dots are not text and take no floor: they are connectors
 * drawn onto artwork that is already on screen, and the six region names
 * they point at are readable before either arrives.
 *
 * COMPOSITOR PROPERTIES, PLUS ONE. `scale` and `translate` on the dot and
 * the label; `stroke-dashoffset` on the line, which repaints a hairline
 * inside a decorative SVG and cannot cause layout — the same trade §28
 * makes for the ring and the header indicator has always made. Nothing
 * here animates a property that costs layout, so nothing can shift the
 * page under a reader's finger.
 * --------------------------------------------------------------- */

.hero-map__region {
  --map-delay: calc(
    var(--reveal-delay, 0ms) + var(--map-index, 0) * var(--map-stagger)
  );
}

/* The leader line. `pathLength="1"` in the markup re-scales each line's own
 * geometry so the browser measures it as one unit long, which is what lets
 * six lines of six different lengths draw from a single dash value with
 * nothing measured at runtime. The dash and the offset are both scoped to
 * `.js`, as the ring's are in §28: with no script the line simply has no
 * dash pattern and is whole. */
.js .hero-map .hero-map__line {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  transition: stroke-dashoffset var(--dur-map-line) var(--ease) var(--map-delay);
}

.js .hero-map.is-in .hero-map__line {
  stroke-dashoffset: 0;
}

/* The dot. `transform-box: fill-box` is what makes `transform-origin:
 * center` mean the circle's own centre rather than the SVG viewport's
 * origin — without it the dot would scale up from the top-left corner of
 * the map and fly into place, which is a different animation and not this
 * one. Both properties sit on the base rule rather than inside the `.js`
 * block: they cost nothing when nothing is scaling, and a transform origin
 * that only exists while a class is present is the kind of thing that
 * silently stops being true. */
.hero-map__dot {
  transform-box: fill-box;
  transform-origin: center;
}

.js .hero-map .hero-map__dot {
  scale: 0;
  transition: scale var(--dur-map-dot) var(--ease) var(--map-delay);
}

.js .hero-map.is-in .hero-map__dot {
  scale: none;
}

/* The label. Movement only — see the floor rule above. --map-label-rise is
 * in em, so it resolves against the label's own font-size and travels the
 * same proportion at both of the map's two type sizes (§6b). */
.js .hero-map .hero-map__label {
  translate: 0 var(--map-label-rise);
  transition: translate var(--dur-map-label) var(--ease) var(--map-delay);
}

.js .hero-map.is-in .hero-map__label {
  translate: none;
}

/* The radar pulse — a second circle behind each dot, expanding out of it
 * and fading, on a slow loop.
 *
 * It is the only continuous motion on this site, so every decision about it
 * is a decision about restraint: 3.5s per cycle, a peak opacity of 0.28 in
 * the Canopy that already draws the leader lines, and a per-region offset
 * of --map-pulse-stagger that is deliberately NOT the entrance's 120ms.
 * Six pulses 580ms apart spread almost exactly across one 3500ms cycle, so
 * no two are ever at the same radius and the group never reads as a single
 * throb. Canopy on the artwork's Sage Mist landmass is 4.31:1 — decorative
 * only, and a ring track is precisely the use the house rule in tokens.css
 * §1 permits for that pair.
 *
 * IT CANNOT RUN BEFORE THE ENTRANCE LANDS. The delay is the entrance's own
 * worst case plus air: the last region starts at 5 x --map-stagger and its
 * line finishes --dur-map-line later, which is 1200ms, against a
 * --map-pulse-delay of 1400ms — and the block's --reveal-delay is added on
 * top of both, so the guarantee holds however late the map entered.
 *
 * Resting opacity is 0 and the keyframes supply their own. That is what
 * makes the no-script case correct: with no `.js` the animation never
 * starts, and what does not animate here is simply not drawn. A continuous
 * loop has no meaningful static state to fall back to — unlike a line, a
 * dot or a label, whose finished state IS the thing the page needs. */
.hero-map__pulse {
  fill: none;
  stroke: var(--canopy);
  stroke-width: 2;
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
}

.js .hero-map.is-in .hero-map__pulse {
  animation: map-pulse var(--dur-map-pulse) var(--ease)
    calc(
      var(--reveal-delay, 0ms) + var(--map-pulse-delay) + var(--map-index, 0) *
        var(--map-pulse-stagger)
    )
    infinite;
}

@keyframes map-pulse {
  from {
    scale: 1;
    opacity: var(--map-pulse-opacity);
  }

  to {
    scale: var(--map-pulse-scale);
    opacity: 0;
  }
}

/* Reduced motion — the entrance removed, not shortened.
 *
 * Every call-out is returned to the state it would have had if none of the
 * rules above had ever matched: line at full length, dot at full size,
 * label in place, pulse not running and not drawn. The `.is-in` selectors
 * are listed alongside the resting ones so the cancellation holds whether
 * the observer has fired or not, and whether the setting was on at load or
 * switched on halfway down the page. */
@media (prefers-reduced-motion: reduce) {
  .js .hero-map .hero-map__line,
  .js .hero-map.is-in .hero-map__line {
    stroke-dasharray: none;
    stroke-dashoffset: 0;
    transition: none;
  }

  .js .hero-map .hero-map__dot,
  .js .hero-map.is-in .hero-map__dot {
    scale: none;
    transition: none;
  }

  .js .hero-map .hero-map__label,
  .js .hero-map.is-in .hero-map__label {
    translate: none;
    transition: none;
  }

  .js .hero-map .hero-map__pulse,
  .js .hero-map.is-in .hero-map__pulse {
    animation: none;
    opacity: 0;
  }
}
