/* ==========================================================================
   FONTS
   Self-hosted variable fonts, built from the TTFs Hudson supplied and
   subset to Latin + Latin Extended. One file per family covers every weight
   from 100 to 900, so there is nothing to swap when a weight changes.

   These used to live in assets/fonts/fonts.css, pulled in with @import. That
   cost a third round trip: style.css had to arrive before fonts.css was even
   discovered, and fonts.css before the woff2. They are inline here instead,
   and each page preloads both files.

   Inter keeps its optical-size axis (14 to 32), which is why
   font-optical-sizing matters below: the browser picks the right letterforms
   for a 61px heading and for 19px body copy out of the same file.

   No italics: the site contains no <em>, no <i> and no font-style: italic,
   so Inter's italic variable font is not shipped.
   ========================================================================== */

@font-face {
  font-family: "Outfit";
  src: url("../assets/fonts/outfit-var.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: "Inter";
  src: url("../assets/fonts/inter-var.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

/* ==========================================================================
   Chronicle Financial Technologies
   style.css

   ---------------------------------------------------------------------------
   Rebuilt to match the live site's own structure rather than a generic
   marketing template. The layout below reproduces the geometry recovered from
   the Squarespace export: a 24-column fluid grid at 1500px, four full-bleed
   dark sections, text and artwork alternating left and right.

   BRAND TOKENS are in the :root block. The values are the real ones, read
   out of Chronicle's own logo PDFs and the supplied accent green.
   ---------------------------------------------------------------------------
   ========================================================================== */

:root {
  /* --- Colour ------------------------------------------------------------
     Exact values from the vector logo art:
       black          rgb(0, 0, 0)                  #000000
       grey on light  rgb(.502, .506, .518) * 255   #808184  (see note)
       grey on dark   rgb(.573, .580, .592) * 255   #929497
     Accent green #18bd5b as supplied. It measures 8.47:1 on black, so on
     this predominantly dark site it can be used at full strength. The
     darkened --accent-ink exists for the two light long-form pages, where
     the brand green would only reach 2.48:1.                              */
  --ink:            #000000;
  --on-dark:        #ffffff;
  /* Measured on the live site: body copy is pure white, not a grey. The
     earlier #d6d7d9 was invented. */
  --on-dark-body:   #ffffff;
  --on-dark-muted:  #929497;   /* BRAND GREY on dark           6.9:1       */
  /* Brand green. Measured on the live site it appears ONLY as
     rgba(25,190,92,0.05), a 5% background wash, never on buttons, eyebrows
     or text. That is still true of everything carried over from live; the
     one place this build uses it at full strength is the hero audience
     bullet, which is a 6px dot and not type, so nothing is being asked to
     read against it. Also used by the campaign pages. */
  --accent:         #18bd5b;

  /* Light ground, used only on the two long-form legal pages. */
  --surface:        #ffffff;
  --surface-alt:    #f5f5f6;
  --body:           #3f4042;
  /* The brand's light-ground grey, #808184, measures 3.90:1 on white and
     fails the 4.5 body threshold, so it is NOT a token here. --muted-ink is
     the darkened version that passes at 5.57:1, and it is what the legal
     pages use. Reintroducing the brand value as a text colour would be a
     WCAG 1.4.3 failure. */
  --muted-ink:      #67686b;
  --line:           #e3e3e4;
  --accent-ink:     #0f7a3c;   /* green text on white          5.4:1       */
  --accent-dark:    #0d6b34;

  /* Hairlines on dark */
  --line-dark:      rgba(255, 255, 255, 0.16);

  /* --- Type --------------------------------------------------------------
     Outfit for headings, Inter for body. Self-hosted from assets/fonts/;
     run `python3 tools/fetch-fonts.py` to populate it.                     */
  --font-display: "Outfit", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  --font-body:    "Inter", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;

  /* --- Grid --------------------------------------------------------------
     Measured off the live site, not inferred. The Squarespace fluid engine
     is a 26-column grid with a 10px gap and ~1.64vw of side padding, and it
     is full-bleed: there is no max-width. Verified against every block on
     the home page, e.g. at a 1512px viewport the hero h1 (grid-area
     3 / 3 / 9 / 14) lands at x=138 w=613, and this model gives x=138 w=613. */
  --grid-cols:    26;
  --grid-gap:     10px;
  --gutter:       clamp(1rem, 1.64vw, 2.25rem);
  /* Live's mobile gutter was --sqs-mobile-site-gutter: 6vw, and this
     deliberately departs from it: 7.8vw, 30% wider, because 6vw put the
     content too close to the edge of a phone. On a 390px screen that is
     23.4px of margin going to 30.4px.

     ONE NUMBER MOVES EVERY MOBILE MARGIN, which is the point of the
     variable. .container carries `padding-inline: var(--gutter)` and the
     8-column grid is expressed as that padding plus the tracks, so the
     content box narrows by 14px and every column narrows with it. The two
     `calc(Npx + 2 * var(--gutter))` max-widths further down are written
     against the gutter for this exact reason: they widen by the same
     amount and the measure they cap stays at N. */
  --gutter-mobile: 7.8vw;
  --section-y:    clamp(4.5rem, 10vw, 9rem);
  --radius:       10px;   /* live button radius */

  /* Measured on live: 129px at rest, 110px once scrolled. The header is
     fixed, so these values are what the first band on each page has to
     clear, and what anchor targets scroll-margin against. */
  --header-h:         129px;
  --header-h-shrunk:  110px;
}

/* THE MOBILE BREAKPOINT.
   Live switches at `(pointer: coarse) and (max-width: 1024px), (max-width:
   799px)`. The coarse-pointer half matters: a tablet gets the mobile layout
   on live at widths where a desktop browser would still get the full one.
   This exact pair is repeated wherever mobile rules are needed. */
@media (max-width: 799px), (pointer: coarse) and (max-width: 1024px) {
  :root {
    --header-h: 84px;
    --header-h-shrunk: 72px;
    --gutter: var(--gutter-mobile);
    /* Live drops from 24 content columns to 8. Its grid is
       gutter + N columns + gutter; expressing the gutter as container
       padding instead gives an identical content box, from --gutter-mobile
       to 100% - --gutter-mobile, with the same 10px gaps between columns.
       That gutter is 7.8vw rather than live's 6vw; see the note on the
       variable. */
    --grid-cols: 8;
  }
}

/* ==========================================================================
   RESET
   ========================================================================== */

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

/* THE BACKGROUND IS DECLARED HERE AND NOT ONLY ON BODY, and that is load
   bearing for the ambient field below. A background on <body> propagates to
   the canvas and wins; the ambient page makes body transparent so a fixed
   layer behind it can show through, and the moment body goes transparent
   something has to hold the black or the page renders WHITE. This is that
   something. On every page without [data-ambient] it is simply covered by
   body's own black and costs nothing. */
html {
  -webkit-text-size-adjust: 100%;
  scroll-behavior: smooth;
  background: var(--ink);
}

/* Body copy measured at 19px / lh 31 / white at a 1512px viewport. */
body {
  margin: 0;
  font-family: var(--font-body);
  /* min(0.24vw + 1rem, 19.24px) on desktop, 18.03px on a phone. */
  font-size: clamp(1.127rem, 0.24vw + 1rem, 1.2025rem);
  line-height: 1.63;
  background: var(--ink);
  color: var(--on-dark-body);
  -webkit-font-smoothing: antialiased;
  /* Let Inter's opsz axis respond to the rendered size. */
  font-optical-sizing: auto;
}

/* ==========================================================================
   AMBIENT FIELD
   --------------------------------------------------------------------------
   The page ground, which used to be flat #000 edge to edge. Two fixed layers
   of very dark green and very dark teal drift across each other on long,
   unsynchronised loops, so the black is never quite the same black twice.

   OPT-IN, via [data-ambient] on <body>. Only index.html carries it. The
   other pages keep the flat ground until this is rolled out to them, which
   is a one-attribute change per page.

   FOUR COLOURS ACROSS THE TWO LAYERS: green and a deep teal on ::before,
   teal and two colder blue-teals on ::after.

   THE WHOLE COOL END IS TRACKED BY ONE NUMBER, the blue-to-green ratio of
   each blob, because that is what "more teal" and "more blue" actually mean
   here and it is far easier to reason about than three channels:

     greens        0.6 to 0.64   warm end, unchanged
     teals         1.36 to 1.50  were 1.20 to 1.35
     blue-teals    1.48 and 1.50 unchanged

   The teals moved up by about 0.15 -- G down three or four, B up four --
   which reads as a nudge toward blue without changing how dark they are.
   Lowering green and raising blue by similar amounts slightly LOWERS
   luminance, since green carries 0.715 of it and blue 0.072, so this is
   also safe for contrast in a way that brightening would not be.

   SATURATION IS THE OTHER HALF, and it is a different number from the one
   above -- which is the thing that caught me out. The field went grey and
   washed out while every ratio was behaving, because ratio says nothing
   about how far a colour is from neutral.

   Measured, the two BLUE-TEALS were the greyest blobs in the set: 0.74 HSL
   saturation against 0.78-0.82 for the teals, and the highest red channel
   of the cool family at 9 and 10. On a near-black ground red is what pulls
   a dark blue toward slate, so those two were quietly desaturating the
   coldest part of the field.

     rgb(10,46,68) -> rgb(3,50,74)   sat 0.74 -> 0.92
     rgb( 9,40,60) -> rgb(2,44,65)   sat 0.74 -> 0.94

   Red almost out, green and blue up together so the 1.48 ratio holds. They
   are now the most saturated things in the field, which is right: they are
   the smallest and coolest blobs, and a cold accent needs more purity than
   a large warm wash to register at all.

   THEIR HISTORY, because the hue has been moved twice. They were navy at
   first -- rgb(12,36,78) and rgb(10,30,68), a ratio over 2.1 -- which gave
   the field regions rather than one colour drifting in brightness, but
   overshot into reading as a second hue in the palette. Pulled back to
   1.48. The teals later came up to meet them, so the cool end spans a
   narrow range now; if it ever needs separating again, move these two
   rather than the teals, or the whole end collapses to one colour.

   WATCH RED, NOT JUST HUE, next time something here reads as washed out.
   It is the channel that does the least for luminance in the cool blobs and
   the most for neutrality.

   THE WHOLE FIELD WAS THEN TAKEN DOWN AND UP TOGETHER: every blob's red
   multiplied by 0.40, and its green and blue by 0.91. Two separate asks --
   darker, and more saturated -- that turn out to be one operation applied
   to different channels, which is why they are recorded as one step.

     red x 0.40    saturation. Red is the minimum channel in all nine
                   blobs, and HSL saturation is (max-min)/(max+min), so
                   cutting the minimum raises it without touching hue.
                   Across the set: 0.65-0.94 before, 0.82-0.97 after.

     G, B x 0.91   luminance. Scaling the two together is what preserves
                   hue: the blue-to-green ratio of every blob lands within
                   0.02 of where it started, so the field is the same
                   colours at a lower level rather than different colours.

   THAT SPLIT IS THE WHOLE TECHNIQUE for this palette and worth keeping.
   Red is almost pure saturation control here and almost no luminance --
   0.2126 of it, on a channel in single digits. Green and blue are almost
   pure luminance. So "darker" and "more saturated" never have to fight:
   they are different multipliers on different channels, and neither moves
   the hue if green and blue are scaled as a pair.

   Darkening also BUYS contrast rather than spending it -- the brand grey
   went from 4.91:1 to 5.13:1 -- which is the opposite of nearly every
   other change in this block.

   TWO LAYERS, NOT ONE, and the reason is the drift rather than the colour: a
   single layer can only move as a unit, so wherever it goes the blobs keep
   their distance from each other and the whole thing reads as a texture
   sliding behind a window. Two layers on different periods pass through each
   other, and the colour at any point on the page is the sum of two things
   moving independently.

   20s AND 27s. The history: 73 and 97, then 20% faster to 61 and 81, then
   50% faster to 41 and 54, now doubled again.

   NOT 21 AND 27, WHICH IS WHAT HALVING ACTUALLY GIVES. 41/2 rounds to 21,
   and 21 and 27 share a factor of three -- so the pair would realign every
   189 seconds and the field would visibly repeat on a cycle short enough to
   notice. 20 and 27 are coprime (20 is 2^2 x 5, 27 is 3^3) and the nearest
   pair that is. This is the trap in every halving of these numbers: the
   obvious arithmetic lands on a common factor more often than not, so check
   the gcd rather than the difference.

   THIS IS FAST FOR A BACKGROUND, and faster than the brief these layers
   were built to. At 20 seconds the field is not drifting, it is moving:
   the colour under a section changes several times while someone reads it,
   and the two layers realign every nine minutes rather than every twenty.
   It is recorded here rather than argued because it was asked for twice --
   but if the background ever starts competing with the content for
   attention, this is the first number to put back.

   TRANSFORM, NOT BACKGROUND-POSITION. A `radial-gradient(... at 22% 18%)`
   has its centre pinned in the painting area, so animating background-position
   moves the tile and not the blob, and animating the percentages is not
   possible at all. Translating and scaling the layer moves the whole painted
   result on the compositor, with no repaint per frame.

   INSET -25% is what pays for the transform: the layers are 150% of the
   viewport in each axis, so a 1.14x scale with a 5% translate can never pull
   an edge into view.

   Z-INDEX -1 puts both behind every element in <body> while staying in front
   of the html background. This only works while <body> establishes no
   stacking context of its own -- do not add transform, filter, opacity or a
   z-index to body, or the field disappears behind its own parent.

   NO BLOB SITS ON THE TOP-LEFT CORNER, and that is deliberate rather than
   incidental. The strongest green used to be centred at 22% 18% of its own
   box, which lands at 8% 2% of the VIEWPORT -- the corner the rays enter
   by. The brightest shaft and the brightest blob were therefore stacked,
   and the result read as a yellow-green corner.

   It is at 56% 30% now, which is upper-centre-right, and 0.40 rather than
   0.49. The corner is where the light comes FROM; it does not also need to
   be where the colour is. Check the mapping before moving any of these:
   viewport = layer x 1.5 - 0.25, because the layer is 150% of the viewport
   at inset -25%.

   HOW DARK IS "EXTREMELY DARK", measured by tools/ambient_contrast.py and
   not estimated: the densest point either layer reaches on its own is
   #072014 for the green and #04191f for the teal. The two drift
   independently, so the honest worst case is one peak sitting over the
   other, which is #092d24. Against that ground:

     white body copy and headings    14.85:1
     brand green #18bd5b              5.99:1
     brand grey  #929497              4.88:1

   all clear of the 4.5:1 body threshold, so every contrast commitment the
   site makes on flat black survives. THE GREY IS THE ONE WITH THE LEAST
   ROOM, and raising these alphas is the quickest way to push it under --
   they are already pulled back about 15% from a first pass that left it at
   4.58:1, which passed and had nothing to spare. Run
   tools/ambient_contrast.py after any change to them; it parses these rules
   directly, so it cannot drift from what is written here.
   ========================================================================== */

body[data-ambient] { background: transparent; }

/* FOUR STOPS PER BLOB, NOT TWO, and the reason is the same one written up
   against the canvas edge fades in js/bars.js: a straight alpha ramp across
   an area this size BANDS VISIBLY on an 8-bit display, and it bands worst
   on a near-black ground where each step is a large proportional change.
   Two stops produced concentric rings around every blob centre.

   The stops are eased rather than evenly spaced -- roughly 100%, 70%, 33%,
   0 of the peak at 0%, 30%, 58% and 100% of the radius -- which both softens
   the falloff and breaks up the regularity the eye reads as a ring. Run
   tools/preview-ambient.py to see the result before opening the page. */
body[data-ambient]::before,
body[data-ambient]::after {
  content: "";
  position: fixed;
  inset: -25%;
  z-index: -1;
  pointer-events: none;
  will-change: transform;
}

/* Green-weighted, the slower of the two. */
body[data-ambient]::before {
  background:
    radial-gradient(42% 38% at 56% 30%, rgba(6, 60, 38, 0.400) 0%, rgba(6, 60, 38, 0.280) 20%, rgba(6, 60, 38, 0.132) 39%, rgba(6, 60, 38, 0) 68%),
    radial-gradient(48% 42% at 78% 58%, rgba(4, 53, 34, 0.430) 0%, rgba(4, 53, 34, 0.301) 21%, rgba(4, 53, 34, 0.142) 41%, rgba(4, 53, 34, 0) 70%),
    radial-gradient(38% 34% at 48% 94%, rgba(6, 64, 41, 0.340) 0%, rgba(6, 64, 41, 0.238) 22%, rgba(6, 64, 41, 0.112) 42%, rgba(6, 64, 41, 0) 72%),
    radial-gradient(36% 32% at 8% 74%, rgba(3, 40, 60, 0.310) 0%, rgba(3, 40, 60, 0.217) 21%, rgba(3, 40, 60, 0.102) 41%, rgba(3, 40, 60, 0) 70%);
  animation: ambient-drift-a 20s ease-in-out infinite alternate;
}

/* Teal-weighted, and travelling the other way. */
body[data-ambient]::after {
  background:
    radial-gradient(46% 40% at 82% 14%, rgba(3, 46, 64, 0.440) 0%, rgba(3, 46, 64, 0.308) 21%, rgba(3, 46, 64, 0.145) 41%, rgba(3, 46, 64, 0) 70%),
    radial-gradient(40% 36% at 14% 54%, rgba(2, 39, 55, 0.370) 0%, rgba(2, 39, 55, 0.259) 22%, rgba(2, 39, 55, 0.122) 42%, rgba(2, 39, 55, 0) 72%),
    radial-gradient(42% 38% at 66% 40%, rgba(1, 46, 67, 0.330) 0%, rgba(1, 46, 67, 0.231) 21%, rgba(1, 46, 67, 0.109) 41%, rgba(1, 46, 67, 0) 70%),
    radial-gradient(40% 36% at 34% 90%, rgba(1, 40, 59, 0.290) 0%, rgba(1, 40, 59, 0.203) 21%, rgba(1, 40, 59, 0.096) 41%, rgba(1, 40, 59, 0) 70%),
    radial-gradient(44% 38% at 62% 88%, rgba(3, 43, 58, 0.260) 0%, rgba(3, 43, 58, 0.182) 21%, rgba(3, 43, 58, 0.086) 41%, rgba(3, 43, 58, 0) 70%);
  animation: ambient-drift-b 27s ease-in-out infinite alternate;
}

@keyframes ambient-drift-a {
  0%   { transform: translate3d(-3%, -2%, 0) scale(1.06); }
  50%  { transform: translate3d( 4%,  3%, 0) scale(1.14); }
  100% { transform: translate3d(-2%,  5%, 0) scale(1.08); }
}
@keyframes ambient-drift-b {
  0%   { transform: translate3d( 3%,  4%, 0) scale(1.10); }
  50%  { transform: translate3d(-4%, -3%, 0) scale(1.04); }
  100% { transform: translate3d( 2%, -5%, 0) scale(1.12); }
}

/* The footer motion switch, not only the OS setting. WCAG 2.2.2: the field
   runs indefinitely, so it has to answer the on-page control like every
   canvas does. Stopped, not hidden -- the colour is the point and only the
   movement is the problem. */
html[data-motion="reduce"] body[data-ambient]::before,
html[data-motion="reduce"] body[data-ambient]::after { animation: none; }

/* THE BANDS GO TRANSPARENT, or there is nothing to see. Every .band paints
   background-color: var(--ink) over the full bleed, which would cover the
   field completely and leave it visible only in the gaps -- of which there
   are none. The band keeps its background-IMAGE, so .band--wave on the four
   campaign pages is untouched.

   .band--promo is named separately because it sets `background` as a
   shorthand, which resets background-color even if this rule came after it. */
body[data-ambient] .band,
body[data-ambient] .band--promo { background-color: transparent; }

/* AND THE HERO'S SCRIM GOES WITH THEM, which is what actually makes the top
   of the page continuous.

   There are two .band--hero::before rules, a desktop one and a mobile one,
   and both are black washes over the hero:

     desktop  opaque black from 0 to 110px, then 0.46 fading to nothing by
              about 280px down
     mobile   a full-height wash, 0.38 at the top rising to 0.60 and only
              back down to 0.22 at the bottom

   Over flat black neither was visible as anything but a slight deepening.
   Over the ambient field the desktop one puts a hard black cap and a 170px
   vignette across the first screen, and the mobile one dims the field
   through the entire hero -- so the gradient appeared to start somewhere
   below the fold on desktop and never to start at all on a phone.

   display: none rather than deleting the rules, because the four campaign
   pages share this stylesheet and none of them carries [data-ambient]:
   they keep their artwork, their flat ground and the scrim that was tuned
   for it. One attribute on <body> is still the whole switch.

   THE PLANE CANVAS STAYS. It draws on a transparent ground now, so the
   field shows between its dots rather than behind a sheet of black.

   WHAT THIS COSTS, stated plainly because it is a real cost: the scrim was
   the hero's contrast mechanism, and tools/test_heroflow.js was already
   reporting the headline at 1.53:1 against the brightest frame of the plane
   WITH the scrim in place. Removing it does not break a passing number, it
   lowers a failing one. That failure predates the ambient work and wants
   fixing at the source -- the plane's own brightness, or a backdrop local to
   the text -- rather than with a wash over the whole band. */
body[data-ambient] .band--hero::before { display: none; }

/* AND THE CONTACT BAND'S ONE REMAINING SLAB. .map-block paints #000 so the
   Google iframe has something to sit on, and .map-block--facade repeats it
   for the click-to-load state -- which is a solid black rectangle roughly
   780 by 460 sitting in the middle of the gradient, the largest flat area
   left on the page.

   Only the FACADE goes transparent. js/map-embed.js removes that class when
   the visitor loads the map, at which point .map-block's own black applies
   again and is entirely covered by the iframe. The facade already carries a
   1px #3a3a3d border of its own, so it still reads as a defined area rather
   than floating text.

   The band needs nothing else: .band--curve::before is already switched
   off, and the mesh canvas behind it draws on a transparent ground. */
/* FROSTED GLASS, not a hole. Transparent was right about the slab and wrong
   about the panel: with nothing behind it the copy sat directly on the mesh
   lattice and the gradient, which is busy under small text.

   backdrop-filter blurs whatever is actually behind the element at paint
   time -- here the contact lattice and the ambient field -- so the panel
   takes its colour from the page instead of declaring one. A fixed dark
   fill would have been a flat rectangle again, just a lighter one.

   THE 4% WHITE IS STILL NEEDED. A blur alone darkens nothing and the panel
   would read as merely out of focus; the wash is what makes it a surface.
   Low enough that the colour underneath still comes through.

   -webkit- prefix for Safari, which still wants it. Where backdrop-filter
   is unsupported the wash alone applies, which degrades to the faint panel
   this had before rather than to nothing.

   The radius and overflow go together: without the clip the blur squares
   off the rounded corners. */
/* The base rule paints #000 here so the Google iframe has a ground. That
   is replaced by the frosted surface further down rather than simply
   cleared, so this line is gone -- leaving it would have fought the wash
   depending on source order. */
/* THE FROSTED MAP PANEL IS GONE, and with it the backdrop-filter that
   caused two separate bugs worth keeping on record.

   The contact section used to hold a click-to-load facade over a Google
   Maps iframe: a frosted panel carrying a privacy notice and a Load map
   button. The embed is now a static SVG generated from OpenStreetMap by
   tools/build-map-svg.py, so there is no third-party request left to gate
   and nothing for the facade to cover.

   WHAT THE BACKDROP-FILTER TAUGHT, because the rule generalises and the
   next frosted surface on this site will meet it: an element with opacity
   below 1 is a BACKDROP ROOT FOR ITS DESCENDANTS, and a backdrop-filter can
   only sample within its own root. Put the filter inside anything whose
   opacity animates and the declaration stays valid, applied, and inert.

     first time   the scene resting at 0.996 -- the blur never appeared
     second time  the panel fading in on the body stagger -- the blur
                  arrived about a second and a half late, which reads as a
                  lag rather than a bug and survived the first fix

   mix-blend-mode on a child does the same by another route: it forces the
   parent to isolate, costing the filter its backdrop just as surely. The
   fix in both cases is to declare the filter ON the animated element
   rather than inside it. */


/* THE RAYS COST TOO MUCH ON A PHONE, and this is the fix for a sustained
   30fps rather than for an occasional dropped frame.

   .rays::before is 300% x 300% -- nine viewports of area -- carrying a
   conic gradient, blurred by 24px, animated on a transform, inside a
   fixed, masked layer. Every one of those is affordable on its own and
   on a desktop GPU. Together on a phone they are a composited surface the
   size of nine screens being re-rasterised through a blur, continuously,
   whether or not anything else is happening.

   Measured on an S24 with the diagnostic that was js/diag.js, removed at
   launch and archived in _dev/source-assets/retired/: frames pinned at
   33ms -- exactly 30fps on a 120Hz display -- for the whole of a scroll,
   with spikes to 50, 67 and 83ms. That is not a dropped frame here and
   there, it is a main thread that never catches up, and it is what the
   "content vibrates vertically" was.

   THE BLUR GOES, THE RAYS STAY. The conic gradient's own stops are
   already soft -- 0 to 0.031 to 0.055 over seventeen degrees -- and
   .rays carries a radial mask that fades the whole thing out before the
   edges. The blur was adding softness to something already soft. Dropping
   it removes the per-frame rasterisation without removing the effect.

   180% rather than 300% as well: the element only has to be large enough
   that rotating it about its top-left corner never pulls an edge into
   view. The sweep is +/-2.4 degrees, so 300% was generous by a factor of
   roughly two.

   Keyed to the same query the rest of the mobile layout uses, so there is
   one definition of "small" on this page. */
@media (max-width: 799px), (pointer: coarse) and (max-width: 1024px) {
  .rays::before {
    width: 180%;
    height: 180%;
    filter: none;
  }
}

/* ==========================================================================
   SCENES
   --------------------------------------------------------------------------
   One section, one screen, one snap point. Scrolling moves from a section
   to the whole of the next section rather than through it.

   THIS REPLACED A PINNED VERSION, and the reason is worth keeping. That one
   gave each section a track 1.7 viewports tall with a sticky stage inside,
   so the content was genuinely motionless while the track passed. It worked
   and it was wrong: 0.7 of every viewport was travel between one section and
   the next with nothing to look at, and the page became much longer in
   exchange for that dead space. Snapping gets the same stillness -- the page
   settles on a section and stays there -- without the gaps, because the
   travel is a transition rather than a stretch of scrolling.

   scroll-snap-stop: always is what makes it a TRANSITION rather than a hint.
   Without it a fast flick skates over several snap points and lands wherever
   momentum ran out; with it every gesture advances exactly one section.

   SCOPED TO html.stage-ready, which js/stage.js adds and only when motion is
   allowed. So: no JavaScript, or reduced motion, and the page is an ordinary
   scrolling document. Snap is a motion behaviour -- it takes the scroll
   position out of the visitor's hands -- so it belongs behind the same
   switch as everything else here. WCAG 2.2.2.

   EXACTLY ONE VIEWPORT, NOT "AT LEAST". This used min-height for a while,
   so a section could grow past the screen on a phone -- legal under
   `mandatory`, since the browser permits any scroll position that keeps the
   viewport inside an oversized snap area. It scrolled correctly and it felt
   wrong: a section taller than the screen has no single resting position, so
   the content never sits where the eye expects and the snap stops reading as
   one screen per section. Fixed height restores that, and the content is
   made to fit instead -- see the artwork cap in the mobile block below.

   SYMMETRIC PADDING, which is what actually centres anything. The stage used
   to clear the fixed header with top padding alone and take a smaller value
   at the bottom. align-content: center then centred the content in what was
   LEFT, not in the viewport, putting everything about 30px low -- enough to
   read as not quite landing, which is exactly how the event card looked.
   Equal padding top and bottom means the optical centre and the viewport
   centre are the same point. The header is transparent now, so the space it
   needs at the top costs nothing at the bottom.

   svh, NOT vh. On mobile browsers vh resolves against the LARGEST viewport
   -- the one with the address bar collapsed -- so a scene sized in vh is
   taller than the screen whenever the bar is showing, which is exactly when
   the page first loads. Every section would start cropped and settle only
   after the first scroll. svh is the small viewport, so it fits from the
   first frame. */

@media (min-width: 800px) and (min-height: 620px), (max-width: 799px) {
  html.stage-ready { scroll-snap-type: y mandatory; }

  body[data-ambient] .scene {
    height: 100vh;          /* fallback for anything without svh */
    height: 100svh;
    padding-block: 0;

    /* CLIP, BUT NOT TIGHT TO THE BOX.

       js/stage.js pins the stage INSIDE this scene with a translateY while
       the scene is within a fade-length of its snap point -- on the stage
       and not on the scene itself, because the snap area is the transformed
       border box and transforming the snap element makes the browser chase
       its own target (see the long note in stage.js).

       The consequence here is that a held stage can hang up to one fade
       length outside its scene, and `overflow: clip` would cut it off at
       the edge -- a hard horizontal crop across the content for the length
       of every transition.

       overflow-clip-margin pushes the clip edge out past the largest hold
       the module can ask for. CFG.snapFade is 0.14, so 14svh is the real
       bound; 18svh leaves room to raise it without this becoming a second
       place that has to be kept in step.

       STILL CLIPPING, deliberately. The canvases overdraw their hosts and
       the scenes must not leak into each other's screens. */
    overflow: clip;
    overflow-clip-margin: 18svh;

    scroll-snap-align: start;
    scroll-snap-stop: always;
    /* .band--hero is display: flex; align-items: center, which would make
       the stage a flex item rather than the full-height box it needs to
       be. */
    display: block;
  }

  /* THE FOOTER NEEDS A SNAP POINT OF ITS OWN. Under `mandatory` the scroll
     position must always rest on one, so a footer with no snap point is a
     footer the browser pulls you back off every time you reach for it.

     `end` rather than `start`: the footer is a few hundred pixels tall, so
     aligning its top to the top of the screen would leave most of a blank
     viewport underneath it. Aligning its bottom to the bottom of the screen
     shows the last section above it and the footer below, which is what the
     end of a page should look like. */
  body[data-ambient] .site-footer { scroll-snap-align: end; }

  body[data-ambient] .scene__stage {
    height: 100%;
    display: grid;
    align-content: center;
    /* EQUAL TOP AND BOTTOM. See the note above: an asymmetric pair centres
       the content in the leftover space rather than in the viewport. The
       value is the header's shrunk height, which is what the first screen
       has to clear. */
    padding-block: var(--header-h-shrunk);
  }

  /* THE CONTENT ABOVE THE CANVAS. `.band > .container { z-index: 2 }` no
     longer matches: the container is a grandchild now that the stage sits
     between them. That is the exact bug written up against
     .band--promo::before, and it bites here for the same reason, so the
     container is lifted again explicitly. The injected .band__mesh is a
     later sibling at z-index 0 and would otherwise paint over the type. */
  body[data-ambient] .scene__stage > .container {
    position: relative;
    z-index: 1;
  }

  /* Each solution is alone in its own scene now, so the rhythm that used to
     separate four of them inside one band is only adding height to a box
     that has to be exactly one screen tall. */
  body[data-ambient] .solution + .solution { margin-top: 0; }
  body[data-ambient] .solution { min-height: 0; }

  /* The hero's own padding is replaced by the stage's. Left on, the two
     would add up and push the headline below centre.

     AND ITS min-height GOES WITH THE PADDING, which is the whole of the
     "the hero needs four arrow presses and comes back faded" bug.

     .band--hero carries min-height: clamp(560px, 57vw, 900px) for the
     unsnapped pages, where it is what gives the hero its size. A scene is
     `height: 100svh`, and MIN-HEIGHT BEATS HEIGHT -- so on a wide, short
     window the hero was the one scene taller than the viewport: 862px
     against 754px at 1512x754.

     Both symptoms fall out of that 108px. A snap area taller than the
     snapport may rest anywhere it still covers the screen, so the first
     108px of scroll changed nothing and the fourth arrow press was the
     first one to reach the next snap point. And anywhere past it, the
     hero's bottom edge has left the screen, so sceneFade() is measuring a
     real distance and dimming a section the visitor believes they are
     still on -- 0.92 at 150px, 0.20 at 200px.

     Neither needed a change to stage.js: the fade was reporting the
     geometry it was given, correctly. The geometry was wrong. Same fix and
     same reason as .solution twelve lines up and .promo in the promo
     block; the hero was simply missed. */
  body[data-ambient] .band--hero { padding-block: 0; min-height: 0; }

  /* SCROLL-MARGIN OFF, and this is the whole of the "some sections do not
     centre" bug.

     `.band[id] { scroll-margin-top: var(--header-h-shrunk) }` exists so that
     following an anchor like index.html#about-us lands below the fixed
     header rather than under it. Harmless for an anchor. Not harmless for a
     snap point: scroll-margin EXPANDS the snap area, so a section with
     110px of it aligns `start` 110px lower than its own top edge. The
     content sits 110px below centre and the section carries 110px of extra
     travel before the next one takes over.

     It bit exactly three sections -- #sff-2026, #about-us and #contact-us --
     and no others, which is why it looked like those bands were sized
     differently. They are not. The four solution blocks carry their ids on
     the inner <article>, which is not the snap target, so .band[id] never
     matched them and they behaved correctly all along. That difference is
     the whole reason this was hard to see.

     Safe to zero because the job it was doing is done twice over now: the
     stage pads itself clear of the header at top and bottom, and the header
     is transparent, so there is nothing for an anchor to land underneath. */
  body[data-ambient] .scene[id] { scroll-margin-top: 0; }

  /* SECTION HEADINGS, 30% UP. The base h2 is
     clamp(2.14rem, 2.16vw + 1rem, 2.8225rem); every term here is that
     multiplied by 1.3, so the curve keeps its shape and its relationship to
     the viewport rather than being re-guessed at a new size. Roughly 45px
     to 59px across the range, against the base's 34 to 45.

     DOWN FROM DOUBLE, which was tried and was too much -- at 90px the
     headings stopped being the loudest thing in a section and became the
     only thing in it, and the longest of them threatened to overflow a
     section that is now exactly one screen tall.

     No line-height override at this size. Doubling needed one, because
     leading that suits 45px is a visible gap at 90px; 30% is well inside
     the range the measured 1.16 was chosen for, and leaving it alone is one
     fewer departure from the brand's own numbers.

     Scoped to .scene__stage, so this is the home page's section headings
     and nothing else. The legal pages set their own h2 size and the base
     rule still serves every other page. */
  body[data-ambient] .scene__stage h2 {
    font-size: clamp(2.782rem, 2.808vw + 1.3rem, 3.669rem);
  }
}

/* THE ARTWORK HAS TO FIT, now that a scene is exactly one screen rather than
   at least one. On a narrow viewport the grid stacks, so a solution is a
   heading, a paragraph and a 4:3 canvas in a column -- and the canvas, being
   the only thing sized by ratio rather than by content, is what overflows.
   Capping its HEIGHT and letting aspect-ratio take the width back is the
   change that costs nothing elsewhere: the canvases redraw at whatever size
   they are given, so this is a smaller picture rather than a cropped one. */
@media (max-width: 799px) {
  /* Back to 36svh. This was cut to 28 to make room for the doubled
     headings; at 30% the heading has given that room back, and on a phone
     the grid stacks heading and canvas in one column where every pixel is
     contested. */
  /* NO margin-inline: auto HERE. It was, and it blanked all three canvases
     at this breakpoint -- which is the whole of "the images don't show up
     on mobile".

     A canvas host contains nothing that occupies space: the <canvas> is
     position: absolute, and the <img> beside it is display: none once the
     script is live (and its PNG does not exist in the repo anyway). So its
     MAX-CONTENT width is zero.

     margin-inline: auto stops a grid item stretching, which makes its
     inline size its max-content size. Zero. aspect-ratio: 4/3 then derives
     a height of zero from that, resize() bails on !r.width leaving w and h
     at 0, and draw() returns on its first line for the life of the page.

     Nothing throws, nothing logs, and the box still occupies its grid
     cell -- so the section looks correctly laid out with an empty space
     where the picture goes. It only applies under 799px, which is why it
     read as a mobile-specific fault rather than a one-line CSS mistake.

     THE CENTRING IS BACK, AND SAFE, because the width is now DEFINITE.
     margin-inline: auto only collapses an item whose inline size would fall
     back to max-content; given an explicit width it simply centres, which
     is all it was ever meant to do.

     width: min(100%, 48svh) is the 4:3 box the artwork wants, expressed
     from the height cap rather than repeated: 36svh of height at 4:3 is
     48svh of width. So the box is a true 4:3 block, centred in its column,
     and never taller than the cap -- instead of a full-width box with the
     drawing letterboxed inside it, which is what removing the centring left
     behind.

     The pair has to stay together. A future edit that drops the width and
     keeps the margin brings the blank canvases straight back, which is why
     tools/test_stage.js checks for a collapser WITHOUT an accompanying
     width rather than for the collapser alone. */
  body[data-ambient] .scene__stage .split__art {
    width: min(100%, 48svh);
    max-height: 36svh;
    margin-inline: auto;
  }
  body[data-ambient] .scene__stage .split__art > canvas,
  body[data-ambient] .scene__stage .split__art > img {
    max-height: 36svh;
    object-fit: contain;
  }

  /* NO MOBILE OVERRIDE FOR THE HEADING ANY MORE. The doubled version needed
     one: its vw term fell below its own floor on a narrow screen, so the
     size stopped tracking the viewport and had to be restated in vw.

     At 30% the single clamp already covers every width. Its floor -- 2.782rem,
     about 44.5px -- is what applies below roughly 800px, which is the base
     rule's own mobile floor times 1.3. One declaration, no second number to
     keep in step with the first. */
}

/* THE FOOTER STAYS SOLID BLACK. It is the end of the page, and a hard edge
   there is what says so -- the field running out through the bottom of the
   document leaves the page with no bottom. Nothing to declare:
   .site-footer already sets #000. This note exists so it is not "fixed".

   THE HEADER IS THE OPPOSITE CASE and goes transparent, which looks like an
   inconsistency and is not. The footer sits at the end of the content; the
   header sits ON TOP of it, across the whole first screen. An opaque bar
   there is a black band cut out of the field at the exact place the field
   is brightest.

   TRANSPARENT AND NOTHING ELSE. NO backdrop-filter -- it was here and it
   came out.

   The argument for it was that blurring a smooth gradient is a visual
   no-op, so the field would read straight through while the blur caught
   content in transit during a snap transition. The first half is true and
   the second half is the problem: the gradient is not the only thing behind
   this bar. The hero's dot plane sits directly under it for the whole first
   screen, and the contact lattice does the same lower down. Those have
   plenty of detail, so the blur had something to work on and showed as a
   frosted band across the top of exactly the two sections with artwork --
   present on some screens and absent on others, which is what makes it read
   as a glitch rather than a treatment.

   So the header is plain transparent and content passing beneath it is
   simply visible for the fraction of a second a transition takes. That is
   the honest trade: the bar is invisible as asked, and the cost is a moment
   of overlap rather than a permanent panel.

   The mobile menu is unaffected: .site-nav takes over the whole viewport
   with its own opaque background when it opens, so nothing here is what
   makes that panel readable. */
/* ==========================================================================
   RAYS
   --------------------------------------------------------------------------
   Light entering from the top-left corner and falling across the page. The
   point is DEPTH: the gradient layers underneath are large and diffuse, so
   the field has colour but no sense of anything casting it. Light with a
   source and a direction is what makes a flat surface read as a lit space.

   A CONIC GRADIENT, NOT ANGLED BANDS, and that is both fixes at once.

   The first version was a repeating-linear-gradient rotated 16 degrees and
   translated sideways. Two things were wrong with it. Parallel bands have
   no origin -- they slide past like a barcode rather than radiating from
   anywhere -- and the shafts had to be built from hard colour stops, which
   meant leaning on a 40px blur to hide four visible edges. A blur that
   large over a layer this size is expensive, some browsers downsample it,
   and what came back was the opposite of smooth.

   A conic gradient interpolates across ANGLE from a point. Put that point
   at the viewport's top-left corner and every stop becomes a wedge
   spreading as it travels -- real rays, with an apex, fanning the way light
   through a window does. And because each shaft ramps up and down over
   15-ish degrees rather than jumping at a stop, the falloff is already
   smooth before any blur is applied. The 24px left below is for the first
   few hundred pixels near the apex, where the angular steps are physically
   closest together; everything past that is smooth on its own.

   THE ANGLES RUN 86 TO 174 DEGREES because CSS measures conic angles
   clockwise from straight up. From a top-left origin the viewport occupies
   90 degrees (due right) through 180 (straight down), so that span is the
   whole of the visible quadrant and anything outside it is painting off
   screen.

   THE FIRST SHAFT IS THE COOLEST ON PURPOSE. At 103 degrees from a
   top-left apex it lies almost along the top edge of the screen, so it fell
   on the same corner as the strongest green blob of the field -- and two
   warm greens stacked read as yellow-green, which is not in this palette.
   It is rgb(128,206,206), a neutral teal, at 0.031 rather than the warmer
   rgb(146,224,190) at 0.042 it started as.

   The other half of that fix is in the AMBIENT FIELD block above, where the
   blob underneath moved off the corner. Neither works alone: whichever was
   left would still read warm against a dark field.

   THE MASK IS WHAT MAKES IT A SOURCE. Light falls off with distance; rays
   at full strength in the bottom-right corner read as a printed pattern.
   The radial mask on the wrapper takes the whole layer down as it travels
   from the corner, so the top-left is where the light is and the far corner
   is where it has run out. On the WRAPPER, not the rotating child, so the
   falloff stays anchored to the corner while the shafts sweep.

   THE SWEEP IS A ROTATION ABOUT THE APEX, alternating a couple of degrees
   either way over 90 seconds. Pinned at the corner by transform-origin: 0 0,
   so the source never moves -- only the angle of what it casts, like light
   through something passing outside. Alternating rather than looping means
   there is no restart to hide: it simply reverses.

   THE CHILD IS 300% SQUARE so the rotation can never pull an edge into
   view. At viewport size a 2-degree turn about the corner would swing the
   far side off the screen.

   CONTRAST: tools/ambient_contrast.py measures these with the gradient
   layers, compositing the brightest shaft over the worst-case ground. This
   is the only layer on the page that ADDS light, so it is the only one that
   can push text under threshold. Run it after changing any alpha here. */

.rays {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  overflow: hidden;
  /* One dial for the whole effect. Separate from the stop alphas so this
     can be turned down without disturbing the balance between shafts.

     0.70, DOWN FROM 0.85, and the number came from the contrast tool rather
     than the eye. At 0.85 the brand grey measured 4.55:1 against the
     brightest ground -- passing, with half a hundredth to spare, which is
     not a margin. 0.70 restores it to 4.71. The rays are the only layer
     here that adds light, so this dial is the one that decides whether the
     page still passes. */
  opacity: 0.70;
  /* Distance falloff from the corner the light enters by. */
  -webkit-mask-image: radial-gradient(135% 135% at 0% 0%,
    #000 0%, rgba(0, 0, 0, 0.72) 34%, rgba(0, 0, 0, 0.3) 62%, transparent 86%);
  mask-image: radial-gradient(135% 135% at 0% 0%,
    #000 0%, rgba(0, 0, 0, 0.72) 34%, rgba(0, 0, 0, 0.3) 62%, transparent 86%);
}

.rays::before {
  content: "";
  position: absolute;
  top: 0;
  left: 0;
  width: 300%;
  height: 300%;
  transform-origin: 0 0;
  background: conic-gradient(from 0deg at 0% 0%,
    rgba(128, 206, 206, 0) 86deg,
    rgba(128, 206, 206, 0.031) 103deg,
    rgba(128, 206, 206, 0) 118deg,
    rgba(132, 206, 214, 0) 125deg,
    rgba(132, 206, 214, 0.030) 136deg,
    rgba(132, 206, 214, 0) 148deg,
    rgba(150, 228, 186, 0) 152deg,
    rgba(150, 228, 186, 0.034) 161deg,
    rgba(150, 228, 186, 0) 174deg);
  filter: blur(24px);
  will-change: transform;
  animation: rays-sweep 90s ease-in-out infinite alternate;
}

/* Small numbers on purpose. The apex is at the corner, so a degree of
   rotation moves the far end of a shaft a long way -- this is already a
   sweep of several hundred pixels across the bottom of the screen. */
@keyframes rays-sweep {
  from { transform: rotate(-2.4deg); }
  to   { transform: rotate(2.4deg); }
}

/* WCAG 2.2.2, and the footer switch as well as the OS setting. STOPPED, NOT
   HIDDEN -- unlike the dust that briefly lived here, a still shaft of light
   is still a shaft of light. Only the sweep is the problem; the depth it
   gives the field is not. */
html[data-motion="reduce"] .rays::before { animation: none; }


/* THE CARD'S HIGHLIGHT IS A SECOND BACKGROUND LAYER ON ITS EXISTING SCRIM,
   not a pseudo-element of its own, and that is the fix for a visible bar.

   .promo::after is already taken: it is the 0.38 black scrim over the
   card's video. One element has one ::after, so the card could not join the
   shared rule above -- that would have REPLACED the scrim rather than added
   to it, leaving the copy to fight a bright frame of video.

   The first attempt put the highlight on .promo__inner instead, the padding
   wrapper, on the assumption that it covered the same area. IT DOES NOT.
   .promo is `display: flex; align-items: center` with a min-height of
   15-20rem, and the inner is only as tall as its content -- so on a wide
   screen the card is taller than the wrapper and the wrapper is centred in
   it. A screen-blended highlight clipped to the wrapper therefore lit the
   middle band of the card and stopped dead at a horizontal edge, and the
   unlit strip above it read as a shaded bar across the top of the video.

   Stacking the highlight INTO .promo::after fixes it at the source: that
   pseudo-element is inset: 0 on the card itself, so the light covers
   exactly what the scrim covers, which is exactly the card.

   NO mix-blend-mode NEEDED on this path. The shared rule above screens
   because it sits over live canvas artwork; here the highlight is simply
   the upper layer of a two-layer background, composited over the scrim the
   normal way. A pale radial at 0.06 over 38% black lifts that corner
   without a blend mode, and avoids giving the card a second compositing
   layer it does not need. */
body[data-ambient] .promo::after {
  background:
    radial-gradient(118% 118% at 0% 0%,
      rgba(168, 232, 206, 0.062) 0%,
      rgba(150, 214, 212, 0.028) 38%,
      rgba(255, 255, 255, 0) 74%),
    rgba(0, 0, 0, 0.38);
}


body[data-ambient] .site-header { background: transparent; }

/* ==========================================================================
   THE INTERIOR PAGES TAKE THE FIELD TOO -- AND THE HEADER HAS TO FILL IN
   --------------------------------------------------------------------------
   privacy.html, accessibility.html and 404.html carry data-ambient="page"
   rather than a bare data-ambient. Every rule above is written
   [data-ambient] with no value, so all of them still match and the field,
   the transparent bands and the print suppression all come along unchanged.
   The value exists for ONE reason: the header.

   THE HOME PAGE IS DARK ALL THE WAY DOWN, so a permanently transparent bar
   is correct there and the rule above says so. THESE THREE ARE NOT. They are
   a dark heading band on top of .doc, which is a WHITE ground -- the
   deliberate light-reading exception for long-form legal copy, written up
   further down this file. The bar is position:fixed, so it travels over both.

   TWO PROBLEMS, NOT ONE, and the second is the one that is easy to miss.
   White nav links on a transparent bar over #ffffff is the obvious failure.
   The subtler one is what happens on the way there: the h1 and the eyebrow
   in the heading band scroll UP THROUGH the transparent bar, so for a few
   hundred pixels there is white display type crossing white nav links. Both
   are legible on their own and the collision is not.

   THE FIX IS A THRESHOLD, NOT A MEASUREMENT. Transparent only at the very
   top of the page, solid black from the first scroll -- so the bar is
   already opaque before any content reaches it and the overlap window never
   opens. This is the commonest treatment on the web for a reason.

   IT NEEDS NO JAVASCRIPT OF ITS OWN. js/nav.js already sets data-shrunk at
   40px to take the bar from 129px to 110px, which is the same moment. Keying
   the background off that attribute means one threshold, one listener, and
   the fill and the shrink happening as a single gesture rather than two
   events a few pixels apart. An earlier version of this measured the bar
   against .doc on every scroll frame; it worked and it was redundant.

   POLARITY IS DELIBERATE. Solid is the base rule and transparent is the
   exception, so with no JavaScript data-shrunk is never set, the second rule
   never matches, and the header is simply solid black as on every other
   page. The failure mode of this feature is the old behaviour.

   Specificity, since three rules now target the same element:
     body[data-ambient] .site-header                     (0,2,1)  above
     body[data-ambient="page"] .site-header              (0,2,1)  later, wins
     body[data-ambient="page"] ...[data-shrunk="false"]  (0,3,1)  wins outright
   The middle one only beats the first by source order. Do not move it above. */
body[data-ambient="page"] .site-header { background: var(--ink); }
body[data-ambient="page"] .site-header[data-shrunk="false"] { background: transparent; }

/* ==========================================================================
   SECTION NAV
   --------------------------------------------------------------------------
   A segmented bar down the right edge: one segment per scene, the current
   one lit. It says how many sections there are and which one you are on,
   which a snapping page needs more than a scrolling one does -- when the
   page moves a whole screen at a time there is no scrollbar thumb creeping
   down to tell you where you are.

   INJECTED BY js/stage.js, never written into the HTML, for the same reason
   the canvases are: it describes a behaviour that only exists when that
   module runs. No JavaScript, or reduced motion, and there is no snapping,
   no scene concept, and so nothing for this to indicate -- and nothing is
   rendered.

   REAL BUTTONS, not decoration. Each one jumps to its section, each carries
   the section's own heading as its accessible name, and the current one is
   marked aria-current. A progress indicator that cannot be operated is a
   picture of a control, which is worse than no control at all for anyone
   navigating by keyboard.
   ========================================================================== */

.scene-nav {
  position: fixed;
  right: clamp(0.6rem, 1.4vw, 1.25rem);
  top: 50%;
  transform: translateY(-50%);
  z-index: 60;
  /* The gaps between segments must not swallow clicks meant for the page
     behind them. Re-enabled on the buttons themselves below. */
  pointer-events: none;
}

.scene-nav ol {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

/* THE BUTTON IS 24px WIDE AND THE BAR INSIDE IT IS 2px.

   WCAG 2.5.8 asks for a 24x24 target. A 2px-wide control is a 2px-wide
   target, so the button carries the size and a ::before carries the look --
   the visible mark is a hairline, the thing you can hit is a finger's
   width. Transparent background, so the extra 22px are invisible. */
.scene-nav__dot {
  pointer-events: auto;
  position: relative;
  display: block;
  width: 24px;
  height: 26px;
  padding: 0;
  border: 0;
  background: none;
  cursor: pointer;
  -webkit-appearance: none;
  appearance: none;
}

.scene-nav__dot::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  right: 0;
  width: 2px;
  border-radius: 1px;
  background: rgba(255, 255, 255, 0.22);
  transform-origin: right center;
  transition: background-color 0.35s ease, transform 0.35s ease;
}

/* The current one thickens rather than growing taller. A height change
   would move every segment below it, so the bar would shuffle on every
   transition; scaleX is a transform, so nothing reflows and the segments
   stay exactly where they are. */
.scene-nav__dot[aria-current="true"]::before {
  background: var(--on-dark);
  transform: scaleX(2);
}

.scene-nav__dot:hover::before { background: rgba(255, 255, 255, 0.6); }

/* #1de26d, not --accent, and the reasoning is the one recorded against the
   hero cue further up this file: the brand green measures 3.43:1 against
   the brightest field this sits over, and a focus ring is a non-text
   contrast case under WCAG 1.4.11 at 3:1 -- but the same green at 50%
   lightness clears 4.92:1 and matches what the cue used, so the two
   controls on this page agree. */
.scene-nav__dot:focus-visible {
  outline: 2px solid #1de26d;
  outline-offset: 3px;
  border-radius: 2px;
}

@media (max-width: 799px) {
  /* Smaller and closer in, where the viewport is narrow and the segments
     are nearer the content. The target stays 24px wide: that rule is about
     fingers, and this is the case it was written for. */
  .scene-nav ol { gap: 6px; }
  .scene-nav__dot { height: 18px; }
}

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

/* Outfit Light with POSITIVE tracking. Measured: h1 weight 300, +0.0201em;
   h2 weight 300, +0.0201em. The previous build used weight 500 and
   -0.025em, which is what made it read tight and editorial rather than
   open like the live site. */
h1, h2, h3, h4 {
  font-family: var(--font-display);
  color: var(--on-dark);
  line-height: 1.16;
  letter-spacing: 0.02em;
  margin: 0 0 0.5em;
  font-weight: 300;
  text-wrap: balance;
}

/* Sizes derived from Squarespace's own formulae rather than eyeballed.
   Live builds every size as (size-value - 1) x scale + 1rem, with
   --heading-1-size-value 3.8, --heading-2-size-value 2.8 and
   --normal-text-size-value 1.2:

     desktop  h1  min(3.36vw + 1rem, 61.36px)      measured 61px at 1512
     desktop  h2  min(2.16vw + 1rem, 45.16px)      measured 45px at 1512
     mobile   h1  2.8 x 0.012 x min(100vh,900) + 1rem = 44.36px at 844vh
     mobile   h2  1.8 x 0.012 x min(100vh,900) + 1rem = 34.23px at 844vh

   Live's mobile branch scales off viewport HEIGHT, which means heading size
   depends on how tall the phone is and drops in landscape. Deliberate
   departure: the clamp floors below reproduce live's phone sizes while
   staying width-driven, so the scale is predictable and does not jump on
   rotation. The ceilings and the slope are live's exactly, so desktop is
   pixel-identical. */
h1 { font-size: clamp(2.775rem, 3.36vw + 1rem, 3.835rem); line-height: 1.164; }
h2 { font-size: clamp(2.14rem,  2.16vw + 1rem, 2.8225rem); line-height: 1.2; }
h3 { font-size: clamp(1.15rem, 1.4vw, 1.5rem); }

p { margin: 0 0 1.15em; }
p:last-child { margin-bottom: 0; }

a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; text-underline-offset: 0.15em; }

ul { margin: 0 0 1.15em; padding-left: 1.15em; }
li { margin-bottom: 0.4em; }

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

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  background: var(--on-dark);
  color: var(--ink);
  padding: 0.75rem 1.25rem;
  z-index: 100;
}
/* Plain :focus, deliberately.

   A :focus:not(:focus-visible) override briefly lived here, to stop the
   link appearing when js/gate.js handed it focus after the preview gate
   unlocked. It was the wrong fix twice over. It only caught the mouse path,
   since submitting the gate with Enter is keyboard interaction and
   :focus-visible rightly matches; and where it did work it left a focused
   control with no visible focus indicator, which is a WCAG 2.4.7 failure
   swapped in for a cosmetic complaint.

   The cause was the focus target, not this rule. gate.js now hands focus to
   <body>, where it would sit after any ordinary page load, so nothing
   focuses the skip link unless a visitor actually tabs to it. When they do,
   they should see it. */
.skip-link:focus { left: 0; }

/* THE SKIP LINK'S DESTINATION, which is a focus target and not a control.

   <main> carries tabindex="-1" so the link can actually hand focus to it;
   without that, activating the link moved the scroll position and left
   focus on <body>, so the next Tab began again at the masthead and the
   link skipped nothing.

   The side effect is this rule's reason for existing. Following the link
   with the keyboard makes main match :focus-visible, and the global ring
   above then draws a 2px outline around EVERY PIXEL of the page content.

   Suppressing it is not the 2.4.7 failure that suppressing a ring usually
   is. That criterion is about keyboard-operable controls, and main is
   never in the tab order: -1 means it can only be focused by being
   targeted. It takes focus, announces itself to a screen reader and hands
   the next Tab to the first control inside it. The feedback a sighted user
   gets is the page jumping to the content, which is what they asked for.
   The ring on the next real control is the one that matters, and it is
   untouched.

   Scoped through the attribute so that if the tabindex is ever removed
   this rule stops matching with it, rather than silently hiding a ring on
   something that has become focusable for a different reason.

   :focus-visible AND NOT :focus, which is the narrower of the two and the
   accurate one. The ring is drawn by the global :focus-visible rule above,
   so that is the only state in which there is anything to suppress;
   focusing main from script matches :focus alone and was never going to
   draw one. Worth knowing when reading the audit: tools/a11y_full.js
   exempts outline removals scoped to :focus-visible from its 2.4.7 check,
   so this spelling also keeps that sweep clean. The exemption is not the
   reason for the spelling -- change it to :focus and the rule gets wider
   and the warning comes back, both of which would be worse. */
main[tabindex="-1"]:focus-visible { outline: none; }

/* ==========================================================================
   THE 24-COLUMN GRID
   ========================================================================== */

/* Full-bleed, as live is. No max-width: the fluid engine spans the viewport. */
.container {
  width: 100%;
  padding-inline: var(--gutter);
}

/* The 26-column grid. Children are placed with .col-* classes carrying the
   same column lines the live site uses, so the mapping is one to one. */
.grid {
  display: grid;
  grid-template-columns: repeat(var(--grid-cols), minmax(0, 1fr));
  column-gap: var(--grid-gap);
  row-gap: clamp(2rem, 4vw, 3.5rem);
  align-items: center;
}

/* Column spans, named for their live grid-area values.

   3-14 and 16-26 were the two-column hero, headline left and the client
   list right. The hero is centred now and uses neither; both are kept
   because the campaign pages and any future two-column band can still
   want them, and because they document the live site's own grid, which is
   what this whole 26-column system is for. Delete them only if that stops
   being true. */
.col-3-14  { grid-column: 3 / 14; }   /* was the hero heading  */
.col-16-26 { grid-column: 16 / 26; }  /* was the hero form     */
.col-3-13  { grid-column: 3 / 13; }   /* solution text, left   */
.col-15-25 { grid-column: 15 / 25; }  /* solution art, right   */
.col-3-9   { grid-column: 3 / 9; }    /* contact heading       */
.col-11-25 { grid-column: 11 / 25; }  /* contact details       */
.col-3-25  { grid-column: 3 / 25; }   /* full measure          */

/* Contact aligns its two columns to the top, not the centre: the address
   block is 269px and the map 469px, so centring would float the text. */
.grid--top { align-items: start; }

/* Solutions 2 and 4 swap sides: text right, artwork left. */
.grid--reverse > .col-3-13  { grid-column: 15 / 25; order: 2; }
.grid--reverse > .col-15-25 { grid-column: 3 / 13;  order: 1; }

/* MOBILE PLACEMENT, from live's own mobile grid-areas.
   Live's areas are expressed against its 10-track grid (lines 2 to 10 being
   the 8 content columns). Here the gutter is container padding, so those
   map to lines 1 to 9:

     live 2/10  full content width   ->  1 / 9
     live 3/9   inset one column     ->  2 / 8
     live 4/8   inset two columns    ->  3 / 7

   Not everything is full-bleed on live, which the previous build assumed. */
@media (max-width: 799px), (pointer: coarse) and (max-width: 1024px) {
  .grid { row-gap: 2rem; }

  .grid > [class*="col-"],
  .grid--reverse > .col-3-13,
  .grid--reverse > .col-15-25 { grid-column: 1 / 9; }

  /* Artwork always follows its text, whichever side it sat on.

     .map-static is listed explicitly. It is not a .split__art, so it kept the
     default order of 0 and stacked ABOVE the contact details, which have
     order 1. Anything that is artwork-like in a two-column band needs to be
     named here or it will jump to the top on mobile. */
  .grid > .split__art,
  .grid--reverse > .split__art,
  .grid > .map-static { order: 2; }
  .grid > .split__text, .grid--reverse > .split__text { order: 1; }

  /* The About lockup is inset on live (mobile area 17/3/20/9), not
     full-bleed like the copy above it. */
  .band--crest .split__art { grid-column: 2 / 8; }

  /* THE TWO LOGO SLIDES SHARE ONE GAP, 1rem, and sharing it is the point.

     These are the only two bands on the home page whose artwork is a
     LOCKUP rather than a canvas drawing -- Rozen under "Keep it in the
     family", Chronicle under "Business before technology". Stacked on a
     phone they read as a pair, one after the other, so an uneven gap
     between copy and mark is visible in a way it is not on the solution
     bands, where the artwork is a globe or a map and no two are alike.

     THEY WERE UNEVEN. .band--crest was given 1.25rem for its own reasons
     -- it has the most to fit, an eyebrow, a heading, two paragraphs and
     a lockup inside one 100svh scene -- while Rozen kept the 2rem every
     band gets from `.grid`. 20px against 32px, on the two slides most
     likely to be compared.

     1rem FOR BOTH, which is tighter than either was. A lockup is a
     picture of a name, so it belongs closer to the sentence naming it
     than a diagram belongs to its paragraph; the wider gap was never
     doing work here. It also returns 16px of height to .band--crest, the
     tallest scene on a phone, on top of what the 1.25rem already saved.

     A MODIFIER IN THE MARKUP, .grid--lockup, rather than
     `.grid:has([data-logo-dots])`. The :has() version reads better and
     was written first, but :has() appears nowhere else in this stylesheet
     and the one place it is discussed -- see .event-rule--to-heading --
     records the house answer as a modifier class. Two class attributes
     and one flat selector also need no feature support to reason about.

     NOT .band--plain, which would have been the obvious band to key off
     and is wrong: four sections on the home page carry it, and three of
     them are solution bands whose artwork is a canvas with room to spare.
     The modifier marks the two lockup grids and nothing else. */
  .grid--lockup { row-gap: 1rem; }

  /* NUDGED DOWN 1.5rem, and only this band.

     The scene stages are `align-content: center` with an equal
     `padding-block: var(--header-h-shrunk)` top and bottom, which centres
     the content in the viewport rather than in the space below the header.
     That was right while the bar slid away on the way down. The home page
     keeps its bar now (see js/nav.js), so on About -- the tallest of the
     four scenes on a phone -- the eyebrow was sitting under a header that
     is no longer going anywhere.

     ASYMMETRIC ON PURPOSE. Adding to the top alone moves the whole block
     down rather than squeezing it, which is what is wanted: there is room
     at the bottom and none at the top. The 12px freed by the row-gap above
     covers most of the 24px this costs, so the block grows by about half a
     line overall.

     THE SELECTOR NEEDS body[data-ambient], not .band--crest alone. The
     rule it is overriding is `body[data-ambient] .scene__stage` at
     (0,2,1); a bare `.band--crest .scene__stage` is (0,2,0) and would lose
     however late it appeared in the file. This is (0,3,1).

     AND IT IS NESTED INSIDE THE SNAP QUERY, which is not tidiness either.
     The padding this adds to only exists while the scenes are snapping;
     outside that, `.scene__stage` is an ordinary div with no padding at
     all and this would not be an adjustment, it would be the whole value.

     The two queries do not agree, which is the trap. A PHONE IN LANDSCAPE
     -- 844 x 390 -- is `pointer: coarse` under 1024px wide, so it takes
     the mobile grid, but it fails `min-height: 620px` and is over 799px
     wide, so it does NOT snap. Applied unconditionally this rule would
     have put 72px + 1.5rem of padding at the top of a 390px-tall viewport:
     a quarter of the screen, on the one orientation where there is least
     of it to spare.

     So: mobile grid AND snapping, both, which is what the nesting says. */
  @media (min-width: 800px) and (min-height: 620px), (max-width: 799px) {
    body[data-ambient] .band--crest .scene__stage {
      padding-top: calc(var(--header-h-shrunk) + 1.5rem);
    }
  }
}

/* Simple two-column layout, still used by the four campaign pages. Those
   pages were not part of the live-site calibration, so they keep the older
   50/50 model rather than the 26-column grid. */
.split {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(2rem, 4vw, 3.5rem) clamp(2rem, 5vw, 5.5rem);
  align-items: center;
}
@media (min-width: 901px) {
  .split--reverse > .split__text { order: 2; }
  .split--reverse > .split__art  { order: 1; }
}
@media (max-width: 900px) {
  .split { grid-template-columns: minmax(0, 1fr); }
  .split > .split__art  { order: 2; }
  .split > .split__text { order: 1; }
}

.split__text > *:last-child { margin-bottom: 0; }

/* ==========================================================================
   FULL-BLEED DARK SECTIONS
   Every section on the live home page is dark with a full-bleed background
   image. The scrim keeps text legible where the mesh artwork is brightest;
   measured, the unscrimmed art took some text to 1.25:1.
   ========================================================================== */

.band {
  position: relative;
  padding-block: var(--section-y);
  background-color: var(--ink);
  background-repeat: no-repeat;
  background-size: cover;
  background-position: center;
  overflow: hidden;
}
/* Three explicit layers, because the animated mesh added a third. The
   generated ::before precedes any real child in the box tree, so without
   these the injected canvas would paint ON TOP of the scrim and defeat it:

     0  .band__mesh   the animated canvas, when present
     1  .band::before the scrim
     2  .band > .container   the content                                   */
.band::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 1;
  background: linear-gradient(100deg,
    rgba(0,0,0,0.92) 0%, rgba(0,0,0,0.84) 40%,
    rgba(0,0,0,0.42) 68%, rgba(0,0,0,0.12) 100%);
  pointer-events: none;
}
.band > .container { position: relative; z-index: 2; }

.band--wave  { background-image: url("../assets/img/bg-mesh-wave.jpg");  background-position: center bottom; }

/* ANIMATED MESH
   js/mesh-bg.js injects this canvas as the first child of any band carrying
   data-mesh. It sits above the background image and below the scrim, so the
   contrast system still applies unchanged.

   The canvas is injected, never written into the HTML. No JavaScript means no
   canvas, and the background image above shows through exactly as before. */
.band__mesh {
  position: absolute;
  inset: 0;
  z-index: 0;
  width: 100%;
  height: 100%;
  display: block;
  pointer-events: none;
}
/* About and Contact carry no background artwork.

   They used bg-mesh-crest.jpg and bg-mesh-curve.jpg. With the images gone the
   scrim has nothing to sit over, so it is switched off too: a gradient of
   black over black is invisible but still a compositing layer, and leaving it
   would be dead weight on every paint.

   The hero keeps its artwork and its scrim, because the animated mesh renders
   there and the h1 sits over it. Nothing about the hero's contrast system
   changes. */
.band--crest,
.band--curve { background-image: none; }
.band--crest::before,
.band--curve::before { display: none; }

/* No artwork behind this one, so no scrim either. */
.band--plain { background-image: none; }
.band--plain::before { display: none; }

/* THE PROMO BAND OPTS OUT TOO, and the way it came to need this is the
   part worth recording.

   The site layers bands in two rules, both near the top of this file:
   .band::before is a shared 100deg scrim at z-index 1, and
   `.band > .container { position: relative; z-index: 2 }` lifts the
   content above it. That second selector is a CHILD combinator, and that
   is where this band fell through.

   The promo band bleeds full width for its video, so its structure is
   .band--promo > .promo > .container -- the container is a grandchild,
   not a child. `.band > .container` stopped matching, the content kept
   z-index auto, and the scrim at z-index 1 landed on top of it: the full
   0.92-to-0.12 diagonal wash over the logo, the copy box and the text,
   heaviest at the left. Which is exactly how it looked.

   Switching the shared scrim off is the right fix rather than chasing
   the z-index, because this band already supplies its own in
   .promo::after and never wanted two. --crest, --curve and --plain do
   the same; --hero replaces it.

   WHY IT SURVIVED REVIEW: the mock-ups used to check this band were
   composited by hand from the layers its own CSS declares. A hand-built
   model shows what the author believes the cascade does; it cannot show
   an inherited rule the author forgot about. tools/test_promo.js now
   asserts the opt-out, and tools/test_heroflow.js checks the general
   case for every band on the page. */
.band--promo::before { display: none; }

/* Live hero is 863px tall with exactly 129px of top padding, which is the
   fixed header's height. The section starts at viewport top 0 so its
   background runs under the header; only the content clears it.

   --hero-lift RAISES THE CONTENT off the mathematical centre, because the
   mathematical centre reads as too low here.

   align-items: center centres the content in the PADDING BOX. The top
   padding clears the fixed header, so most of the space above the content
   is hidden behind an opaque black bar, while every pixel of the space
   below it is visible. The band is also the first thing on the page with
   nothing above it, so there is no preceding content to balance against and
   the eye takes the header's lower edge as the top of the frame. Both push
   the same way: geometrically centred, optically low.

   Implemented as extra BOTTOM padding rather than as a transform or a
   negative margin, so the content stays in normal flow and the band still
   sizes itself around it. The doubling is the arithmetic of centring: the
   content sits at the midpoint of the padding box, so lifting it by N means
   adding 2N below.

   It is a variable rather than a number folded into the clamp so that the
   mobile rule can switch it off in one line, and so the next person can see
   what it is for. Mobile sets it to 0: the columns stack there, the block
   is taller than the band, and there is no slack to centre in. */
.band--hero {
  /* --hero-lift raises the CENTRED block off the band's mathematical
     middle, by being added twice to the bottom padding. Lifted from
     clamp(1.25rem, 3vw, 3rem): the block read as sitting slightly low,
     which it was -- the hero gained a scroll cue below the content and
     lost the Request a demo button above it, and neither change was
     reflected here. 43px of lift at 1440 becomes 66px. */
  --hero-lift: clamp(2rem, 4.6vw, 4.25rem);
  padding-block: var(--header-h)
                 calc(clamp(3rem, 6vw, 6rem) + var(--hero-lift) * 2);
  display: flex;
  align-items: center;
  min-height: clamp(560px, 57vw, 900px);
}
/* THE HERO HEADLINE IS SIZED HERE, NOT ON THE GLOBAL h1.

   The global clamp is clamp(2.775rem, 3.36vw + 1rem, 3.835rem) and it
   dresses every h1 on every page. This is the hero's own, 20% larger at
   every width: 2.775 -> 3.33rem, 3.36vw -> 4.03vw, 3.835 -> 4.602rem. At
   1440 the headline goes from 61px to 74px.

   ON `.band--hero h1`, DELIBERATELY, AND NOT ON `.band--hero .hero-centre
   h1` TWELVE LINES DOWN. That rule already exists and is the obvious home
   for it, and it would have been a bug: it is (0,2,1) against the mobile
   override's (0,1,1), so a size declared there beats the phone rule from
   OUTSIDE the media query. Specificity does not care that the media query
   comes later in the file. The headline would have silently kept its
   desktop size on a 390px screen.

   Matching the mobile rule's selector exactly is what keeps source order
   in charge, which is what a media query override needs. */
.band--hero h1 {
  margin-bottom: 0;
  font-size: clamp(3.33rem, 4.03vw + 1.2rem, 4.602rem);
}

/* THE HERO SCRIM: the shared .band::before, plus a header fade.

   There is no centred ellipse here any more. One briefly replaced the
   shared gradient, because a centred-statement hero put the 100deg ramp's
   LIGHTEST point directly behind the end of every line. The two-column
   layout is back, so the shared gradient is correct again: dense behind the
   headline in columns 3-14, falling away across the form on the right,
   which is where the artwork should read.

   IT IS ALSO LIGHTER THAN THE SHARED ONE NOW, and deliberately diverges
   from it. The shared .band::before runs 0.92 down to 0.12 and the four
   campaign pages still want that over a static JPEG. Over the animated
   arcs it was throwing away the thing it was sitting on: the field was
   being computed bright and then dimmed by an almost opaque gradient. The
   alphas here came down, the field's own brightness went up, and the
   headline still measures far above the 4.5:1 floor with room to spare.
   tools/test_heroflow.js parses these values out of this file and holds
   that floor, so lowering them further is a measured decision rather than
   a guess.

   IT DIPS IN THE MIDDLE AND COMES BACK UP, which a plain ramp cannot do.
   The hero has text in TWO columns -- the headline in 3-14 and the
   audience list in 16-26 -- with open frame between them. A monotonic
   ramp protects one end and abandons the other: falling to zero on the
   right let the background reach 2.46:1 behind the list, well under the
   floor. The stops now sit high at both ends and dip to 0.26 across the
   middle, so the brightest part of the scrim is where there is nothing to
   read and the field shows through at full strength exactly there.

   What stays is the HEADER FADE, which belongs to the animated background
   rather than to the old layout. .site-header is opaque black, 129px tall,
   and sits over the top of this band while the page is scrolled to the top.
   Its lower edge cut a hard horizontal line straight across the animation.
   This holds the field at full black until the header's SHRUNK height and
   then ramps away, so the edge always falls inside solid black and there is
   nothing to clip. Keying it to --header-h-shrunk rather than --header-h is
   what makes it survive the shrink on scroll: at the full height the extra
   19px is already dark, and at the shrunk height the newly exposed strip is
   still inside the ramp rather than on a step.

   Done here rather than in the canvas for two reasons: it needs to be a
   pixel height tied to the header variable, not a fraction of a band whose
   height moves with the viewport; and the canvas cannot cover the static
   fallback image a visitor without JavaScript sees, which the header clips
   in exactly the same way.

   The background-image list ends in `none` for the second layer, so this
   rule REPLACES nothing: it adds the fade in front and lets the shared
   .band::before gradient show through underneath via background-color. That
   is why the gradient is restated rather than inherited -- a ::before
   carries one background, and listing the header fade alone would have
   dropped the scrim entirely.

   A WARNING THAT COST REAL TIME. An earlier version of this block was
   broken and nothing caught it. Two separate edits each prepended prose
   that began outside the comment and ended with its own comment terminator.
   The first comment therefore closed early, everything after it became raw
   text, and a CSS parser reads raw text followed by an opening brace as a
   SELECTOR. An invalid selector drops the whole rule, so the scrim silently
   did not exist. A comment explaining this must not contain the terminator
   sequence itself, even inside backticks: the rewrite of this very note did,
   which closed the comment early and reproduced the bug on the spot.

   Braces stayed balanced, so a brace check passed. The contrast test found
   the rule by regexing the file as TEXT, so it happily measured a rule the
   browser was discarding. tools/test_heroflow.js strips comments and parses
   rules before trusting them, and that is what caught it. */
.band--hero::before {
  background:
    linear-gradient(180deg,
      rgb(0,0,0) 0,
      rgb(0,0,0) var(--header-h-shrunk),
      /* FOUR STOPS, SHAPED AROUND THE ARC, not two stops cut short.

         The outermost band's top edge is a hard crest rather than a
         fade -- measured, intensity goes 0 to 0.73 in a single 8px step
         at 191px down a 821px band. Whatever this gradient is doing at
         191px lands on that crest and reads as the band being clipped.

         A two-stop ramp cannot satisfy both ends. Ending it at 224px
         put real density on the crest. Pulling it in to 184px cleared
         the crest but squeezed the same alpha range into 112px instead
         of 152px, which made the transition STEEPER -- a harder edge in
         place of a clipped one.

         So the shape changed rather than the length. The ramp now falls
         fast through the region the header occupies, is down to 0.10 by
         the time it reaches the crest at 191px, and takes another 85px
         to reach nothing. A tenth of a stop of density on the arc's
         leading edge is not visible as clipping; the long tail is what
         makes the transition gradual.

         Moving the FIELD instead was tried and is the wrong fix: it
         drops the whole composition to get one edge out from under a
         gradient, and the gradient meets it again lower down.

         Header legibility is unchanged. Its content stops at 129px,
         where the alpha is 0.46 against the 0.49 the old linear ramp
         gave at the same point. */
      rgba(0,0,0,0.46) calc(var(--header-h) + 2px),
      rgba(0,0,0,0.10) calc(var(--header-h) + 62px),
      rgba(0,0,0,0) calc(var(--header-h) + 150px)),
    /* A CENTRED ELLIPSE, because the hero is centred.

       This replaces a 100deg ramp with two dense ends and a light window
       between them, which was exactly right for a two-column hero: the
       headline sat in grid columns 3-14 under one dense end and the client
       list in 16-26 under the other, with the window in the middle letting
       the field show where there was nothing to read.

       Centred copy lands in that window. Measured on the old ramp, the
       scrim ran 0.66 at 10% of the width, down to 0.32 at the centre, back
       to 0.60 at 80% -- so the new layout would have put every line of the
       headline over the single lightest point on the band.

       An ellipse was here once before, for a centred hero, and was removed
       when the two-column layout returned. It is the right tool again, for
       the same reason it was then: when the text is in the middle, the
       scrim has to be densest in the middle, and no linear gradient can be
       dense at the centre and light at both ends.

       56% BY 48%, DOWN FROM 78x68, which is 49% of the area and 35% of
       what this scrim covered two versions ago.

       THE DRIVER WAS THE COPY, NOT THE SCRIM. The headline went from
       "Integrated front, middle, and back office platform" over two
       lines to "Operational alpha." on one, the Request a demo button
       left the hero, and the subline is a single short sentence. The
       text block is a fraction of what the 78x68 ellipse was sized
       around, and nobody re-derived the ellipse when the copy changed
       -- it went on protecting space that had no type in it, dimming
       the bands for nothing.

       Measured across four sizes, the laptop headline moves 7.74 to
       7.59:1 between 78x68 and 56x48: essentially nothing, because the
       type is deep inside the plateau at every one of them. The whole
       difference is how much of the FIELD gets dimmed, which is the
       point -- the travelling pulse is far easier to see on a band the
       scrim is not sitting on.

       THE CORE IS 0.52, AND IT WENT UP TO GET THERE. It was taken down
       to 0.46 on a measurement that was wrong: the contrast harness did
       not model the BLOOM PASS, a blurred copy of the field drawn back
       over itself with 'lighter' at 10% in js/hero-flow.js draw().
       Every ratio this stylesheet was tuned against was therefore
       optimistic, and at 0.46 the laptop headline was really 4.39:1 --
       under the floor, shipped, and invisible to the suite.

       With bloom modelled the measurements are: 4.39:1 at core 0.46,
       5.26:1 at 0.52, 5.96:1 at 0.56.

       0.52 WITH THE BLOOM KEPT, rather than cutting bloom to 0.04 and
       holding 0.46, because the two cost different things. Bloom
       brightens the WHOLE band; the core only darkens the ellipse round
       the text. Paying in scrim buys back compliance where the type is
       and leaves the rest of the field as bright as it was.

       Mean band brightness after the scrim across the three steps:
       36.6, then 42.6 from the size change, then 43.9 of 255 from the
       alpha and a density increase together. The size was worth four
       times what the alpha was.

       A MULTIPLICATIVE SCRIM CANNOT HIDE A PULSE'S CONTRAST, strictly
       speaking: it scales crest and trough alike, so their ratio is
       preserved. What it does is push both toward black, where the eye
       discriminates poorly -- a 2:1 ratio between two dark values is
       much harder to see than the same ratio between two bright ones.
       That is why shrinking the footprint works where lowering the
       alpha would have cost contrast behind the type.

       SIZED TO THE MEASURED BEATS, not to their bounding box. tools/preview-hero.py reports
       three boxes on a 1280x730 band: the headline at x 0.250-0.750, the
       subline at 0.359-0.641, the button at 0.439-0.561. The hero is a T.

       WHY THE BOUNDING BOX WAS THE WRONG TARGET. Held against the union
       of those three -- a rectangle 0.250-0.750 wide and running the full
       height -- the binding points were its two bottom corners, which
       contain no type at all: the button is a fifth of the headline's
       width. An ellipse falls off fastest exactly at the corners of any
       box inscribed in it, because both axes contribute to the radius, so
       the scrim was being sized to protect empty background. Measured
       against the real beats the minimum came down from 86% by 76% to
       78% by 68%, covering 19% less of the frame for the same protection.

       THE PLATEAU RUNS TO 88%, not 60%. The far corners of the headline
       line -- the widest beat, and the one that binds -- sit at about
       0.86 of the radius, so a plateau ending at 60% left them in the
       falloff at roughly half the centre's alpha. Pushing the inner stop
       out past them covers every beat evenly and confines the drop to the
       frame's outer edge, which is where the arcs enter and leave and
       where the field should read at full strength.

       THE RIM GOES TO FULLY TRANSPARENT, where it used to stop at 0.06.
       The ellipse covers about half the frame; the final stop is what the
       OTHER half gets, clamped, so a residual 0.06 was a flat wash over
       everything outside the ellipse -- the quietest possible way to dim
       a whole band. Taking it to zero costs nothing behind the type,
       since the type is nowhere near the rim.

       DEEPER OVER THE TEXT, LIGHTER EVERYWHERE ELSE, which is the trade
       the smaller ellipse buys and is worth stating plainly because it
       runs against the instinct: the centre went UP, 0.42 to 0.50. A
       scrim that is lighter in the middle AND covers less ground cannot
       protect anything. Concentrating it is what lets the area shrink,
       and the contrast headroom that frees is then spent on dot density
       in js/hero-flow.js rather than banked.

       The earlier note here said shrinking the ellipse fails -- at 70% by
       60% the headline ends fell into the falloff and contrast dropped to
       4.05:1. That was true, and it was true because of the bounding box.
       The floor now is the headline's own width: it spans half the frame,
       so below about 72% by 62% its ends leave the plateau whatever the
       zones say. 78x68 keeps a real margin against that.

       Measured, holding the same shape:

         alpha, plateau to 85%      desktop  laptop
         0.62 / 0.56 / 0.11           9.8      7.6   <- over-scrimmed
         0.48 / 0.42 / 0.08           7.0      5.1

       THEN THE FIELD'S OWN HIGHLIGHT CAME DOWN, which bought a lighter
       scrim than any of those. `whiten` in js/hero-flow.js went 0.50 to
       0.15: it acts only on the brightest pixels, which are exactly the
       ones that set the worst case behind the type, and barely touches the
       mean the eye reads as "how visible is the animation". Measured, it
       was worth +0.8 of contrast for a 4% drop in field brightness.
       Spending that on the scrim gives the field far more back than the
       highlight cost. Re-measured after it:

         0.44 / 0.38 / 0.07           6.8      5.3
         0.42 / 0.36 / 0.06           6.5      5.0
         0.40 / 0.34 / 0.06           6.2      4.8
         0.34 / 0.28 / 0.05           5.4      4.1   <- laptop under floor

       THEN THE ZONES BECAME PER-BEAT and the ellipse came in to 78x68,
       measured on the worst of the three beats at each width:

         ellipse  alpha, plateau 88%   desktop  laptop
         86x76    0.50 / 0.45 / 0.00     8.6      7.3
         78x68    0.50 / 0.45 / 0.00     8.5      7.3   <- here, before density
         72x62    0.52 / 0.47 / 0.00     8.8      5.0   <- headline ends leaving
         66x56    0.50 / 0.45 / 0.00     5.6      2.7   <- collapsed

       Net against the first centred version: centre alpha 0.74 -> 0.42.

       The first version spent more than twice the scrim legibility needed
       -- 9.8:1 against a 4.5 floor -- and the field paid for all of it.
       This is double the field at a contrast that still clears the floor
       with room for a background that moves.

       THE NUMBERS ABOVE ARE THE 1280 BREAKPOINT, not 1440, and that
       matters: the laptop band is shorter and its text block covers a
       larger fraction of the frame, so it is consistently the tighter of
       the two. A sweep run only at 1440 picked 0.52/0.44/0.08 and shipped
       a laptop measuring 4.39:1, under the floor. Tune against 1280.

       The centre is at 47% rather than 50% because the block is not
       vertically centred in the band: --hero-lift raises the content off
       the mathematical centre, and the ellipse has to follow it or the
       dense part sits below the type.

       tools/test_heroflow.js parses this rule and holds a 4.5:1 floor
       against it, so changing these values is a measured decision. It
       understands both gradient forms; see the scrim parser there. */
    /* ======================================================= DIAGNOSTIC
       SCRIM OFF. This is a temporary experiment, not a shipping state:
       with all three stops at 0 the hero text fails WCAG AA outright.

       It exists to settle one question -- how much of the hero's
       darkness is the scrim. Four previous theories were wrong, so this
       removes the variable completely rather than reducing it.

       Predicted if the scrim IS the cause: mean band brightness over
       lit pixels goes 27.3 to 40.7 of 255, a 49% lift, and the bands
       read noticeably brighter.
       Predicted if it is NOT: barely any visible change, and the answer
       is that a sparse dot field on black is simply this dark.

       TO RESTORE, put back:
         rgba(0,0,0,0.52) 0%, rgba(0,0,0,0.47) 88%, rgba(0,0,0,0) 100%
       ==================================================================*/
    radial-gradient(ellipse 56% 48% at 50% 47%,
      rgba(0,0,0,0) 0%, rgba(0,0,0,0) 88%,
      rgba(0,0,0,0) 100%);
}

/* ==========================================================================
   THE CENTRED HERO
   Headline, one bridging line, one button. Replaces a two-column layout
   that carried the headline in grid columns 3-14 and a list of client
   types in 16-26; that list now has its own section below, where it can
   carry copy instead of being five words wedged beside a headline.

   NOT A GRID COLUMN. The 26-column grid exists to reproduce measured
   positions from the live site, and a centred block has no measured
   position to reproduce. A max-width with auto margins states the intent
   directly, and survives the grid being changed underneath it.

   THE MEASURE IS SET ON THE HEADLINE ITSELF, and that is load-bearing
   rather than stylistic. `ch` resolves against the font-size of the
   element the property is written on, so putting it on the wrapper sized
   it against the INHERITED BODY SIZE instead of the display type.
   Measured at 1440: 1ch is 11.7px on the wrapper and 40.1px on the h1, so
   `max-width: 19ch` on .hero-centre rendered a 221px column where 761px
   was intended -- the hero at under a third of its width, centred, which
   is as bad as it sounds.

   It survived because tools/preview-hero.py computed the measure from the
   H1's font while the stylesheet computed it from the wrapper's. The two
   disagreed and the mock looked right. The preview now PARSES this value
   out of this rule, so they cannot drift again.

   The subline carries its own, narrower measure. A width that suits two
   lines of 61px display type would let a ten-word sentence run nearly as
   wide, and the pair would stop reading as a hierarchy.
   ========================================================================== */
/* position: relative so the scroll cue can hang off the BOTTOM OF THIS
   BLOCK rather than off the band. Anchored to the band it sat at the
   very bottom edge, far below the button it follows; anchored here it
   keeps a fixed gap under the button at every viewport, while staying
   out of flow so it adds no height and pushes nothing up. */
/* LEFT-ALIGNED, not centred. The class name is now a misnomer and is kept
   anyway: it is referenced by js/hero-intro.js, tools/preview-hero.py and
   the selectors below, and renaming it across all of them to describe an
   alignment that may change again is churn for nothing. What the class
   actually marks is "the hero's content block", which is still true.

   THE LEFT MARGIN IS NOT SET HERE. It comes from .col-3-25 in index.html,
   which puts this block on grid column 3 -- the line every other band on
   the page starts at. Setting a margin here instead would hard-code the
   column maths in a second place and let the hero drift away from the rest
   of the page the next time the grid changes.

   `margin-inline: 0` and not `auto` is therefore belt and braces: a grid
   item with no max-width fills its track either way, but `auto` would
   re-centre it inside the track the moment one is added. */
.hero-centre {
  position: relative;
  margin-inline: 0;
  text-align: left;
}

/* The accent word in the headline. --accent is the brand green #18BD5B,
   already defined in :root -- not a new value.

   A class on a span rather than a ::first-line or a word-index trick,
   because which word is emphasised is a content decision and belongs in
   the markup. The span is also what survives with JavaScript off, which
   is why it is written into index.html rather than injected.

   js/hero-intro.js carries this class onto the per-character spans it
   generates; see the note on splitChars there. Without that it would be
   destroyed by the intro animation, which rebuilds the headline. */
.hero-accent { color: var(--accent); }

/* 19ch of the HEADLINE's own font-size: about 761px at 1440, which holds
   this string to two lines at the sizes it actually renders. Measured, not
   chosen -- tools/preview-hero.py lays the real string out in the real
   font and reports the line count. */
/* 21ch, NOT 19. "Capture operational alpha." measures 18.23ch in Outfit
   at every desktop step, so a 19ch cap left about 4% of slack -- enough
   in a controlled measurement, not enough in a browser, where hinting
   and subpixel rounding differ and the line tipped over onto two.

   A cap is not a width: the headline still occupies only what it needs,
   so widening this does not widen the type or its contrast zone. It only
   moves the point at which the line breaks. 21ch gives about 15%, which
   survives a font substitution while still breaking a genuinely long
   headline rather than letting it run the full container.

   `ch` resolves against the font-size of the element it is WRITTEN on,
   which is why this sits on the h1 and not on .hero-centre -- there it
   resolved against the ~19px body size and produced a 221px headline. */
/* margin-inline 0, not auto: the headline is narrower than the container
   and `auto` centred that slack. The max-width stays and still does its
   only job, which is deciding where the line breaks. */
.band--hero .hero-centre h1 {
  max-width: 21ch;
  margin-inline: 0;
  margin-bottom: 0;
}

/* THE SUBLINE AND THE BUTTON ARE A PAIR, and the headline is separate.

   The gap above the subline is now LARGER than the gap below it, which is
   the opposite of the first pass. Grouping by proximity: the subline and
   the button together are one thought -- a reason to click and the thing
   to click -- while the headline is the statement they follow from. Equal
   gaps made three evenly spaced items, which reads as a list rather than
   as a hierarchy.

   tools/preview-hero.py mirrors both clamps to place the block, so these
   two values and the two in that file have to move together. */
/* PAIRED WITH THE HEADLINE, NOT THE BUTTON, and the spacing is what
   says so. It sat 28-40px under the headline and only 16-22px above the
   button, so the eye grouped it downward with the call to action --
   the opposite of the reading order. The gaps are now inverted: tight
   above, generous below.

   Larger too, 20-28px against 17-20px. Still unmistakably subordinate
   to a 44-61px headline, which is the other half of it: close enough to
   read as one thought with the headline, far enough that the hierarchy
   is never in question. */
/* The inline `auto` became 0 with the alignment. Same reason as the h1:
   34ch is narrower than the container, and auto centred the difference. */
.hero-sub {
  max-width: 34ch;
  margin: clamp(0.6rem, 1.1vw, 1rem) 0 0;
  color: var(--on-dark-body);
  font-size: clamp(1.375rem, 0.95vw + 1rem, 2rem);
  line-height: 1.3;
}

/* THE HERO'S REQUEST A DEMO BUTTON, BACK -- WITHOUT THE RING.

   It was removed along with an animated green ring: @property
   --ring-angle, a conic gradient on .hero-cta .btn::before, @keyframes
   ring-spin and two reduced-motion overrides. The button is what came
   back; the ring stayed deleted. It is the plain sitewide .btn--primary
   now, the same white-on-black pair used in the header and the footer.

   Worth keeping from that deletion, because both cost an afternoon:
   `inset` on an absolutely positioned child resolves against the PADDING
   box, so a ring meant to stand outside a bordered button must add the
   border width back or it lands exactly on the border edge and is
   invisible. And animating a custom property needs @property to register
   it -- unregistered, the value is a string, the whole gradient is
   invalid, and nothing renders at all rather than merely not moving.

   SPACING IS THE ONLY THING THIS RULE DOES. The gap is larger than the
   subline's own top margin (0.6-1rem) because the button is a control
   following a statement rather than a third line of it, and smaller than
   the cue's (4-6.5rem) because it still belongs to the block the cue is
   separated from. Nothing else is needed: .btn carries the appearance and
   [data-hero-rise] carries the entrance. */
.hero-cta {
  margin: clamp(1.5rem, 2.4vw, 2.25rem) 0 0;
}


/* ==========================================================================
   THE SCROLL CUE  "Explore" + a bobbing chevron
   ========================================================================== */

/* ANCHORED TO THE BOTTOM OF THE CENTRED BLOCK, NOT TO THE BAND.

   It began as a fourth item inside .hero-centre, which was wrong in a
   way that only showed on the page: .band--hero centres its content
   vertically, so height added at the BOTTOM of the stack pushes
   everything above it UP -- the headline and subline both moved.

   Pinning it to the band's bottom edge fixed that and introduced the
   opposite problem: it sat around y 0.89, far below the content it
   follows. `top: 100%` on a child of .hero-centre is the answer to
   both -- out of flow, so it adds no height and displaces nothing, but
   positioned against the block, so the gap above it is fixed and it
   tracks the content rather than the viewport height.

   No z-index: inside .container it inherits that element's z-index 2
   and clears the shared band scrim like everything else. */
/* CENTRED IN THE FRAME, while the copy above it is left aligned, and the
   two facts are less in tension than they look. The headline, subline and
   cue are not one block: the first two are a statement, read left to
   right off the page margin, and the cue is a page-level control that
   belongs to the viewport rather than to the sentence above it. Centring
   it is what says so.

   `text-align: center` lands on the FRAME centre, not an approximation of
   it, and that falls out of the markup rather than being arranged here.
   .hero-centre spans .col-3-25, which is lines 3 to 25 of a 26-column
   grid -- two columns outside it on the left and two on the right. The
   span is symmetric, so its centre is the frame's centre.

   That symmetry is load-bearing and silent. Change the hero to .col-3-13
   or any other asymmetric span and this cue slides off centre with no
   warning, because nothing here mentions a column.

   STILL ANCHORED TO THE COPY, via top: 100% on .hero-centre, not to the
   band. Anchored to the band it would sit at a fixed height no matter how
   tall the copy got; anchored here the gap below the content is constant
   and the cue tracks it. Out of flow either way, so it adds no height and
   displaces nothing above it.

   THE GAP IS ROUGHLY DOUBLE what it was -- 2rem/3.8vw/3.25rem became
   4rem/7.5vw/6.5rem, about 55px to 107px at 1440. The cue read as the
   fourth line of the copy at the old spacing, which is precisely what it
   is not. Pushing it down is what separates the statement from the
   control.

   No z-index: inside .container it inherits that element's z-index 2 and
   clears the shared band scrim like everything else. */
.hero-scroll {
  position: absolute;
  top: 100%;                 /* the bottom edge of the content block */
  margin: clamp(4rem, 7.5vw, 6.5rem) 0 0;
  left: 0;
  right: 0;
  text-align: center;
}

/* One target: label and arrow together, stacked and centred, so the hit
   area is the whole cue rather than seven small characters. Comfortably
   past the 24x24 minimum in WCAG 2.5.8.

   NO BACKDROP. There was a radial scrim here while the cue sat at the
   band's bottom edge, outside the hero scrim's ellipse, on bare field
   where it measured 3.3:1. It now sits around y 0.70, inside the
   ellipse, so the band scrim covers it like every other beat. The
   measured figure is in tools/test_heroflow.js, which holds this zone
   to the full 4.5:1 text threshold. */
.hero-scroll__cue {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  padding: 0.45rem 0.9rem;
  color: var(--on-dark-body);
}

/* Plain text now, not a link's accessible name. The whole cue carries
   aria-hidden on the paragraph in index.html, so nothing here is
   announced; this is styling for the eye alone. */
.hero-scroll__label {
  font-size: clamp(0.8125rem, 0.2vw + 0.75rem, 0.9375rem);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

/* THE ARROW BOBS, THE CUE DOES NOT. This was written when the cue was a
   link and the reason was its hit area -- animating the whole thing made
   a moving target of something people were trying to click. There is no
   hit area now, and the rule stands anyway: moving the word with the
   arrow turns a steady label into a bouncing one.

   A chevron, with no shaft. One was added and then taken out again:
   with a label sitting above it the arrow only has to point, and a
   full arrow next to the word read as two signals for one idea. */
.hero-scroll__arrow {
  display: block;
  margin-inline: auto;
  animation: hero-bob 2.1s ease-in-out infinite;
}

@keyframes hero-bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(5px); }
}

/* THE HOVER AND FOCUS STATES ARE GONE WITH THE LINK.

   There was a long note here about #1de26d -- the brand green with its hue
   and saturation untouched and its lightness raised from 42% to 50%,
   chosen because the true --accent measures 3.43:1 against the brightest
   frame this cue sees and a hover state is a state text is read in. That
   reasoning was correct and is now moot: a <span> has no hover worth
   styling and takes no focus, so both rules matched nothing.

   KEPT AS A RECORD, because the next control placed over the hero field
   faces the identical problem and the answer is already worked out:
   --accent needs the background below 0.044 luminance, the field reaches
   0.0735 here, and #1de26d clears 4.5:1 with margin at 4.92:1. The section
   nav further down this file uses it for exactly that reason. */

/* WCAG 2.2.2. Decorative motion with no control of its own, so it stops
   entirely under either signal -- the OS preference and the footer
   toggle, which is this site's own pause mechanism. The arrow stays
   where it is; only the movement goes. */
@media (prefers-reduced-motion: reduce) {
  .hero-scroll__arrow { animation: none; }
}
html[data-motion="reduce"] .hero-scroll__arrow { animation: none; }

/* Interior pages do the same, plus their own breathing room.

   .lede is theirs alone now. The hero carried one for as long as it was a
   centred statement, along with a row of client types under it; both went
   when the two-column layout and the form came back. */
.band--page {
  padding-block: calc(var(--header-h) + clamp(2rem, 4vw, 3.5rem))
                 clamp(4rem, 9vw, 7rem);
}
.band--page h1 { margin-bottom: 0; }
.band--page .lede { margin-top: 1.25rem; max-width: 52ch; color: var(--on-dark-body); }

/* On the live site "About us" is plain body copy, not a styled eyebrow.
   The small uppercase green treatment in the previous build was invented.
   Kept as a class so the campaign pages can still use it, but it now
   inherits body styling on the home page. */
.eyebrow {
  margin: 0 0 0.35rem;
  color: var(--on-dark-body);
}

/* The three animated slots reserve their space with aspect-ratio on the
   host ([data-globe], [data-netmap], [data-bars]). The Rozen block is a
   plain image and had nothing holding the space, so it shifted the page as
   it loaded, which is hostile to anyone tracking a magnified viewport.

   Matching 4/3 with object-fit: contain, rather than width/height
   attributes: the asset has never been downloaded, so its intrinsic size is
   unknown and inventing one would distort the logo.

   ON A CUSTOM PROPERTY so the ratio can be changed per slot without
   restating this selector. The :not() chain is (0,4,0), which is high
   enough that an override has to either repeat all three exclusions or
   invent specificity to beat them -- both of which make the next edit a
   puzzle. A variable set on the element wins without any of that, and it
   is the same device --art-scale already uses a few rules down. The
   fallback keeps every slot that never sets it at the original 4/3. */
.split__art:not([data-globe]):not([data-netmap]):not([data-bars]) {
  aspect-ratio: var(--art-box, 4 / 3);
}
.split__art:not([data-globe]):not([data-netmap]):not([data-bars]) img {
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* THE TWO LOGO SLOTS, which js/logo-dots.js turns into canvases.

   THE CANVAS IS POSITIONED BY SCRIPT, not by CSS, and that is a fix rather
   than a preference. It was `inset: 0; width: 100%` while the Rozen slot
   sized it to 68% through a separate rule -- but the module measured the
   HOST for its backing store, so it drew a 100%-wide field into a 68%-wide
   box and the whole logo came out horizontally squashed. One element
   cannot be sized by CSS and measured from a different element by script.

   js/logo-dots.js now measures the <img> and matches the canvas to its box
   exactly. The image is the ground truth for where the artwork sits, which
   also makes the cross-fade below land pixel for pixel.

   CROSS-FADE, NOT display: none. The dots are an entrance, not a
   replacement: they assemble and then hand over to the real logo. Two
   consequences -- the image has to keep its layout box the whole time (it
   is what the canvas is measured against, and display: none has no box),
   and the two have to be able to overlap, which opacity allows and display
   does not. */
[data-logo-dots] { position: relative; }

/* NO TRANSITION. js/logo-dots.js drives the hand-over frame by frame as
   part of the same timeline that runs the dots -- a CSS transition here
   would be a second animation with its own easing and duration, starting
   where the first one stopped, which is exactly the seam this arrangement
   exists to remove. */
.logodots__canvas {
  position: absolute;
  display: block;
  pointer-events: none;
}

/* THE IMAGE WAITS AT OPACITY 0 AND IS REVEALED AT THE END.

   Only ever applied by .logodots--live, which js/logo-dots.js adds after a
   canvas has actually been created. No JavaScript, no 2D context or no
   data file and this never matches, so the image is simply visible --
   which is the fallback the whole arrangement depends on. */
/* The resting states only. Everything between them is inline opacity,
   written by the module. */
.logodots--live > img { opacity: 0; }
.logodots--live.logodots--settled > img { opacity: 1; }

/* THE GLOW IS A LAYER BEHIND THE MARK, NOT LIGHT COMING OFF IT.

   It was `filter: drop-shadow`, which traces the alpha channel -- so the
   green hugged every letterform and the tagline glowed as hard as the
   logomark. Correct for a neon sign, wrong for this: what is wanted is a
   soft pool of light the logo is sitting in front of.

   A radial gradient on the host's ::before does that, and has two other
   advantages. It is a plain paint rather than a filter, so it costs
   nothing per frame once it has settled, where drop-shadow re-runs a blur
   over the whole element. And its size is independent of the artwork, so
   the light can be wider than the mark -- which is what makes it read as
   a glow rather than an outline.

   SIZED FROM --art-scale, so the Rozen slot's narrower lockup gets a
   correspondingly narrower pool. 1.25 of the artwork width: enough to
   spill past the mark on both sides without reaching the column edge.

   z-index 0 against the image's 1 is load-bearing. The ::before is
   positioned and the <img> is not, so without it the glow would paint
   over the logo -- positioned elements beat static ones regardless of
   source order. */
[data-logo-dots]::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  z-index: 0;
  width: calc(var(--art-scale, 100%) * var(--glow-spread, 1.25));
  height: 64%;
  transform: translate(-50%, -50%) scale(0.72);
  opacity: 0;
  pointer-events: none;

  /* FAINT. These stops were twice this strong and the pool read as a
     shape in its own right rather than as light. On a near-black page a
     very little green goes a long way -- the top stop is 0.15 alpha and
     is still clearly visible. */
  background: radial-gradient(ellipse at center,
    rgba(24, 189, 91, 0.15) 0%,
    rgba(24, 189, 91, 0.08) 36%,
    rgba(24, 189, 91, 0.02) 58%,
    rgba(24, 189, 91, 0) 72%);
}

/* THE SPREAD IS WIDER FOR THE SMALLER LOGO, which is not an inconsistency.

   The glow was a fixed 1.25 of the artwork's width, so the Rozen lockup --
   rendered at 68% where Chronicle is at 100% -- got a pool a third
   narrower in absolute terms, and it read as a tighter, meaner light.

   Light does not scale with the thing emitting it. A small lamp and a
   large one spread about the same distance into a dark room, so the
   smaller mark needs the LARGER multiple to end up with a comparable
   pool: 68% x 1.75 is 119% of the slot against Chronicle's 100% x 1.25 =
   125%. Near enough identical, which is what makes them look like the
   same effect. */
.split__art--inset { --glow-spread: 1.75; }

/* NO GLOW IN A LOGO ROW. The green pool stays on index.html, where each
   mark has a column to itself and the light has somewhere to fall. In the
   campaign pages' .logo-row the two marks sit side by side inside a 783px
   measure, so the two pools overlap in the middle and read as a smudge
   between the logos rather than as light behind each one.

   content: none rather than display: none or opacity: 0, because it stops
   the pseudo-element being generated at all -- so the logo-glow animation
   on .logodots--settled::before has nothing to run on and never starts.
   Hiding a generated box would have left that animation ticking.

   SCOPED TO .logo-row, which appears in no other page: index.html's dot
   hosts are .split__art, so this cannot reach them. The dots themselves
   are untouched; only the light behind them goes. */
.logo-row [data-logo-dots]::before { content: none; }

/* Above the glow, below the dots. Stated rather than left to paint order,
   because the img is static and the other two are positioned -- the
   defaults would put the glow on top of the logo and the logo under its
   own entrance. */
[data-logo-dots] > img { position: relative; z-index: 1; }
.logodots__canvas { z-index: 2; }

/* RADIATES OUT: it grows as it brightens, overshoots slightly, and settles
   back. A fade alone would read as a light being switched on behind the
   mark rather than spreading from it.

   It does not loop. A perpetual glow on two large marks is a standing
   compositing cost and a WCAG 2.2.2 question, since it is motion a
   visitor cannot stop. */
.logodots--live.logodots--settled::before {
  animation: logo-glow 1600ms ease-out forwards;
}

@keyframes logo-glow {
  from { opacity: 0;    transform: translate(-50%, -50%) scale(0.72); }
  55%  { opacity: 1;    transform: translate(-50%, -50%) scale(1.07); }
  to   { opacity: 0.82; transform: translate(-50%, -50%) scale(1); }
}

/* The glow still arrives, it just does not animate getting there: it is a
   resting state, and only its arrival is motion. The site's own motion
   toggle collapses the animation to its end frame by the same logic. */
@media (prefers-reduced-motion: reduce) {
  .logodots--live.logodots--settled::before {
    animation: none;
    opacity: 0.82;
    transform: translate(-50%, -50%) scale(1);
  }
}


.logodots--settled > .logodots__canvas { opacity: 0; }

/* Reduced motion needs no override: there are no transitions to suppress,
   and the module jumps straight to the settled state. */

/* ==========================================================================
   EVENT PROMO BAND  (Singapore FinTech Festival 2026)
   DATED. Delete this block, the markup in index.html and js/promo-video.js
   after 20 November 2026.
   ========================================================================== */

/* The gold is the festival's, not ours, and it is scoped to this band
   rather than added to the palette at the top of the file. Promoting a
   third party's brand colour to a site token invites it into places it
   does not belong, and it has to leave when the band does. */
.band--promo {
  --sff-gold: #f5b51e;
  /* No padding override and no background of its own any more. The band is
     page now; the card inside it is the banner. It keeps .band's normal
     --section-y padding, which is what puts air above and below the box
     instead of butting it against the hero and the solutions. */
}

/* FULL-BLEED, and it has to be: the video runs edge to edge while the
   content inside sits on the page's grid. So .promo is the positioning
   context and the bleed, and the .container.grid inside it does the
   aligning -- the same container every other band uses. */
/* THE CARD. Everything the band used to be -- the dark ground, the gold
   rule, the clipping -- moved down here, because the shape changed from a
   full-bleed slab to a box sitting on the page.

   SIDES AS WELL AS TOP AND BOTTOM, and thinner: 6px edge to edge read as a
   rule across the page, which is what a banner wants. The same weight drawn
   all the way round a box reads as a frame around a picture. 2px keeps the
   festival's gold unmistakably present at a weight that suits a card.

   overflow: hidden does two jobs now. It still clips the video, which is
   sized to cover and overhangs on some aspect ratios, and it is also what
   makes the radius apply to the video rather than only to the border. */
.promo {
  position: relative;
  isolation: isolate;
  background: #05060b;
  border: 2px solid var(--sff-gold);
  /* SQUARE CORNERS, and the only place on the site with them. --radius is
     the measured Squarespace button radius and everything else that has a
     corner uses it. This card is the festival's rather than Chronicle's, it
     already carries a colour from outside the palette, and the hard corners
     say the same thing the gold does: this block is a guest. */
  border-radius: 0;
  overflow: hidden;             /* keeps the video's stacking local, so
                                     the fixed header can never be caught
                                     underneath it */
  min-height: clamp(15rem, 23vw, 20rem);
  display: flex;
  align-items: center;
}

/* The vertical padding lives here rather than on .band--promo, which is
   set to 0 so the video can reach the gold rules. */
/* .promo__inner, NOT a nested .container. The card is already placed on the
   grid by .promo.col-3-25, so a container inside it would add the page
   gutter a second time and inset the content by about another 100px at
   1440. This is padding and nothing else. */
.promo__inner {
  width: 100%;
  padding: clamp(1.5rem, 3vw, 2.5rem) clamp(1.5rem, 3vw, 2.5rem);
}

/* COVER, not contain: the clip's aspect ratio will not match the band at
   every viewport, and letterboxing a background reads as a mistake.
   object-position centres the crop.

   THE BLUR IS BAKED INTO THE FILE, not applied with `filter: blur()`.
   Three reasons, and the third is the one that decided it:

     - A CSS blur samples beyond the element's edges, which come back
       transparent, so the band would fringe light at all four sides
       unless the video were scaled up past the frame to hide it.
     - It is a per-frame GPU pass on a full-width surface, for a result
       that never changes.
     - Blur removes exactly the high frequencies that make this clip
       expensive to encode. Pre-blurred it is 285 KB against 1.56 MB
       sharp -- the blur paid for itself five times over.

   The source is 720px wide and the browser scales it up, so it reads
   blurrier on screen than the sigma alone suggests. */
.promo__bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center;
  z-index: -2;
}

/* A FLAT SCRIM OVER THE WHOLE CLIP, carrying the logo.
   The logo sits on the video with no box behind it. Logotypes are exempt
   from the contrast minimums (WCAG 1.4.3 and 1.4.11 both carve them out),
   so this is a legibility judgement rather than a conformance one -- but
   the clip is not ours and may be recut, so the band should not depend on
   any particular frame being dark. 0.38 is enough to hold a white mark
   against a fully white frame without flattening the artwork. */
.promo::after {
  content: "";
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.38);
  z-index: -1;
  pointer-events: none;
}

/* NO LEFT-HAND GRADIENT. There was one, and taking it out is the fix for
   a visible artefact rather than a tidy-up.

   It existed to stop the logo washing out at t=15s, where a magenta face
   of the compass used to swing through the left third at full intensity.
   Two later changes made it both wrong and visible:

     - THE BLUR REMOVED THE PROBLEM. Blurring averages peaks down. Sampled
       across the blurred clip at 4fps, the brightest background under the
       mark now reaches 0.156 relative luminance without any gradient at
       all, so the white logo reads 5.09:1 -- against 3:1 for non-text,
       and logotypes are exempt from even that.

     - THE COPY BOX BECAME TRANSLUCENT. The gradient ran to zero at 52% of
       the band width while the box starts near 29%, so its tail sat
       behind the box's left third. At 0.86 that was invisible. At 0.76 it
       read as a dark ramp across the first part of the sentence, which is
       exactly what it looked like: a gradient on top of the copy.

   The flat scrim below covers the logo on its own. Worth re-measuring if
   the clip is ever replaced with a sharper or brighter one. */

/* Inside the grid cell: logo, then the copy box taking the rest. */
.promo__row {
  position: relative;
  display: flex;
  align-items: center;
  gap: clamp(1.5rem, 3vw, 3rem);
}

/* The supplied mark is 300x86, a wide horizontal lockup that already
   carries "18-20 NOV 2026" -- so the dates appear twice on this band, in
   the logo and in the copy. That is deliberate: the logo is an image, and
   a visitor with images off or a screen reader user gets the dates from
   the sentence either way. */
.promo__logo {
  flex: 0 0 auto;
  width: clamp(11rem, 16vw, 15rem);
  height: auto;
  aspect-ratio: 300 / 86;       /* reserves the box before the file lands */
}

/* THE TEXT BOX IS WHAT MAKES THE CONTRAST PROVABLE.
   Everything else on this page is measured against a field this code
   renders, so the worst case is knowable. A third party's video is not:
   it can be recut at any time and the frame behind the copy could become
   solid white without anything here changing. So the copy does not sit on
   the video at all -- it sits on a near-opaque box, and the contrast
   follows from the box's alpha alone.

   THE THRESHOLD IS 0.535, and it is worth writing down. White text on
   rgba(0,0,0,A) over a WHITE frame has a background luminance of
   lin(1 - A); solving 1.05 / (L + 0.05) >= 4.5 gives 1 - A <= 0.4654, so
   any A at or above 0.535 clears AA against literally any video content.

   0.70 here, walked down from 0.86 in two steps so more of the blurred
   colour reads through the box. Composited over the 0.38 scrim the
   transmittance is 0.62 x 0.30 = 0.186, which is 13.3:1 against a white
   frame -- the floor holds with the video at its worst, not merely at
   its average.

   STILL WELL CLEAR OF THE 0.535 FLOOR, but the margin is now something
   to watch: another two steps of the same size would reach it, and at
   that point the guarantee stops being "any video content" and starts
   depending on this particular clip staying dark. The test in
   tools/test_promo.js checks the box ALONE, without the scrim, which is
   what makes that boundary visible rather than theoretical.

   The blur makes the real worst case gentler still, since blurring an
   image lowers its peaks, but none of the arithmetic above leans on
   that: the clip could be swapped for a sharp one without taking the
   copy below AA. */
/* flex: 1 1 auto with no max-width, so the box runs from the logo out to
   the right-hand content margin -- the same line column 25 ends on, which
   is where the artwork in every solutions block stops. A max-width here
   would leave a ragged gap on wide screens and break that alignment. */
.promo__text {
  flex: 1 1 auto;
  min-width: 0;                   /* flex children refuse to shrink below
                                     their content without this */
  background: rgba(0, 0, 0, 0.70);
  padding: clamp(1.25rem, 2.2vw, 1.9rem) clamp(1.25rem, 2.4vw, 2.2rem);
}

.promo__copy {
  margin: 0;
  color: var(--on-dark-body);
  font-size: clamp(1rem, 0.45vw + 0.9rem, 1.25rem);
  line-height: 1.5;
}

.promo__cta { margin: clamp(0.9rem, 1.6vw, 1.25rem) 0 0; }

/* Underlined in rest state, not on hover only: this is a link inside a
   block of prose on a dark panel, and colour alone would not distinguish
   it (WCAG 1.4.1). text-underline-offset keeps the rule off the
   descenders. */
/* 600, down from 700. The underline is what marks this as a link -- it
   is there in the rest state, not only on hover -- so the weight does
   not have to carry the affordance as well, and 700 against 400 body
   copy in a short banner read as shouting. */
/* NO UNDERLINE, A DOUBLE CHEVRON INSTEAD -- which changes what carries
   the "this is a link" signal, so the replacement has to actually carry
   it. WCAG 1.4.1 says colour alone must not distinguish a link, and an
   underline was doing that job. The chevron is a non-colour visual cue
   in its place, and because it is part of the link's own box it moves
   and highlights with it.

   The underline comes BACK on hover and focus: a rest state that is
   quiet and a hover state that is explicit is the usual trade, and it
   means a visitor who is unsure can confirm by pointing at it.

   The glyph is an SVG rather than the characters ">>" or a font
   ligature: two literal angle brackets are announced as punctuation by
   some screen readers and would land inside the accessible name. This
   one is aria-hidden and the name is unchanged. */
.promo__link {
  display: inline-flex;
  align-items: center;
  gap: 0.55em;
  color: var(--on-dark);
  font-weight: 600;
  font-size: clamp(1rem, 0.45vw + 0.9rem, 1.25rem);
  text-decoration: none;
}

/* Nudges right on hover. transform rather than margin so it cannot
   reflow the line, and it is a transform on a decorative child, so
   reduced motion has nothing to stop -- there is no animation, only a
   transition, which the site's global reduced-motion rules already
   switch off. */
.promo__chev {
  flex: 0 0 auto;
  transition: transform 0.18s ease;
}
.promo__link:hover .promo__chev,
.promo__link:focus-visible .promo__chev { transform: translateX(3px); }

.promo__link:hover { text-decoration: underline; text-underline-offset: 0.22em; }
.promo__link:hover { color: var(--sff-gold); }
.promo__link:focus-visible { text-decoration: underline; }
.promo__link:focus-visible {
  outline: 3px solid var(--sff-gold);
  outline-offset: 4px;
}

/* STACKED ON NARROW SCREENS. Side by side, the logo would squeeze the
   copy to a column a few words wide long before the band is actually
   narrow. The logo leads because it is what identifies the band at a
   glance. */
/* THE FULL BREAKPOINT CONDITION, both halves of it.

   This read `@media (max-width: 799px)` alone, which was wrong. The
   site switches its grid to 8 columns and its gutter to --gutter-mobile
   on `(max-width: 799px), (pointer: coarse) and (max-width: 1024px)` --
   so a touch tablet at 900px got the collapsed 8-column grid while this
   band kept its side-by-side layout, and the logo squeezed the copy into
   a narrow strip. Any rule that has to agree with the grid has to carry
   the grid's whole condition, not the half of it that is easy to
   remember. */
@media (max-width: 799px), (pointer: coarse) and (max-width: 1024px) {
  .promo { min-height: 0; }

  /* MORE AIR TOP AND BOTTOM. The shared clamp bottoms out at 1.5rem on a
     phone -- 24px between the gold rule and the logo, which on a band
     this short read as the content being wedged against the edges. 2.5rem
     flat instead: a clamp here would only ever resolve to its minimum at
     phone widths, so the clamp was doing nothing but hiding the number. */
  .promo__inner { padding-block: 2.5rem; }

  .promo__row {
    flex-direction: column;
    align-items: flex-start;
    gap: 1.5rem;
  }

  /* 68% OF THE CONTENT MEASURE, CENTRED. 233px at a 390px viewport.

     Settled by rendering 55 / 68 / 80 / 100% side by side rather than by
     argument, having overshot in both directions first: 42vw read as
     incidental, full width was overbearing AND soft.

     68% leaves the copy box as the wider element, so the eye takes the
     logo then the message rather than weighing them equally.

     IT IS ALSO THE SHARPNESS DECISION. The mark is 300px wide and the
     content box is 343px, so 87% is exactly 1:1 -- at or below that the
     logo is downscaled and stays crisp, above it the lettering is being
     stretched. 68% is 0.76x, which keeps margin on a 3x screen too.
     Full width was 1.14x at 1x and about 3.4x at 3x, which is what made
     it soft. A larger asset would lift that ceiling; see the note on
     .promo__logo above.

     margin-inline: auto rather than align-self, because an auto margin
     on a flex item absorbs the free space on both sides and centres it
     without touching the column's align-items, which .promo__text still
     relies on. */
  .promo__logo {
    width: 68%;
    max-width: none;
    margin-inline: auto;
  }
  .promo__text { width: 100%; }
}

/* REDUCED MOTION. js/promo-video.js pauses the clip and the poster shows
   through, so there is nothing here to switch off -- but if the script
   fails to load, the video has no `autoplay` attribute and so never
   starts in the first place. The poster is the no-JS state as well as the
   reduced-motion one, which is why it has to be a frame that stands up on
   its own. */

/* ==========================================================================
   SOLUTION BLOCKS
   Heading and body are one unit, as they are in the source markup. No
   eyebrow labels: the live site does not have them here.
   ========================================================================== */

.solution + .solution { margin-top: clamp(4.5rem, 10vw, 9rem); }

/* Anchor targets sit under the fixed header without this. Uses the shrunk
   height, because the header is always shrunk by the time you have scrolled
   far enough to hit an anchor. */
.band[id], .solution[id] { scroll-margin-top: var(--header-h-shrunk); }

/* .solution__cta is gone from index.html -- all four buttons were removed,
   see the note on the Fundserv block there. The rule stays because it is
   the only record of the spacing those buttons used, and restoring one
   means restoring the gap above it too. Costs nothing: no element on any
   page carries the class, so it never matches. Delete it with confidence
   only once the decision is settled. */
.solution__cta { margin-top: 1.75rem; }

.split__art img {
  width: 100%;
  height: auto;
  border-radius: var(--radius);
}

/* SCALED BACK 20%, on the Rozen lockup only.

   A MODIFIER RATHER THAN `#consulting .split__art img`. The section id is
   an anchor target, so keying layout to it couples the logo's size to a
   URL fragment -- rename the anchor and the sizing silently goes with it,
   with nothing left to explain why. The class says what it does.

   `display: block` IS LOAD-BEARING, not tidiness. An <img> is inline by
   default and `margin-inline: auto` does nothing to an inline box, so the
   logo would sit at 80% hard against the left edge of its slot -- which
   reads as a layout bug rather than as a smaller logo. At 100% width the
   question never arose.

   THE PERCENTAGE IS THE SCALE ONLY BECAUSE THIS ARTWORK IS WIDE. The slot
   is a 4/3 box with object-fit: contain and the lockup is 4.37:1, so at
   any width from 0 to 100% it stays bound by width rather than height.
   For a tall logo object-fit would start letterboxing and this number
   would quietly stop meaning what it says. */
/* 68%: 80% of the slot, then 15% off that. Stated as the compound figure
   rather than as calc(80% * 0.85), because the slot width is what a
   percentage resolves against and chaining the reductions in CSS would make
   the next adjustment an exercise in reading history.

   ON A CUSTOM PROPERTY because two things have to agree: the <img> that
   shows without JavaScript, and the canvas js/logo-dots.js injects over it.
   Written twice they would drift, and the drift would only show in the
   half-second before the canvas mounts.

   _dev/tools/build-logo-dots.py also knows this number -- it sizes the
   Rozen sampling grid at 156 columns against Chronicle's 230 so the dots
   come out the same size on screen. Change it here and that has to follow,
   or the two logos stop looking like the same material. */
.split__art--inset { --art-scale: 68%; }

.split__art--inset > img {
  display: block;
  width: var(--art-scale);
  margin-inline: auto;
}

/* 58% ON MOBILE, AND THE 68% ABOVE IS WHY IT HAS TO CHANGE AT ALL.

   68% is a size RELATIVE TO THE CHRONICLE LOCKUP, and it only reads that
   way because the two sit in the same column span on desktop: both are
   col-15-25, so Rozen draws at 0.68 of the width Chronicle draws at.

   THE MOBILE GRID BREAKS THAT PAIRING. Rozen's span collapses to the full
   content width (1/9, via the .grid--reverse > .col-15-25 override) while
   Chronicle keeps an inset 2/8 under `.band--crest .split__art`. Six of
   eight columns plus five 10px gaps is 0.742 of the content box, so the
   real ratio between the two logos was:

     desktop   0.68 / 1.00   = 0.68
     mobile    0.68 / 0.742  = 0.92     <- a third larger than intended

   Nothing was wrong with either number on its own. The relationship
   between them was simply never restated for a breakpoint that gives the
   two logos different slots.

   AND IT IS NOT ONLY THE SIZE. _dev/tools/build-logo-dots.py samples Rozen
   at 156 grid columns against Chronicle's 230, chosen so that 0.68 of a
   slot at 156 and 1.00 of the same slot at 230 put a dot on screen at the
   same pitch. js/logo-dots.js then takes the canvas box from the <img>, so
   a mobile box that is proportionally too wide spreads 156 columns across
   it and the dots come out coarse. Since the canvas replaces the image,
   those dots are what a visitor actually sees. At 390 x 844, both figures
   taken at the 7.8vw gutter so the gutter change is not in the comparison:

                   width     px per dot
     was  68%     223.8px      1.435     35% coarser than Chronicle
     now  58%     190.9px      1.224     15% coarser
     Chronicle    244.4px      1.063

   A SHORT PHONE MOVES THESE A LITTLE. `.scene__stage .split__art` is
   `width: min(100%, 48svh)`, and 48svh falls under the full content width
   below about 686px of viewport height -- an iPhone SE, say -- so Rozen's
   slot clamps while Chronicle's inset 2/8 slot is still the smaller of the
   two and does not. The ratio moves from 0.78 to 0.76 across that range,
   which is inside the margin this choice was made on.

   58% was chosen over the 51% that would have restored the desktop ratio
   exactly: 51% matches the pitch to within 1% but reads as a noticeably
   small logo on a phone. This is the deliberate middle, and the residual
   15% is the cost of it. Closing the gap completely means resampling the
   Rozen grid for mobile in build-logo-dots.py, which is a second density
   and a second number to keep in step -- worth it only if the coarseness
   is visible to someone who is not looking for it.

   THE MOBILE QUERY IS THE SITE'S STANDARD PAIR, not the snap query a few
   hundred lines up. This is about the grid Rozen is placed on, which is
   what --grid-cols: 8 switches, and that happens here. */
@media (max-width: 799px), (pointer: coarse) and (max-width: 1024px) {
  .split__art--inset { --art-scale: 58%; }

  /* THE BOX, NOT THE LOGO. --art-scale above sets how wide the artwork
     draws; this sets how tall the slot holding it reserves. They are
     different problems and this is the one that was making the gap look
     wrong.

     A 4/3 SLOT IS THE WRONG SHAPE FOR A LOCKUP. It is right for the globe,
     the bars and the Canada map, which are drawings that fill it. These
     two are wordmarks at 4.26:1 and 4.71:1, so `object-fit: contain`
     fits them by WIDTH and centres them vertically in a box more than
     five times their own height. Measured at 390 x 844:

                   slot box   logo    dead space above and below
       Rozen        246.9px   44.8px        101px each
       Chronicle    183.3px   51.8px         66px each

     So the space under the copy was never mostly the row-gap -- 16px of
     gap sat on top of 101px of empty slot, and no amount of tightening
     the gap was going to fix that.

     4 / 2.1 IS 4/3 WITH 30% OF THE HEIGHT TAKEN OUT, written that way so
     the derivation is legible: 3 x 0.7 = 2.1. Rozen's box goes to 172.8px
     and Chronicle's to 128.3px.

     THE LOGOS DO NOT CHANGE SIZE. They are width-bound under contain, and
     both boxes stay far taller than the artwork needs, so this removes
     dead space only. The glow on [data-logo-dots]::before is sized at 64%
     of the host and tightens with it, which is the right direction.

     MOBILE ONLY. On desktop the two slots sit side by side with their
     copy rather than stacked under it, and the 4/3 box is what gives the
     column its height; shortening it there would pull the layout in.

     .split__art[data-logo-dots] RATHER THAN THE BARE ATTRIBUTE, because
     the attribute alone reaches further than this rule should. sff2026
     carries two [data-logo-dots] hosts of its own, in a .logo-row, and
     they are not .split__art -- so the aspect-ratio rule above never
     matches them and setting --art-box there would be a property nothing
     reads. Harmless today and a false lead the next time someone greps
     for where the box size comes from. The qualifier says the variable is
     for the slots that actually have a box. */
  .split__art[data-logo-dots] { --art-box: 4 / 2.1; }
}

/* NO MATCHING CANVAS RULE. The canvas used to be sized here too, which is
   what broke the Rozen field: CSS put it at 68% while the module measured
   the host at 100% and drew into the gap. The canvas now takes its box from
   the <img> at runtime, so this number reaches it indirectly and cannot
   disagree with it. */

/* WIREFRAME GLOBE
   js/globe.js injects a canvas into [data-globe] and adds .globe--live, which
   is what hides the image. The image stays in the markup and stays visible if
   the script never runs. The 4:3 box gives the canvas a height to measure,
   since a canvas has no intrinsic size the way an image does. */
/* The Canada network map, same arrangement as the globe: the canvas is
   injected over the image and the image is hidden only once that works. */
/* Isometric growth bars, same arrangement as the globe and the map. */
[data-bars] { position: relative; aspect-ratio: 4 / 3; }
.bars__canvas {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  display: block;
}
.bars--live > img { display: none; }

/* The world data-flow map on sff2026.html. Same arrangement as the three
   slots below it: the host reserves the space with aspect-ratio so the page
   does not shift as the module mounts, the canvas fills it absolutely, and
   the <img> is hidden only once js/world-flow.js has actually got a context.

   16/10 rather than the map's own 2.7:1, because the hub and its label sit
   BELOW the map band inside the same canvas -- the module fits the world
   into whatever is left above CFG.hubY and centres it. It was 4/3 while
   the figure was capped at 560px; at the full 760 that would have been
   570px tall, which pushed the next caption off a laptop screen. */
[data-worldflow] { position: relative; aspect-ratio: 16 / 10; }
.worldflow__canvas {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  display: block;
}
.worldflow--live > img { display: none; }

[data-netmap] { position: relative; aspect-ratio: 4 / 3; }
.netmap__canvas {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  display: block;
}
.netmap--live > img { display: none; }

[data-globe] { position: relative; aspect-ratio: 4 / 3; }
.globe__canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.globe--live > img { display: none; }

/* --- THE FALLBACK IS HIDDEN FROM FIRST PAINT, NOT FROM MOUNT -------------

   The three rules above hide each image once its canvas has mounted, which
   is after DOMContentLoaded and, for the network map, after the graph is
   built. Everything between first paint and that moment showed the
   fallback: an opaque 2500px still with a black background, which flashed
   and then vanished.

   It was invisible with motion ON only by accident. js/stage.js starts
   those slots at opacity 0, so the flash played out behind a transparent
   box. Turn motion off and stage.js never runs, the slot is opaque from
   the start, and the flash is in plain sight. That is how it was found.

   .art-js is set on <html> before paint by the inline script in the head
   of index.html. No JavaScript at all and this never matches, so the image
   is simply visible, which is the whole reason it is in the markup.

   SAFE BECAUSE THE BOX IS NOT THE IMAGE'S. Each of these three slots
   carries its own aspect-ratio and the canvas is absolutely positioned
   inside it, so display: none here costs no layout. The logo slots are
   deliberately not in this rule: they hold their box with the image, which
   is why js/logo-dots.js fades them to opacity 0 rather than hiding them.

   AND IT EXPIRES. Hiding the fallback on the promise that JavaScript will
   replace it trades a flash for an empty slot every time that promise is
   broken: a failed request, a throw during init, an engine with no Path2D,
   all of which leave the module returning null with no class added. The
   inline script marks any slot still without a canvas after three seconds
   and the image comes back. Same safety net js/stage.js already keeps for
   its groups, and the same reasoning behind it: a missing animation is
   acceptable, missing content is not. */
.art-js [data-bars] > img,
.art-js [data-netmap] > img,
.art-js [data-globe] > img { display: none; }

.art-js [data-bars].art-no-canvas > img,
.art-js [data-netmap].art-no-canvas > img,
.art-js [data-globe].art-no-canvas > img { display: block; }

/* If the artwork has not been downloaded yet, this keeps the row from
   collapsing into a broken-image sliver. Harmless once the files exist. */
.split__art figure {
  margin: 0;
  min-height: 240px;
  display: grid;
  place-items: center;
  padding: 1rem;
  text-align: center;
  font-size: 0.8125rem;
  color: var(--on-dark-muted);
  background: rgba(255, 255, 255, 0.04);
  border-radius: var(--radius);
}

/* ==========================================================================
   HEADER
   The live site has no navigation. This rebuild needs one for the extra
   pages, so it is kept deliberately quiet: transparent over the dark bands,
   white logo, no background of its own until scrolled.
   ========================================================================== */

/* Measured on live: 129px tall at rest, shrinking to 110px once scrolled,
   position FIXED, solid black, no border and no blur. Fixed matters: the
   header is out of flow, so the hero sits at viewport top 0 and clears the
   header with its own padding. The previous build used position:sticky,
   which occupies flow space AND had hero padding on top of it, pushing all
   the page content down by an extra header's height. */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 50;
  background: var(--ink);
  height: var(--header-h);
  /* background-color rides the SAME 0.25s as the height, deliberately. Both
     are driven by data-shrunk crossing 40px, so matching the duration and
     the easing makes the shrink and the fill read as one gesture. At
     different durations they read as the bar doing two things in a row.
     Only the ambient interior pages ever change colour here; everywhere else
     this animates a value that never moves and costs nothing. */
  transition: height 0.25s ease, top 0.28s ease-out,
              background-color 0.25s ease;
}
@media (prefers-reduced-motion: reduce) {
  .site-header { transition: none; }
}
.site-header[data-shrunk="true"] { height: var(--header-h-shrunk); }

/* Hide on scroll down, reveal on scroll up.

   This animates TOP, not transform, and that is deliberate. The mobile
   overlay .site-nav is position:fixed and lives INSIDE .site-header, so a
   transform on the header would make it the overlay's containing block and
   the overlay would size itself against the header bar rather than the
   viewport. Animating top leaves the containing block alone.

   The tradeoff is that top is not compositor-accelerated the way transform
   is. It is one fixed element moving once per direction change, so the cost
   is negligible, and not breaking the menu wins over the micro-optimisation.

   --header-h rather than --header-h-shrunk, so the bar clears the viewport
   whether or not it happens to be shrunk at that moment.

   The two directions are deliberately asymmetric. The duration on this rule
   governs the move INTO the hidden state, and the one on .site-header governs
   the move back out, so hiding takes 0.45s and revealing stays at 0.28s.
   Getting out of the way can afford to be unhurried; coming back should feel
   immediate, because the visitor scrolling up is usually reaching for the
   nav. A slow reveal just reads as lag. */
.site-header[data-hidden="true"] {
  top: calc(-1 * var(--header-h));
  transition: height 0.25s ease, top 0.45s ease-in;
}

/* Content spans grid columns 2 to 26, matching live: at a 1512px viewport
   the logo starts at x=81 and the CTA ends at x=1431. */
.site-header__inner {
  height: 100%;
  align-items: center;
  row-gap: 0;
}

.brand {
  grid-column: 2 / 12;
  display: inline-flex;
  align-items: center;
}
/* The full lockup with "FINANCIAL TECHNOLOGIES", 264x56 on live, not the
   wordmark-only mark the previous build used. */
.brand img {
  height: 56px;
  width: auto;
  transition: height 0.25s ease;
}
.site-header[data-shrunk="true"] .brand img { height: 46px; }
@media (prefers-reduced-motion: reduce) {
  .brand img { transition: none; }
}

/* Hamburger. Three bars drawn from one element plus its two pseudo-elements,
   crossing into an X when open. 44x44 because that is the minimum comfortable
   tap target; the bars themselves are 24px wide inside it.

   The button's accessible name comes from the visually-hidden "Menu" span, so
   it stays constant. Open or closed is conveyed by aria-expanded, which is
   what screen readers announce, rather than by swapping the label text. */
.nav-toggle {
  display: none;
  appearance: none;
  background: none;
  border: 0;
  padding: 0;
  width: 44px;
  height: 44px;
  cursor: pointer;
  color: var(--on-dark);
  place-items: center;
}

.nav-toggle__bars,
.nav-toggle__bars::before,
.nav-toggle__bars::after {
  display: block;
  width: 24px;
  height: 2px;
  background: currentColor;
  border-radius: 2px;
}
.nav-toggle__bars { position: relative; transition: background-color 0.1s ease 0.15s; }
.nav-toggle__bars::before,
.nav-toggle__bars::after {
  content: "";
  position: absolute;
  left: 0;
  transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.nav-toggle__bars::before { top: -7px; }
.nav-toggle__bars::after  { top: 7px; }

/* Open: the middle bar disappears and the outer two cross. */
.nav-toggle[aria-expanded="true"] .nav-toggle__bars { background: transparent; transition-delay: 0s; }
.nav-toggle[aria-expanded="true"] .nav-toggle__bars::before { transform: translateY(7px) rotate(45deg); }
.nav-toggle[aria-expanded="true"] .nav-toggle__bars::after  { transform: translateY(-7px) rotate(-45deg); }

@media (prefers-reduced-motion: reduce) {
  .nav-toggle__bars,
  .nav-toggle__bars::before,
  .nav-toggle__bars::after { transition: none; }
}

/* Available to any control that needs a name without a visible label. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.site-nav {
  grid-column: 12 / 26;
  justify-self: end;
}

/* Measured gap between nav items on live is 42px. */
.site-nav ul {
  display: flex;
  align-items: center;
  gap: 42px;
  list-style: none;
  margin: 0;
  padding: 0;
}
/* :not(.btn) matters. Without it this rule (0,1,1) outranks .btn--primary
   (0,1,0) and repaints the CTA's text with --on-dark-body, which is now
   white, giving a white label on a white button. */
/* Measured on live: Inter 12px, weight 500, white, +0.0103em. The previous
   build used Outfit 15px at weight 400. */
.site-nav a:not(.btn) {
  font-family: var(--font-body);
  font-size: 0.75rem;
  font-weight: 500;
  letter-spacing: 0.0103em;
  color: var(--on-dark);
  padding-block: 0.4rem;
  border-bottom: 2px solid transparent;
  text-decoration: none;
}
.site-nav a:not(.btn):hover { color: var(--on-dark); text-decoration: none; }
.site-nav a:not(.btn)[aria-current="page"] {
  color: var(--on-dark);
  border-bottom-color: var(--on-dark);
}

/* The demo CTA is a button in the bar, a full-width block in the mobile menu. */
/* RESTORED. These two rules and the two in the mobile block below were
   deleted when the button came out of the header, and are back verbatim
   from _dev/history/_backup/2026-10-19-pre-ambient/css/style.css. The
   mobile pair carries the specificity note that was the original bug. */
.site-nav__cta .btn { padding: 0.65rem 1.25rem; font-size: 0.875rem; }
.site-nav__cta .btn:hover { text-decoration: none; }

/* MOBILE HEADER AND OVERLAY MENU
   Live hides the inline nav and shows a burger at the breakpoint, then opens
   a full-screen overlay rather than a dropdown panel. Menu links are clamped
   between 1.375rem and 1.875rem so they never run away on a wide phone or a
   tablet in portrait. The mobile logo caps at 30px. */
@media (max-width: 799px), (pointer: coarse) and (max-width: 1024px) {
  .nav-toggle {
    display: grid;
    grid-column: 6 / 9;
    justify-self: end;
    position: relative;
    z-index: 2;      /* above the overlay, so it doubles as the close control */
  }
  .brand { grid-column: 1 / 6; }
  .brand img { height: 30px; }              /* live: max-height 30px */
  .site-header[data-shrunk="true"] .brand img { height: 30px; }

  .site-nav {
    position: fixed;
    inset: 0;
    z-index: 1;                    /* below the header bar, so the burger
                                      stays clickable as the close control */
    grid-column: auto;
    justify-self: auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: var(--ink);
    opacity: 0;
    visibility: hidden;
    transition: opacity 400ms cubic-bezier(0.4, 0, 0.2, 1),
                visibility 600ms cubic-bezier(0.4, 0, 0.2, 1);
  }
  .site-nav[data-open="true"] { opacity: 1; visibility: visible; }

  /* Hero headline, on mobile.

     The global h1 clamp bottoms out at 2.775rem, so on a 390px phone the
     fluid term never gets a look in and the headline lands at 44.4px. This
     rule exists to take control of that, and it still does -- it is just
     aiming higher than it used to.

     RAISED 20%, matching the desktop rule it overrides: 2.25 -> 2.7rem,
     7.6vw -> 9.12vw, 3 -> 3.6rem. At 390px the headline goes from 39px to
     47px.

     THE CONSTRAINT IS THE LONGEST WORD, not the clamp. "Operational" does
     not break, so it has to fit the content box on the narrowest phone --
     11 characters at 47px in Outfit is roughly 270px against about 342px
     of content width at 390px. It fits with room to spare, but that is the
     measurement to redo before raising this again: the failure is not a
     cramped headline, it is a word overflowing the frame. */
  .band--hero h1 {
    font-size: clamp(2.7rem, 9.12vw + 0.72rem, 3.6rem);
  }

  /* Interior page headline, on mobile.

     Same root cause as the hero above: the global clamp bottoms out at
     2.775rem and the fluid term never engages at phone widths, so inner
     pages also landed at 44.4px.

     2.5rem is not just a taste call. At 44.4px the longest word on these
     pages, "Technologies", measures 305px once a reader applies the WCAG
     1.4.12 text-spacing override (letter-spacing 0.12em). The mobile content
     box at 320px is 281.6px, and .band--page has overflow-x hidden, so the
     tail of the word was cut off. That is a 1.4.12 failure: content lost
     when the user restyles text.

     The alternatives were worse. overflow-wrap: break-word stops the loss
     but splits the headline as "Accessibilit / y" and "Technologi / es".
     hyphens: auto renders identically in Chrome, which does not insert a
     soft hyphen on an emergency break, so it buys nothing. Taking 10% off
     the size is the only fix where nothing is clipped AND no word is split.

     Measured at 320px with the spacing override: longest line 296px inside
     a 320px viewport, band scrollWidth back to the viewport width. */
  .band--page h1 {
    font-size: 2.5rem;
  }

  /* Clearance under the fixed header. The hero's top padding is exactly the
     header's height, so the headline started immediately below the bar with
     nothing between them. The extra 2.25rem is breathing room, not layout:
     the band still starts at viewport top 0 and its background still runs
     under the header. */
  /* --hero-lift off. It raises the content off the mathematical centre on
     desktop, where there is slack to centre in; here the columns stack, the
     content is taller than the band, and the band grows to fit it. Leaving
     the lift on would just add dead space under the fold. */
  /* RAISED ON MOBILE, TWICE, and the second time with the right lever.

     The first attempt cut the clearance under the header from 2.25rem
     to 0.75rem, on the assumption that this block is top-aligned. It is
     not: .band--hero is `display: flex; align-items: center` at every
     width, and the mobile rule never changed that -- so the block is
     centred in the padding box here exactly as it is on desktop, and
     trimming the top padding moved it by half of what was removed.

     --hero-lift is the mechanism built for this. It is added TWICE to
     the bottom padding, which shifts the centre of the padding box up
     by its own value, so the block rises by the full amount. It was
     pinned at 0 on mobile under the same mistaken belief that a lift
     does nothing to a top-aligned block.

     Top padding is back to a clean var(--header-h) and the raising is
     all done by the lift: about 24px at 390px, against the 12px the
     previous version managed. */
  .band--hero {
    /* Sized to the CUE, not by eye. The scroll cue is absolutely
       positioned, so the flex centring above has no idea it exists and
       centres the headline and subline alone -- leaving the thing a
       visitor actually sees, content plus cue, hanging below the band's
       middle. The cue occupies roughly 100px under the block on a
       phone, so the lift carries about half of that back. */
    --hero-lift: clamp(2.5rem, 12vw, 4rem);
    padding-block-start: var(--header-h);
  }

  /* RAISED ON MOBILE, AND THE LEVER MOVED AGAIN -- because the scene layout
     took the old one away without any rule going red.

     The padding above is on .band--hero, which WAS the box doing the
     centring. Under the ambient scene layout it is not: the scene is
     `padding-block: 0` and the centring happens in .scene__stage, which
     sets its own symmetric `padding-block: var(--header-h-shrunk)`. So the
     two mobile declarations above -- including the --hero-lift that the
     comment block explains at length -- have been inert on the hero since
     scenes landed. They still read as though they work, which is why this
     is spelled out rather than quietly replaced.

     --hero-lift ITSELF IS STILL THE RIGHT MECHANISM, and still inherits
     down to the stage from the rule above. Adding it twice to the bottom
     padding moves the centre of the padding box up by its own value, so
     the block rises by the full amount rather than half of it -- the same
     arithmetic the desktop rule at --hero-lift's declaration uses, applied
     to the box that is actually centring now. About 47px at 390px wide.

     WHY THE BLOCK WANTS RAISING AT ALL: the scroll cue is absolutely
     positioned, so the grid centres the headline and subline alone and the
     thing a visitor sees -- copy plus cue -- hangs below the middle. The
     lift carries roughly half the cue's height back up.

     MOBILE AND COARSE-POINTER ONLY. Desktop keeps a symmetric stage; this
     media query is (max-width: 799px) or a touch device under 1024px, so
     nothing here reaches a desktop viewport. */
  body[data-ambient] .band--hero .scene__stage {
    padding-block: var(--header-h-shrunk)
                   calc(var(--header-h-shrunk) + var(--hero-lift) * 2);
  }

  /* The headline measure comes off on mobile. 19ch of a 39px headline is
     about 486px, wider than a 390px phone's content box, so it never
     binds -- but stating it is cheaper than leaving the next person to
     work out whether it does. The subline keeps a measure, just a tighter
     one: it is set at body size and would otherwise run the full width and
     stop reading as a caption to the line above. */
  .band--hero .hero-centre h1 { max-width: none; }
  .hero-sub { max-width: 30ch; }

  /* THE HERO SCRIM, on mobile.

     The desktop scrim runs at 100deg: 0.92 alpha on the left falling to 0.12
     on the right. That works on a wide viewport, where the headline sits in
     columns 3-14 under the dense end and the wave reads in the open right
     third. On a narrow one it crushes the left two thirds of the frame to
     near black and only about 15% of the visible mesh energy lands there.

     Vertical instead. The headline still sits under the dense end, because on
     mobile it is at the TOP rather than to the left, and the wave crest near
     the bottom comes through. Measured at 390x560 against the mobile h1:

       100deg horizontal   11.57:1   mesh visible left/mid/right  15/26/60%
       vertical, this one  13.20:1                                41/26/33%

     Contrast improves as well, because the h1 now sits under 0.88 rather
     than under whatever the diagonal happened to give at its right edge. */
  .band::before {
    background: linear-gradient(180deg,
      rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.80) 38%,
      rgba(0,0,0,0.45) 68%, rgba(0,0,0,0.22) 100%);
  }

  /* The home hero needs its own, and it has to be declared HERE rather than
     only up at .band--hero::before.

     Both selectors are one class plus a pseudo-element, so they carry equal
     specificity and source order decides. The rule above sits later in the
     file, inside this media query, so on a phone it would simply replace
     the desktop hero rule outright and the HEADER FADE would silently stop
     applying. This is the same trap that collapsed the footer: a later
     same-specificity rule quietly winning.

     So this restates the vertical gradient from .band::before and puts the
     header fade in front of it. It is NO LONGER the same ramp every other
     .band--wave gets: see the note on the stops below.

     The fade's ramp is shorter than desktop's, 70px rather than 95px,
     because --header-h is 84px here rather than 129px. */
  .band--hero::before {
    background:
      linear-gradient(180deg,
        rgb(0,0,0) 0,
        rgb(0,0,0) var(--header-h-shrunk),
        rgba(0,0,0,0) calc(var(--header-h) + 70px)),
      /* A WINDOW, NOT A RAMP, and this is where most of the "the whole
         hero is too dark" came from.

         The old stops were 0.82 / 0.75 / 0.58 / 0.52: a ramp that is
         dense at the top and never gets below half, which averaged 0.67
         alpha ACROSS THE ENTIRE BAND. Two thirds black everywhere, on
         the screen where the animation has the least room to begin with.
         It was that dark for a reason that no longer holds -- it was
         protecting an audience list that used to sit at y 0.51-0.87 and
         has since moved into the ticker.

         The text now occupies y 0.21-0.70. So this holds a deep, flat
         window across exactly that span and lets both ends go light: the
         strip above, where only the header sits, and the strip below,
         where the arcs sweep out of frame. Mean alpha over the band
         falls 0.67 -> 0.51, a 24% reduction, while the weakest point
         over the type barely moves (0.575 -> 0.562).

         The shape is the mobile equivalent of what the desktop ellipse
         does: concentrate on the type, release everything else. A ramp
         cannot do it, because the type is in the MIDDLE of the frame
         here, not at the top -- the same argument that put an ellipse on
         desktop, one axis down.

         Measured, worst of the three beats:
           0.82/0.75/0.58/0.52   390px 8.99   320px 8.64  <- old, mean 0.67
           0.38/0.60/0.56/0.22   390px 7.73   320px 7.70  <- here, mean 0.51
           0.30/0.52/0.48/0.16   390px 5.96   320px 5.94  <- mean 0.44

         The middle row keeps a wide margin over the 4.5 floor, which is
         deliberate: phones vary far more in brightness and viewing angle
         than laptops, and this is the breakpoint nobody can preview. */
      linear-gradient(180deg,
        rgba(0,0,0,0.38) 0%, rgba(0,0,0,0.60) 18%,
        rgba(0,0,0,0.56) 74%, rgba(0,0,0,0.22) 100%);
  }

  .site-nav ul {
    flex-direction: column;
    align-items: center;
    gap: 0;
    padding: 0;
    text-align: center;
  }
  .site-nav li { margin: 0; }
  /* THE CTA IS A BLOCK AT THE FOOT OF THE OVERLAY, not a fourth link, so
     it needs a gap that reads as a break rather than as list spacing.

     SPECIFICITY, not the value, was the bug. `.site-nav li { margin: 0 }` a
     few rules up is (0,1,1); `.site-nav__cta` alone is (0,1,0), so the reset
     won whatever the source order and this margin was silently zero. Adding
     the element qualifier takes it to (0,2,1). */
  .site-nav li.site-nav__cta { margin-top: 4rem; }
  .site-nav__cta .btn {
    display: inline-flex;
    justify-content: center;
    padding: 0.95rem 2.25rem;
    font-size: 1rem;
  }
  /* Overlay links. Was a bare 8.5vmin, which reached 33px on a 390px phone
     and kept climbing on anything wider, with only 0.28em of padding to
     separate them. A clamp caps the top end so a large phone or a tablet in
     portrait does not get enormous type, and the padding is nearly doubled
     so the four items read as a list rather than a stack. */
  .site-nav a:not(.btn) {
    display: block;
    font-size: clamp(1.375rem, 6.2vmin, 1.875rem);
    font-weight: 300;
    letter-spacing: 0;
    line-height: 1.2;
    padding: 0.5em 0;
    border-bottom: 0;
  }
}

/* The 576px-and-up override that used to live here set 6.6vmin, which on a
   700px tablet in portrait resolved to 46px. The clamp above covers that
   range on its own now. */

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

/* The overlay covers the page, so the page must not scroll behind it. */
body.nav-open { overflow: hidden; }

/* ==========================================================================
   CONTACT
   Heading on the left, details on the right, as in the source grid.
   ========================================================================== */

.contact-details {
  font-style: normal;
  font-size: clamp(1.0625rem, 1rem + 0.4vw, 1.25rem);
  line-height: 1.9;
}
/* WHITE, NOT --accent, AND THAT IS A MEASUREMENT RATHER THAN A TASTE.

   These three links -- the address, the phone number and Get directions --
   are the obvious place to want the brand green, and it was asked for and
   turned down on the numbers. This band has NO SCRIM: the About and
   Contact background images were removed and their scrims went with them,
   so the only thing between the mesh and this text is how dim the preset
   is. tools/contact_mesh_check.py measures the worst pixel behind this
   copy across a full pulse period, at three band heights:

     #ffffff  current                   5.29:1   passes
     #18bd5b  --accent, brand green     2.13:1   FAILS 4.5
     #1de26d  the brighter green        3.06:1   FAILS
     #8ff5b5  pale green                4.00:1   FAILS
     #b5f8d0  very pale green           4.36:1   FAILS
     #d6fbe6  near white, green cast    4.74:1   passes

   The worst pixel sits at luminance 0.149, so a foreground needs 0.843 to
   clear 4.5:1 -- and nothing that still reads as green is that light. The
   two colours that pass are white and a tint of white. Mobile measures
   6.34:1 for the brand green because that preset is dimmer; desktop is
   what fails, at 1440 and above.

   SO THERE ARE ONLY TWO WAYS TO GET GREEN HERE, and both cost more than
   the colour is worth: darken the mesh behind this block with a scrim of
   about 45% -- reinstating the thing that was deliberately removed -- or
   dim the preset for the whole band. If either is ever done, re-run
   contact_mesh_check.py rather than assuming the number moved far enough.

   UNDERLINE ON HOVER ONLY, which is the requested treatment and the one
   thing on this block worth a second look. See the note below. */
.contact-details a {
  color: var(--on-dark);
  text-decoration: none;
  text-underline-offset: 0.18em;
}
/* BOTH PROPERTIES ON HOVER, and the colour is not decoration.

   WCAG 1.4.1 asks that a link be distinguishable from the text around it
   by something other than colour. At rest these three now match the two
   plain address lines above them exactly -- same white, same weight, no
   underline -- so at rest there is no cue at all, which is the thing to
   be aware of rather than a subtlety.

   The hover state therefore carries both an underline AND a colour shift,
   so the affordance is unambiguous the moment a pointer finds it, and
   :focus-visible below does the same for the keyboard. A visitor who
   never hovers and never tabs sees three lines of address.

   IF THIS NEEDS A REST-STATE CUE LATER, the cheap ones are a slightly
   heavier weight on the links or a dotted underline at low opacity.
   Colour is not available: see the contrast table above. */
.contact-details a:hover,
.contact-details a:focus-visible {
  color: var(--on-dark-muted);
  text-decoration: underline;
}
.contact-details span { display: block; }
.contact-details__directions { margin-top: 1rem; font-size: 0.9375rem; }

/* ==========================================================================
   CONTACT MAP
   Live's map block reports data-aspect-ratio 58.509, so the frame is 58.5%
   of its own width. At 783px wide that is 458px, matching the measured 469
   once padding is counted.
   ========================================================================== */

/* .map-block IS GONE. It sized the Google iframe: a 783/458 aspect box,
   overflow: hidden to clip the frame to it, and black behind the tiles
   while they arrived.

   Everything it did belongs to the element being sized, which is now
   .map-static__link, and leaving it on the figure was actively wrong in
   two ways: a black square-cornered slab painted behind the frosted pane,
   and -- because <figcaption> is a child of <figure> -- a fixed aspect
   height with overflow: hidden clipped the ODbL credit out of the layout
   entirely. A licence line that is present in the markup and invisible on
   screen is worse than one that was never written.

   The grid rule `.grid > .map-block { order: 2 }` went with it; the figure
   carries .map-static and that rule now names it instead. */


/* THE STATIC MAP. A figure wrapping a link wrapping an image, so the whole
   picture is the target rather than a small label beside it -- comfortably
   past the 24x24 minimum in WCAG 2.5.8 without needing a rule to say so.

   `display: block` on the image kills the inline baseline gap, which would
   otherwise leave a few pixels of band colour under the map looking like a
   border someone forgot to remove. */
.map-static { margin: 0; }

/* THE PANE BEHIND THE MAP IS THE PLACEHOLDER, and this is a reversal worth
   recording because the previous note here said the opposite.

   The frosting was removed when the map itself stopped being translucent,
   on the reasoning that a backdrop-filter under a fully opaque image is
   invisible and therefore a promoted compositing layer doing nothing. That
   is true AFTER the image paints. Before it paints there is no image, and
   what a visitor sees is this box -- which was flat black.

   The contact section is the last scene on the page and the image is
   `loading="lazy"`, so that box is genuinely on screen for a moment. It is
   now a frosted pane with a shimmer, which says "arriving" rather than
   "broken", and it shares the map's radius so nothing shifts when the
   image lands on top of it.

   THE FILTER STILL HAS TO SIT ON THE ELEMENT js/stage.js FADES. An element
   with opacity below 1 is a backdrop root for its DESCENDANTS, so a filter
   nested inside a fading element samples an empty box. [data-stage-body] is
   on this link for that reason and must stay.

   AND IT IS TORN DOWN ON LOAD. js/map-ready.js sets data-map-loaded once
   the image is up, which drops the filter, the background and the shimmer
   together. That answers the objection the old note raised: the layer is
   not kept alive for the life of the page, it exists only while it is
   doing something. */
.map-static__link {
  display: block;
  position: relative;

  /* THE ASPECT BOX LIVES HERE NOW, moved off the figure with the rest of
     .map-block. It has to be on this element and not left to the image:
     the frosted placeholder is this box's background, so before the image
     arrives there is nothing to give it height. Without the ratio the pane
     would be zero-tall until the load finished, which is the one moment it
     exists for. */
  aspect-ratio: 783 / 458;

  text-decoration: none;
  border-radius: var(--radius);
  overflow: hidden;
  background: rgba(255, 255, 255, 0.05);
  -webkit-backdrop-filter: blur(18px) saturate(1.2);
  backdrop-filter: blur(18px) saturate(1.2);
}

/* THE SHIMMER. A soft band swept across the pane.

   A TRANSLATED ELEMENT, NOT AN ANIMATED background-position. The gradient
   version repaints the whole box every frame; a transform on a narrow
   child is handed to the compositor and costs nothing. The band is 60% of
   the pane wide and starts fully off the left edge, so there is no flash
   of a half-drawn highlight on the first frame.

   The parent clips it: `overflow: hidden` above is load-bearing here, not
   just for the corners. */
.map-static__link::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: 0;
  left: -60%;
  width: 60%;
  z-index: 0;
  background: linear-gradient(100deg,
    rgba(255, 255, 255, 0) 0%,
    rgba(255, 255, 255, 0.14) 50%,
    rgba(255, 255, 255, 0) 100%);
  animation: map-shimmer 1.9s ease-in-out infinite;
}

@keyframes map-shimmer {
  from { transform: translateX(0); }
  to   { transform: translateX(370%); }
}

/* Reduced motion keeps the pane and drops the sweep. A frosted box still
   reads as a placeholder; a moving one is the part that is decorative. */
@media (prefers-reduced-motion: reduce) {
  .map-static__link::before { animation: none; }
}

/* ONCE THE IMAGE IS UP, none of the above is doing anything. The filter
   and its compositing layer go with the shimmer rather than lingering
   behind an opaque image for the life of the page. */
.map-static__link[data-map-loaded] {
  background: none;
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
}
.map-static__link[data-map-loaded]::before { content: none; }

/* Above the shimmer. Without the stacking context the pane's ::before
   paints over the image, which looks like a permanent haze rather than a
   placeholder. */
/* Fills the aspect box above rather than defining it. `height: 100%` with
   object-fit guards the case where the generated SVG's ratio drifts from
   783/458 -- it would letterbox rather than stretch, and the pane would
   still be the right shape. */
.map-static__link img {
  display: block;
  position: relative;
  z-index: 1;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The call to action sits ON the map, because the map is the thing being
   clicked and a label below it would read as belonging to the credit line
   underneath. Pill-shaped to match the buttons, but deliberately NOT .btn:
   it is a label inside a link, not a control, and .btn here would put a
   button inside a link in the accessibility tree. */
.map-static__cta {
  position: absolute;
  right: 0.75rem;
  bottom: 0.75rem;

  /* THREE LAYERS INSIDE THE LINK, AND ALL THREE NEED A NUMBER.
     
         0   ::before   the shimmer, behind everything
         1   img        the map, covering the shimmer once it loads
         2   this       the pill, over the map
     
     This one was left at `auto`, which is not the same as "on top".
     Positioned elements with z-index: auto paint BEFORE positioned
     elements with a positive z-index, so the image at 1 painted over the
     pill and the button vanished. It was fine until the shimmer went in
     and the image needed a number of its own.
     
     Stated explicitly rather than relying on DOM order, because the whole
     point of the stack is that DOM order stopped deciding it. */
  z-index: 2;
  padding: 0.5rem 1rem;
  border-radius: 999px;

  /* INVERTED: white text on black, against the sitewide .btn--primary's
     black-on-white. Deliberate, and the only place on the site that does
     it, because this pill sits ON a near-white map rather than on the dark
     page. A white button on a white map has no edge; the same button in
     black is the only thing in that corner with any contrast.

     Measured against the map's land colour, #e9e9e9:

       white pill   1.21:1   invisible, needs a border to exist at all
       black pill  16.21:1

     The TEXT keeps the same pairing either way -- #ffffff on #0b0b0b
     measures 19.68:1 -- so this is a surface inversion, not a contrast
     compromise.

     #0b0b0b rather than pure black, matching the map pin. Two different
     blacks a few pixels apart on the same image read as a mistake. */
  background: #0b0b0b;
  color: var(--on-dark);
  font-family: var(--font-body);
  font-size: 0.8125rem;
  font-weight: 500;
  letter-spacing: 0.0203em;
  text-transform: uppercase;
  line-height: 1.2;
  /* BACKGROUND ONLY, matching .btn exactly. There was a
     `transform: translateY(-1px)` on hover as well, which lifted the pill
     and read as a drop shadow -- no other button on this site moves, so it
     looked like a different kind of control that happened to share a
     colour. Every .btn transitions background-color, color and
     border-color and nothing else; this does the same. */
  transition: background-color 0.15s ease;
}

/* THE HOVER IS ON THE PILL ALONE; THE CLICK IS STILL THE WHOLE MAP.

   It used to fire from anywhere on the link, on the reasoning that a large
   target with no feedback feels broken. In practice the opposite read: a
   button lighting up while the pointer sits in the far corner of a picture
   looks like a stray repaint, because nothing about the pointer's position
   explains it.

   `.map-static__cta:hover` is enough -- the pill is inside the anchor, so
   hovering it hovers the link too, and the anchor keeps the entire map
   clickable exactly as before. Nothing about the hit area changed; only
   what reports a hover did.

   FOCUS IS STILL KEYED TO THE LINK, deliberately. The pill is a span, not
   a control, so it can never receive focus itself -- a keyboard user tabs
   to the anchor. Writing `.map-static__cta:focus-visible` would be a
   selector that can never match, which is the kind of dead rule that looks
   like coverage.

   THE HOVER GOES LIGHTER, NOT DARKER, which inverts with the surface. The
   sitewide .btn--primary is white and darkens to #e2e2e4 on hover; a black
   button cannot darken, so this lifts to #26262a instead. Same idea --
   "the surface responds" -- expressed in the direction the surface has
   room to move.

   #26262a is a 1.31:1 step from #0b0b0b, which almost exactly matches the
   1.29:1 step .btn--primary makes from white to #e2e2e4 -- so the two
   buttons respond by the same perceptual amount in opposite directions.
   That was tuned by measuring, not by eye. Text contrast on hover is
   15.07:1. */
.map-static__cta:hover,
.map-static__link:focus-visible .map-static__cta {
  background: #26262a;
}

/* .map-static__credit IS GONE. The ODbL credit is drawn inside the SVG
   now -- bottom-left, 8.5px, with a white halo -- rather than being a line
   of text under the map that page CSS could clip, restyle or hide. It was
   in fact clipped for its whole life by .map-block's overflow. */


/* ==========================================================================
   BUTTONS
   ========================================================================== */

/* Measured from live: Inter 500, 14px, +0.0203em, uppercase, white ground,
   black text, 10px radius, 2px border, padding 0.709em / 1.216em. The green
   buttons in the previous build were invented; the live site has none.

   THE 2px BORDER IS STILL HERE AND IS NOW TRANSPARENT; see .btn--primary
   below for why the colour went and why the width stayed. */
.btn {
  display: inline-flex;
  align-items: center;
  gap: 0.5rem;
  font-family: var(--font-body);
  font-size: 0.875rem;
  font-weight: 500;
  letter-spacing: 0.0203em;
  text-transform: uppercase;
  line-height: 1.2;
  padding: 0.709em 1.216em;

  /* FULLY ROUNDED. A large fixed length rather than 50%: a percentage
     radius resolves against each axis separately, so on a button that is
     wider than it is tall it produces an ellipse that flattens as the
     label gets longer. A length larger than half the height is clamped by
     the browser to exactly half, which is a true pill at any width, so
     "Request a demo" and a one-word button get the same end caps.

     NOT var(--radius). That is 10px and is still the right answer for the
     cards, the map panel and everything else that uses it; only the
     buttons are pills. Changing the variable would have rounded the lot. */
  border-radius: 999px;
  border: 2px solid transparent;
  cursor: pointer;
  text-decoration: none;
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.btn:hover { text-decoration: none; }

/* NO BORDER COLOUR. It was `border-color: var(--ink)`, a 2px black ring
   reproduced from the live site, and it was wrong on this one.

   EVERY PRIMARY BUTTON ON THIS SITE SITS ON A DARK BAND -- all twelve of
   them: nine in the fixed header, plus the hero, the introduction CTA and
   one each on sff2025 and sff2026. A black ring on dark ground does not
   draw an edge, it eats one: it takes 2px off the white pill all the way
   round and leaves a hard dark seam where the artwork shows through the
   corner radius. On the home page the effect was plainest, because the
   hero button and the header button are on screen together.

   NOTHING MOVES. .btn keeps `border: 2px solid transparent`, so the box
   is the same size it has always been; only the colour is gone. Removing
   the border outright would have pulled 4px off the width and height of
   every button on the site.

   AND NOTHING IS LOST FOR CONTRAST. WCAG 1.4.11 wants 3:1 at the boundary
   of a control, and the boundary here is the white fill against dark
   artwork, not the ring -- a BLACK ring against a dark band was never
   providing it. The focus ring is separate and untouched: the global
   :focus-visible outline is --accent at 2px with a 3px offset.

   IF A PRIMARY BUTTON EVER LANDS ON A LIGHT BACKGROUND it will need an
   edge of its own, because white on white is nothing at all. That is the
   one case this rule no longer covers. */
.btn--primary {
  background: var(--on-dark);
  color: var(--ink);
}
.btn--primary:hover { background: #e2e2e4; color: var(--ink); }

/* .btn--ghost IS GONE TOO. Its own comment said it: "only ever the Decline
   button", and that button no longer exists. A variant kept for a caller
   that was deleted is the kind of dead rule that gets picked up later by
   whoever wants a secondary button and inherits a 1px border width chosen
   to sit beside a specific Accept button on a specific black bar. */


/* ==========================================================================
   EVENT / CAMPAIGN PAGES
   ========================================================================== */

.event-hero { text-align: center; }
.event-hero__logo { width: min(240px, 62vw); margin: 0 auto clamp(2rem, 4vw, 3rem); }
.event-hero h1 { margin-inline: auto; }
.event-hero p { margin-inline: auto; max-width: 56ch; }
.event-hero__meta {
  display: inline-block;
  border: 1px solid rgba(255, 255, 255, 0.3);
  border-radius: 999px;
  padding: 0.45rem 1.15rem;
  font-family: var(--font-display);
  font-size: 0.8125rem;
  color: var(--on-dark);
  margin-top: 1.5rem;
  text-decoration: none;
}
.event-hero__meta:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; }

/* --------------------------------------------------------------------------
   THE CAMPAIGN MEASURE

   783px of content, centred. Measured off live at a 1512px viewport, where
   the heading, the body copy, every rule and the content column itself all
   come back exactly 783 wide at x=365.

   THIS IS A DELIBERATE EXCEPTION TO .container, which is full-bleed
   site-wide -- width:100%, no max-width, "as live is" per the note on it.
   That is true of the main site and false of these pages: the campaign
   pages were built on a fixed measure and never shared the fluid engine.
   Without this the body copy here runs about 1440px on a laptop, nearly
   double live, and 1440px of 19px Inter is roughly 190 characters a line
   -- far past the 45-to-90 that is comfortable to read, and the single
   biggest reason these pages did not look like live.

   calc() rather than a bare 783px because box-sizing is border-box
   globally and .container carries padding-inline: var(--gutter). A flat
   max-width:783px would be the BORDER box, so the gutters would eat inward
   and leave 711px of content. Adding the gutters back makes 783 the
   content width, which is what was measured.

   Scoped to its own class rather than applied to .container, so it cannot
   reach index.html. sff2024 and cibcmellon2025 are deliberately NOT on it:
   they were outside this change and should be looked at before they move. */
.event-measure {
  max-width: calc(783px + 2 * var(--gutter));
  margin-inline: auto;
}

/* --------------------------------------------------------------------------
   CO-BRANDED LOGO ROW

   Two marks side by side: ours and the event's, or ours and a partner's.
   Used twice on the SFF pages -- Chronicle + SFF in the hero, Chronicle +
   Rozen in the family-office section -- which is what live does on both.

   SIZED BY WIDTH, not height, and that is a deliberate reversal of the
   obvious choice. Measured off live, every mark in both rows renders
   between 305 and 329px: the row is a set of equal-width slots. Matching
   heights instead would have been defensible on its own terms, but it
   breaks here because the marks have very different aspect ratios -- the
   Chronicle wordmark is 4.7:1, the SFF2025 lockup 2.05:1. Equal heights
   would make the event's mark over twice the width of ours on a page that
   is ours, and the 2026 mark is a third ratio again (3.5:1), so any
   height rule would need per-year tuning. Width does not.

   flex-wrap because at narrow widths two 305px marks cannot share a line;
   they stack and stay centred rather than shrinking to illegibility. */
.logo-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  /* ~138px on a laptop, which is the gap measured between the two marks in
     both of live's rows (Chronicle ends at 692, the partner mark starts at
     830). The old 4rem cap pulled them into a huddle in the middle of the
     783 measure; at this gap the pair sits across the measure the way live
     has it, close to space-between without the wrap misbehaviour that
     justify-content: space-between causes when the marks drop to one
     column. */
  gap: clamp(1.5rem, 9vw, 8.625rem);
  margin: 0 auto;
}
/* THE WIDTH IS ON THE FLEX CHILD, WHATEVER IT IS, and that indirection
   earns its keep: a mark in this row can be a bare <img> (hero Chronicle),
   an <a> round an image (hero SFF), a [data-logo-dots] host (family
   Chronicle), or an <a> round a host (family Rozen). All four are direct
   children of .logo-row, so sizing the child and letting the image fill it
   covers every case without a selector per arrangement.

   Live's marks land at 325 (Chronicle), 313 (Rozen) and 305 (SFF). One
   shared width rather than three: a 20px spread is invisible here, and
   equal slots is the more defensible design anyway. */
.logo-row > * {
  flex: 0 0 auto;
  width: min(318px, 68vw);
}
.logo-row a > [data-logo-dots] { width: 100%; }

/* ONLY IMAGES THAT ARE NOT THEMSELVES THE FLEX CHILD, and the restriction
   is the whole point of writing two selectors instead of `.logo-row img`.

   `.logo-row img` was (0,1,1) and so beat `.logo-row > *` at (0,1,0) --
   which meant the hero's bare Chronicle <img>, a direct child of the row,
   took width:100% and rendered at the full 783px measure instead of 318.
   That is the hero logo that "shouldn't have been affected": it was
   collateral damage from a rule meant for the images nested inside an <a>
   or a dot host.

   Both selectors below require an ancestor inside the row, so neither can
   ever match a bare direct-child image again. */
.logo-row a img,
.logo-row [data-logo-dots] img { width: 100%; height: auto; }

/* The page's thematic breaks, and on these pages they are also the thing
   that separates one section from the next -- see the spacing block below,
   where the bands give up their padding. Plain <hr>, because that is what
   it is: a break that is already announced as one, rather than a div
   borrowing a border.

   Named .event-rule, not .event-hero__rule, since it stopped belonging to
   the hero the moment it started dividing sections. */
.event-rule {
  border: 0;
  border-top: 1px solid var(--line-dark);
  /* 65px ABOVE a rule is the page's backbone -- live measures exactly 65
     into three of its four rules. Below is 65 as well when body copy
     follows, which is the hero's first rule. */
  margin: 65px auto;
}

/* A rule that runs straight into a heading closes up. Live: 43px under the
   hero's closing rule, 32px under each of the two section dividers. 36 is
   the middle of those three and sits within 7px of all of them.

   The reason it is not 65 is optical rather than arbitrary: a 45px heading
   at line-height 1.16 carries roughly 7px of internal leading above its
   cap height, so a measured gap under a heading already reads larger than
   the same gap under 19px body copy. Live is compensating for that and so
   is this.

   A modifier in the markup rather than :has() or a sibling selector,
   because two of the three cases are a rule in one <section> and a heading
   in the NEXT one -- no selector can see across that boundary. */
.event-rule--to-heading { margin-bottom: 36px; }

/* THE ONE GOLD THING ON THE SITE, and it is scoped so it stays that way.

   #f1b643 is sampled from live, where this line is an <em> inside the
   section's subheading. It is NOT --accent. The brand green could not take
   its place quietly: this line is the page's single piece of emphasis, and
   green reads as a link colour sitting inches from actual links in the
   paragraph below it. The gold is inherited from the Squarespace build and
   is off-palette by every other measure, so it lives here, on one class
   used by the campaign pages, and nowhere near the design tokens. If the
   campaign pages are ever brought onto the brand palette properly, this is
   the rule to delete. */
.event-lede {
  color: #f1b643;
  font-style: italic;
  font-family: var(--font-display);
  font-size: clamp(1.05rem, 2.2vw, 1.3rem);
  line-height: 1.4;
  text-align: center;
  margin: 0 auto 34px;   /* live: lede bottom 633 -> para top 667 */
}

/* ==========================================================================
   CAMPAIGN PAGE VERTICAL RHYTHM

   THE BANDS GIVE UP THEIR PADDING HERE, and this is the fix for the
   complaint that there was "way too much space" between the booth line and
   "Keep it all in the family."

   Every .band on this site carries padding-block: var(--section-y), which
   resolves to 144px on a laptop. That is right for index.html, where bands
   are discrete full-bleed scenes separated by whitespace. It is wrong here.
   Between the end of the hero and the next heading the page was stacking
   144px of hero padding, 144px of the next band's padding and a 48px rule
   margin: about 336px of air where live has 43. Live is not a stack of
   padded bands at all -- it is ONE continuous column, and what separates
   its sections is a rule with 65px either side, not a gap.

   So: padding off, rules in, and the gaps below are the measured ones.
   Scoped to .event-flow on <main>, so index.html and the other event pages
   keep the band padding they were designed with.

   EVERY NUMBER BELOW WAS MEASURED off chroniclefintech.com/sff2025 at a
   1512px viewport, as element-bottom to next-element-top. They are written
   as plain pixels on purpose: these pages are a fixed-measure layout
   matching a fixed reference, and a clamp() here would be inventing
   responsive behaviour live does not have. The one exception is the top of
   the hero, which has to clear a fixed header this site has and live does
   not.
   ========================================================================== */
.event-flow .band { padding-block: 0; }

/* The site header is position:fixed and 129px tall, so without this the
   logo row starts underneath it. Live's header is not fixed, which is why
   this one value has no counterpart to match -- 2.5rem past the header is
   chosen to sit close to live's 100px opening gap. */
.event-flow .event-hero { padding-top: calc(var(--header-h) + 2.5rem); }

/* Closes the page above the footer. Live's last element is the demo button
   with 46px under it before the footer rule; this is that, rounded to the
   section scale. */
.event-flow .band:last-of-type { padding-bottom: clamp(3rem, 6vw, 4.5rem); }

/* Heading after a rule. Live: 43px in the hero, 32px at both of the other
   two rules. 36 is the middle and reads identically at this size. The base
   h1-h4 rule sets margin: 0 0 0.5em, which at a 45px heading is 22px -- so
   the bottom margin is restated here rather than inherited, because live
   wants 32 under a heading, not 22. */
.event-flow .event-band > h2 { margin-top: 0; margin-bottom: 32px; }

/* Paragraph to paragraph inside a section. Live: 16px. The site default is
   1.15em, which at 19.24px body copy is 22px -- close, but the accumulated
   difference over a page is visible next to a reference. */
.event-flow .event-band > p { margin-bottom: 16px; }
.event-flow .event-band > p:last-child { margin-bottom: 0; }

/* Body copy to the partner lockup. Live: para2 bottom 806 -> logos top 862.
   This replaces the inline margin-top the markup used to carry. */
.event-flow .event-band > .logo-row { margin-top: 56px; }

/* Hero logo row down to the first rule. Live: 65px, same as every other
   side of every other rule. The rule's own 65px margin already provides
   it, so nothing is needed here -- noted so the absence reads as a
   decision rather than an oversight. */

/* Centres a campaign section's heading without centring its body copy.
   Live does exactly this split: the h2 and the gold lede sit centred, the
   paragraphs underneath run left at the full container measure. Centred
   body text at paragraph length is genuinely harder to read, because every
   line starts at a different x and the eye has to hunt for the next one,
   so the heading is the only thing that gets it. Note the child combinator
   -- a nested heading inside a stacked article is not this. */
.event-band > h2 { text-align: center; }

/* --------------------------------------------------------------------------
   STACKED SOLUTION BLOCKS (campaign pages)

   Caption above image, centred, read straight down. This is NOT the
   alternating two-column .split used on index.html and it should not be
   converted to one: each item here is a single statement over a single
   screenshot, and the split layout puts the two side by side at a size
   where the portal screenshots stop being readable. The campaign pages
   have always stacked on live. */
.event-stack { text-align: center; }

/* Between one solution and the next. Live measures 51px after the
   aggregation diagram and 11px after each portal screenshot -- not a
   rhythm, an artefact: the two screenshots are browser captures that carry
   their own white margin inside the PNG, so 11px of real gap looks like
   forty. 40 is the honest version of what live looks like, and it keeps
   the three items evenly spaced instead of one loose and two tight.
   The old clamp() went up to 128px, which was nearly three times this. */
.event-stack > article + article { margin-top: 40px; }

/* Caption to its image. Live: 15px, 11px, 11px. No max-width on the
   caption -- it ran to 54ch while .container was full-bleed, which was a
   guard against a 190-character line, and .event-measure is that guard
   now. A second cap inside it would only make captions narrower than
   live. */
.event-stack p { margin: 0 auto 14px; }

/* Heading down to the first caption. Live: 22px. The markup used to carry
   an inline margin-bottom of 3.5rem (56px) here, which was two and a half
   times live and is now gone from both pages. */
.event-flow .event-stack > h2 { margin-bottom: 22px; }

/* Two caps rather than one, because the artwork is two different kinds.
   The portal screenshots are 2500x1513 browser captures that need width to
   stay legible; the aggregation diagram is a 2500x2500 square that would
   tower over the page at the same width and push the next caption off
   screen. The narrow modifier is for the square. */
.event-stack figure { margin: 0 auto; max-width: 760px; }
.event-stack figure.is-narrow { max-width: 380px; }

/* The world data-flow map runs the full column, same as the screenshots.
   It no longer needs its own cap -- the class is kept only so the figure
   can be addressed, and because the aspect-ratio on [data-worldflow] is
   what actually controls its height now. */
.event-stack figure.is-map { max-width: 760px; }

/* EVEN AND LARGER, ABOVE AND BELOW, and it was neither before.

   The two gaps came from different rules and never met, so nobody had to
   decide what the figure's spacing was:

     above   14px   `.event-stack p` margin-bottom, i.e. the caption's
     below   40px   `.event-stack > article + article` margin-top

   56px on both. The number is not new to this page -- it is what
   `.event-flow .event-band > .logo-row` already uses -- and the animated
   map is the one piece of artwork here that needs room to read as a
   diagram rather than as an illustration between two sentences.

   MARGIN COLLAPSING DOES THE WORK, which is why this is one declaration
   rather than three. Above, the figure's 56 meets the caption's 14 and the
   larger wins. Below, the figure is the last child of an <article> with no
   padding, border or overflow, so its bottom margin collapses out through
   the article and meets the next one's 40 -- larger wins again. The other
   figures on the page are untouched and keep the 14/40 rhythm.

   IF AN ARTICLE EVER GAINS padding, a border, or overflow other than
   visible, that collapsing stops and the gap below drops back to 40 while
   the one above stays 56. That would look like this rule half-working. */
.event-stack figure.is-map { margin-block: 56px; }
.event-stack img { width: 100%; height: auto; border-radius: var(--radius); }

/* AN IMAGE THAT IS A GLYPH, NOT A FIGURE. The Canadian flag inline in the
   pavilion sentence on sff2026.

   THE ASSET ITSELF CARRIES NO COMMENT, and that is deliberate twice over.

   First, the build copies assets/ verbatim while stripping comments from
   HTML and CSS, so a note inside the .svg is the one kind of note that
   ships to every visitor. The explanation lives here, where it costs
   nothing in dist. favicon.svg and map-markham.svg have none either.

   Second, the comment that was briefly in there broke the file outright.
   It was written in this codebase's house style, which uses a double
   hyphen as an em dash, and a double hyphen is ILLEGAL inside an XML
   comment. An <img> parses SVG as XML and refuses a malformed document
   entirely, so the flag did not fail to style, it failed to render at
   all, with nothing in the console pointing at punctuation in a comment.
   tools/check.mjs now parses every SVG the site ships for exactly this.

   ABOUT THE ARTWORK. Proportions are the 1965 proclamation: 1:2 overall,
   a white square half the width, quarter-width red bands. The red is
   #d52b1e, the usual sRGB rendering of Pantone 032; the proclamation
   names the Pantone and not a hex, so there is no single correct number.
   Replacing the file with an official one needs no change here: nothing
   references its internals.

   THE LEAF IS TRACED, NOT DRAWN, and that is the whole reason it is
   right. Three hand-authored attempts all came out as a star: a maple
   leaf's proportions are specific enough that eyeballing a polygon does
   not converge, and the first one shipped with 116px of its stem clipped
   off below the flag's bottom edge.

   What replaced them: Hudson supplied a reference bitmap, the red mass
   inside the white square was masked out of it, the boundary followed,
   and the result simplified with Douglas-Peucker at a 1.3px tolerance --
   which lands on 39 vertices, because the leaf really is a straight-edged
   polygon and a tolerance in that range finds its actual corners rather
   than smoothing them away. The right half was then mirrored onto the
   left so the symmetry is exact rather than merely close.

   MEASURED AGAINST THE REFERENCE, 2.33% of the leaf's pixels disagree,
   and that difference is a sub-pixel outline all the way round: tracing
   follows pixel CENTRES, so the polygon sits about half a pixel inside
   the bitmap's edge. At the 29px this renders at, that is invisible.

   THE LEAF IS NOT VERTICALLY CENTRED, and must not be "corrected" to be.
   It sits 45.4 above and 40.5 below in the 512-tall viewBox, because the
   stem hangs lower than the top tip rises. That asymmetry is in the real
   flag. Horizontal centre is exactly 512.0.

   SIZED IN em SO IT TRACKS THE TYPE. The flag is 1:2, so 0.72em of height
   is 1.44em of width, which puts it at roughly the cap height of the text
   beside it rather than at its full line height -- a flag matched to the
   ascender reads as oversized next to lowercase letters.

   vertical-align: -0.08em RATHER THAN middle. `middle` aligns to the x
   height plus half the ex-box, which sits a wide short image visibly high
   against a capital. A small negative baseline offset seats it on the
   baseline the way a capital letter sits.

   border-radius: 1px, NOT var(--radius). The 10px site radius on a 22px
   image rounds the corners nearly to a lozenge. One pixel just takes the
   hard corner off a white rectangle on a black page.

   display: inline-block IS THE LOAD-BEARING LINE. The sitewide reset is
   `img, svg { max-width: 100%; height: auto; display: block; }` -- right
   for every other image on this site, all of which are figures -- and a
   block-level image inside a <strong> drops onto its own line. Without
   this declaration the flag sits under the sentence rather than in it.
   The same reset is also why height is restated: `height: auto` on a
   2:1 file would otherwise derive the height from the width. */
.flag-inline {
  display: inline-block;
  width: 1.44em;
  height: 0.72em;
  vertical-align: -0.08em;
  border-radius: 1px;
}

/* The in-page demo CTA. The footer carries one on every page, but the
   header's was removed site-wide, and the note on index.html justified
   that by saying each page still asks in its own content. These pages did
   not, until now -- live has this button at the foot of the samples list
   and the rebuild dropped it. */
.event-cta { text-align: center; margin-top: 46px; }  /* live: list bottom 3113 -> button top 3159 */

/* The samples section, which live sets left rather than centred. Heading
   to intro 32px, intro to the download list 19px. */
.event-flow .event-samples > h2 { margin-bottom: 32px; }
.event-flow .event-samples > p  { margin-bottom: 19px; }

.download-list { list-style: none; margin: 0; padding: 0; max-width: 620px; }

/* Cards to the full column, flush with the rules above and below them.
   The 620px default stops short of the 783 measure, which left the cards
   visibly narrower than the dividers they sit between -- two different
   left-to-right extents in the same column, which reads as a mistake
   rather than as an indent.

   A modifier rather than a change to .download-list, because sff2024,
   sff2025 and cibcmellon2025 all use that class and were not part of this
   change. */
.download-list--wide { max-width: none; }
.download-list li { margin: 0 0 0.6rem; }
.download-list a {
  display: flex;
  align-items: center;
  gap: 0.85rem;
  border: 1px solid var(--line-dark);
  border-radius: var(--radius);
  padding: 1rem 1.3rem;
  color: var(--on-dark);
  font-family: var(--font-display);
  font-size: 0.9375rem;
  text-decoration: none;
  background: rgba(255, 255, 255, 0.03);
  transition: border-color 0.15s ease, background-color 0.15s ease;
}
.download-list a:hover {
  border-color: var(--accent);
  background: rgba(255, 255, 255, 0.07);
  text-decoration: none;
}
.download-list .ext {
  margin-left: auto;
  font-size: 0.6875rem;
  font-weight: 600;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--accent);
  flex: none;
}

.media-figure { margin: 0; border-radius: var(--radius); overflow: hidden; }
.media-figure img { width: 100%; height: auto; }

.video-slot {
  border: 1px dashed var(--line-dark);
  border-radius: var(--radius);
  aspect-ratio: 16 / 9;
  display: grid;
  place-items: center;
  text-align: center;
  padding: 1.25rem;
  color: var(--on-dark-muted);
  font-size: 0.8125rem;
  background: rgba(255, 255, 255, 0.03);
}

.trio {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
  gap: clamp(1.5rem, 3vw, 2.5rem);
}
.trio h3 { margin-bottom: 0.4rem; }
.trio p { font-size: 0.9375rem; color: var(--on-dark-muted); }

/* ==========================================================================
   LONG-FORM LEGAL PAGES
   Accessibility and Privacy run to tens of thousands of characters. Reading
   that much body copy reversed out of black is measurably harder, so these
   two pages use a light ground. A deliberate exception, not an oversight.
   ========================================================================== */

.doc {
  background: var(--surface);
  color: var(--body);
  padding-block: clamp(3rem, 6vw, 5rem);
}
.doc h1, .doc h2, .doc h3, .doc h4 { color: #000; }
.doc a { color: var(--accent-ink); text-decoration: underline; text-underline-offset: 0.15em; }
.doc a:hover { color: var(--accent-dark); }

.legal {
  display: grid;
  grid-template-columns: 250px minmax(0, 1fr);
  gap: clamp(2rem, 5vw, 4.5rem);
  align-items: start;
}
@media (max-width: 900px) { .legal { grid-template-columns: minmax(0, 1fr); } }

.legal__toc {
  position: sticky;
  top: calc(var(--header-h-shrunk) + 1rem);
  font-size: 0.875rem;
}
@media (max-width: 900px) {
  .legal__toc {
    position: static;
    border: 1px solid var(--line);
    border-radius: var(--radius);
    padding: 1.25rem 1.5rem;
    background: var(--surface-alt);
  }
}
.legal__toc h2 {
  font-size: 0.75rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted-ink);
  margin-bottom: 0.9rem;
}
.legal__toc ol { list-style: none; margin: 0; padding: 0; }
.legal__toc li { margin-bottom: 0.55rem; }
.legal__toc a { color: var(--body); text-decoration: none; }
.legal__toc a:hover { color: #000; text-decoration: underline; }
.legal__toc ol ol { margin: 0.55rem 0 0 0.9rem; }
.legal__toc ol ol a { color: var(--muted-ink); font-size: 0.8125rem; }

.legal__body { max-width: 74ch; }
.legal__body > section + section { margin-top: clamp(2.75rem, 6vw, 4.25rem); }
.legal__body h2 {
  font-size: clamp(1.5rem, 1.2rem + 1.2vw, 2rem);
  padding-bottom: 0.5rem;
  border-bottom: 1px solid var(--line);
  scroll-margin-top: var(--header-h-shrunk);
}
.legal__body h3 { margin-top: 2.25rem; scroll-margin-top: var(--header-h-shrunk); font-size: 1.15rem; }
.legal__body ul { padding-left: 1.3em; }
.legal__body li { margin-bottom: 0.55em; }

.legal__meta {
  font-size: 0.875rem;
  color: var(--muted-ink);
  border-left: 3px solid var(--line);
  padding: 0.15rem 0 0.15rem 1rem;
  margin: 1.5rem 0;
}
.legal__signoff {
  margin-top: 1.75rem;
  padding-top: 1.5rem;
  border-top: 1px solid var(--line);
  font-size: 0.9375rem;
}
.legal__signoff strong { color: #000; }

.notice {
  border: 1px solid var(--line);
  border-left: 3px solid var(--accent-ink);
  border-radius: var(--radius);
  background: var(--surface-alt);
  padding: 1.15rem 1.4rem;
  font-size: 0.9375rem;
  margin: 0 0 2rem;
}
.notice p { margin-bottom: 0.6em; }
.notice p:last-child { margin-bottom: 0; }
.notice strong { color: #000; }

/* ==========================================================================
   FOOTER
   ========================================================================== */

/* Measured off live: 261px tall, solid black, no top border, and laid out on
   the same 26-column grid as the rest of the site.

     logo         cols  3 / 9    332px wide
     Home/About   cols 15 / 17
     Contact/etc  cols 18 / 20
     CTA          cols 21 / 25
     copyright    cols  3 / 11

   The previous build had a four-column block of headed link lists plus a
   separate base bar. Live has none of that. */
.site-footer {
  background: #000;
  padding-block: clamp(2.5rem, 5vw, 3.5rem) clamp(2rem, 4vw, 3rem);
  font-size: 0.875rem;
  color: var(--on-dark-body);
}

.site-footer__grid { align-items: start; row-gap: clamp(1.5rem, 3vw, 2.25rem); }

.site-footer__brand { grid-column: 3 / 11; }
.site-footer__brand img { width: 332px; max-width: 100%; height: auto; }

/* MOVED RIGHT, 15-21 TO 20-26, to close the hole the demo button left.

   The two lists used to sit in the middle because the CTA occupied 21-26
   and held the right edge. With the button gone nothing did, and the
   footer read as a brand on the left, two columns adrift in the centre,
   and a sixth of its width empty -- which looks like a missing element
   rather than a choice, because it was one.

   The pair keeps its 3+3 shape and simply ends on column 26, the grid's
   content edge, so the footer is brand-left and links-right. That is also
   where the motion toggle below now starts; see the note there. */
.site-footer__nav--a { grid-column: 20 / 23; }
.site-footer__nav--b { grid-column: 23 / 26; }
/* .site-footer__cta IS GONE, with the button it placed. The demo CTA is in
   the fixed header on every page now and the footer stopped asking again.
   Deleted rather than commented out, for the reason already recorded
   against .site-nav__cta: CSS that still parses gets picked up by whoever
   next reuses the name. The two mobile rules in the block below went with
   it.

   THE GAP IT LEFT IS CLOSED BY MOVING, NOT BY FILLING: the two nav lists
   shifted right and took the edge this used to hold. See above. */
.site-footer__copy   { grid-column: 3 / 13; grid-row: 2; margin: 0; }

/* Row 2 already holds the copyright at columns 3-13, so the motion toggle
   sits to its right rather than on top of it.

   20/26 WITH start ALIGNMENT, AND IT IS AN ALIGNMENT AGAIN.

   It was 21/26, justified by the demo button directly above it: both were
   grid items in the same 26-column grid, so a shared start line put the
   switch's left edge on the same pixel as the button's -- measured at
   1512px as x = 1157 for both, delta 0.

   The button went, which left that 21 arbitrary for exactly one revision.
   Now that the two nav lists have moved right to 20-23 and 23-26, column
   20 is the left edge of everything in the footer's right-hand group, and
   the toggle takes it so the group reads as one column rather than as a
   stack that drifts a track to the right on its last row. Same argument
   as before, a different element to answer to.

   END, NOT START, so the toggle finishes on column 26 -- the same line
   the second nav list above it ends on, and the right edge of everything
   in this half of the footer. Starting it at column 20 put it under the
   FIRST list while the block it belongs to ran to 26, so it read as
   indented from the right-hand group rather than part of it.

   THE OVERHANG NOTE ON .motion-toggle STILL HOLDS AND NOW POINTS LEFT.
   The control will not wrap and needs about 152px; where the cell is
   narrower it spills, and with end alignment it spills toward column 20
   instead of past 26. Columns 13 to 20 are empty on this row -- the
   copyright stops at 13 -- so there is more room on that side than there
   was in the right gutter, and nothing can leave the viewport.

   IF THE NAV LISTS MOVE AGAIN, MOVE THIS WITH THEM.

   PLACEMENT LIVES HERE, WITH THE OTHERS, AND NOT BESIDE ITS OWN STYLING.
   It was originally written further down, next to the motion-control rules
   it belongs to conceptually. That put it AFTER the mobile media query at
   equal specificity, so it won there too, and column 19 of an 8-column
   mobile grid spawned seventeen implicit tracks. With twenty-four 10px gaps
   in a 281px content box there was nothing left to share out: all eight real
   tracks collapsed to zero. The logo rendered 50px wide, the copyright
   wrapped six lines deep in a 92px column, and the demo button read "QUE
   DEM". Every page, every mobile width.

   Every other footer item is placed in this block for exactly that reason.
   Anything given a grid-column in the footer belongs here too. */
.site-footer__motion { grid-column: 20 / 26; grid-row: 2; justify-self: end; }

.site-footer ul { list-style: none; margin: 0; padding: 0; }
.site-footer li { margin-bottom: 0.6rem; }
.site-footer li:last-child { margin-bottom: 0; }

/* Live's footer links are Inter 14px, weight 400, #dcdcdc. */
/* RIGHT-ALIGNED, SO THE RIGHT-HAND BLOCK HAS ONE EDGE.

   The motion toggle on row 2 is `justify-self: end`, so its right edge is
   exactly on column line 26. These lists sat at the default left
   alignment, starting at lines 20 and 23 and ending wherever the longest
   word happened to stop -- "Accessibility" well short of 26, "Privacy"
   shorter still. So the toggle was flush to an edge that nothing above it
   shared, and the whole right-hand group read as misaligned even though
   every box in it was placed correctly.

   The fix belongs on the TEXT, not on the boxes. The columns were already
   right; it was the ragged right edge inside them that was wrong.

   NOT A CHANGE TO THE GRID. Both lists keep their 3-column spans, so the
   two columns stay where they were and only their contents move. nav--b
   now ends on 26 with the toggle; nav--a ends on 23, which is its own
   column's edge and reads as a second right-aligned stack rather than as
   a mistake.

   DESKTOP ONLY, by source order rather than by a media query: the mobile
   block further down names .site-footer__nav--a and --b directly at the
   same specificity and sets text-align: center, and it comes after this.
   A phone stacks the whole footer centred, where a right edge would mean
   nothing. */
.site-footer__nav { text-align: right; }

.site-footer__nav a {
  font-size: 0.875rem;
  font-weight: 400;
  color: #dcdcdc;
  text-decoration: none;
}
.site-footer__nav a:hover { color: var(--on-dark); text-decoration: underline; }

.site-footer__copy { font-size: 0.875rem; color: var(--on-dark); }

/* Live's mobile footer is inset, not full-bleed: logo and links sit in
   columns 3-9 of its 10-track grid and the CTA in 4-8, which map to 2-8 and
   3-7 here. Everything stacks in one column, CENTRED.

   Centring needs both halves: text-align for the inline content, and
   justify-items / justify-self for the blocks themselves. The logo also needs
   margin-inline: auto, because an <img> is a replaced element and sits at the
   start of its track regardless of text-align. */
@media (max-width: 799px), (pointer: coarse) and (max-width: 1024px) {
  .site-footer__grid { row-gap: 1.25rem; justify-items: center; }

  .site-footer__brand,
  .site-footer__nav--a,
  .site-footer__nav--b,
  .site-footer__motion,
  .site-footer__copy {
    grid-column: 2 / 8;
    grid-row: auto;
    text-align: center;
    justify-self: center;
  }

  /* The two .site-footer__cta rules that sat here -- a 3/7 span stretched
     to a full-width centred button -- went with the button itself. */

  .site-footer__nav--b { margin-top: -0.6rem; }   /* reads as one list */
  .site-footer__brand img { width: 100%; max-width: 240px; margin-inline: auto; }
  .site-footer__copy { margin-top: 0.75rem; }
}

/* ==========================================================================
   MOTION CONTROL
   WCAG 2.2.2. data-motion="reduce" on <html> is set by js/motion.js, either
   from the stored preference or from the OS setting, before first paint.
   ========================================================================== */

/* Grid placement for .site-footer__motion is NOT here. It sits with the other
   footer placements above the mobile media query, because written here it
   came after that query and overrode it. See the note there. */
.site-footer__motion { margin: 0; }

.motion-toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.55rem;
  background: none;
  border: 0;
  padding: 0.4rem 0;
  font: inherit;
  font-size: 0.875rem;
  color: #dcdcdc;              /* 12.0:1 on black, same as the footer links */
  cursor: pointer;
  /* The control needs 152px and its grid cell is narrower than that between
     roughly 800 and 880px, where the label dropped below the track and the
     switch read as two separate things. It is a single control and has to
     stay on one line. The 12px it overhangs at the narrowest point lands in
     the grid's right gutter track, which is ~30px of slack, so nothing
     leaves the viewport. Verified at 800, 860, 900, 1000, 1100 and 1200. */
  white-space: nowrap;
}
.motion-toggle:hover { color: var(--on-dark); }

/* THE SWITCH.

   Checked means motion IS reduced, which matches the label: the switch
   turns "Reduce motion" on. Green is therefore the paused state, not the
   playing one.

   State is carried three ways, deliberately. aria-checked for assistive
   technology. Knob POSITION for anyone who cannot distinguish the two track
   colours, which is what keeps this clear of SC 1.4.1 Use of Color. Track
   colour for everyone else.

   EVERY CONTRAST HERE IS MEASURED, not chosen by eye. SC 1.4.11 wants 3:1
   for the visual information that identifies a control and its state.

     on  track  #18bd5b on black ............ 8.47:1
     off track  #3a3a3d on black ............ 1.85:1   <- fails alone
     off border #6e6e73 on black ............ 4.14:1   <- so the border carries it
     off knob   #fff on #3a3a3d ............ 11.34:1
     on  knob   #fff on #18bd5b ............. 2.48:1   <- fails alone

   The last one is why the knob has a ring. White on the brand green is not
   far enough apart to read the knob's position reliably, and position is
   half of how this control states itself. rgba(0,0,0,0.5) over #18bd5b
   composites to #0c5e2e: 7.91:1 against the white knob and 3.19:1 against
   the track, so the knob's edge is defined on both sides. At 1px on an 18px
   circle it reads as a hairline, not as a border.

   The off track is the same story one level down: a near-black fill would
   vanish against a black footer, so the boundary is the border, not the
   fill. */
.motion-toggle__track {
  position: relative;
  flex: none;
  width: 44px;
  height: 24px;              /* also the SC 2.5.8 target minimum, on its own */
  border-radius: 999px;
  box-sizing: border-box;
  background: #3a3a3d;
  border: 1px solid #6e6e73;
}
.motion-toggle__knob {
  position: absolute;
  top: 50%;
  left: 2px;
  translate: 0 -50%;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5);
}

.motion-toggle[aria-checked="true"] .motion-toggle__track {
  background: #18bd5b;       /* brand green, flat; the gradient pair is for artwork */
  border-color: #18bd5b;
}

/* The knob travels by transform, not by swapping left for right.

   Swapping to `left: auto; right: 2px` puts the knob in the same place, but
   `auto` is not an animatable value, so the slide either jumps or depends on
   how the browser happens to resolve it. A transform always interpolates.

   20px is the full travel and is derived, not guessed: 44px track minus two
   1px borders is a 42px inner width, an 18px knob inset 2px sits at 2..20,
   and the far position is 22..40. */
.motion-toggle[aria-checked="true"] .motion-toggle__knob {
  translate: 20px -50%;
}

/* The knob slides rather than jumps, but only for people who have not asked
   for stillness. This is the one transition that has to survive
   data-motion="reduce" being switched ON, since otherwise the act of
   reducing motion would itself be animated, so it is scoped to the case
   where the flag is absent rather than fighting the blanket rule below. */
html:not([data-motion="reduce"]) .motion-toggle__knob {
  transition: translate 0.18s ease;
}
@media (prefers-reduced-motion: reduce) {
  .motion-toggle__knob { transition: none; }
}

/* What the flag actually switches off. These mirror the existing
   prefers-reduced-motion rules so there is one behaviour, two triggers. */
[data-motion="reduce"] * ,
[data-motion="reduce"] *::before,
[data-motion="reduce"] *::after {
  animation-duration: 0.001ms !important;
  animation-iteration-count: 1 !important;
  transition-duration: 0.001ms !important;
  scroll-behavior: auto !important;
}/* THE COOKIE NOTICE SECTION IS GONE. It styled a bar that asked Accept or
   Decline on a site which sets no cookies, runs no analytics and makes no
   third-party request; see the note where the markup was, in index.html.

   Its one genuinely reusable finding, since it cost a round of measuring:
   rgba(255, 255, 255, 0.34) over black resolves to #575757, which is
   2.91:1 and misses the 3:1 WCAG 1.4.11 asks of a control boundary. A
   border that reads as a hairline at 2px is probably failing that test.
   Solid white is 21:1. */

/* ==========================================================================
   SCROLL REVEAL
   Measured off the live site: transform + opacity, 0.6s, ease, from
   opacity 0 and translateY(30% of the element's own height). Paragraphs
   fade without moving. See js/reveal.js.

   Everything is scoped to .reveal-ready, which js/reveal.js puts on <html>.
   No JavaScript, or prefers-reduced-motion, means the class is never added,
   these rules never match, and the content is simply visible. That is the
   one place this intentionally differs from Squarespace, which hides the
   content in CSS and shows a blank page if its script fails.
   ========================================================================== */

.reveal-ready [data-reveal] {
  opacity: 0;
  transform: translateY(30%);
  transition: opacity 0.6s ease, transform 0.6s ease;
  will-change: opacity, transform;
}

/* Paragraphs fade only. Measured translateY on live is 0. */
.reveal-ready [data-reveal="fade"] {
  transform: none;
}

.reveal-ready [data-reveal].is-revealed {
  opacity: 1;
  transform: none;
  will-change: auto;   /* drop the compositing hint once it has arrived */
}

/* ==========================================================================
   SECTION STAGE
   --------------------------------------------------------------------------
   What [data-reveal] does, but reversible, and with the hero's per-character
   flicker on the heading instead of a fade. Driven by js/stage.js.

   WHY IT IS NOT [data-reveal] WITH A FLAG. The same reason hero-intro.js is
   its own module, written up at the top of that file: reveal.js and this
   both set opacity and transform on the elements they own, so if both
   matched the same node the result would depend on script order. They are
   kept disjoint by attribute instead -- reveal.js looks for [data-reveal],
   this looks inside [data-stage-group], and index.html carries only the
   second set. Every other page still uses reveal.js, untouched.

   THREE ROLES inside a group, in source order:
     [data-stage-head]  the heading. Flickers in character by character.
     [data-stage-body]  copy, buttons, anything that follows. Fades and rises.
     [data-stage-art]   a canvas or image. Flickers in as one piece.

   THE STATE LIVES ON THE GROUP, in data-stage-state, and has two positions:
     (absent)  the start state. Nothing is visible.
     "in"      arrived. The entrance animations run and then hold.

   NOTHING HERE DESCRIBES LEAVING, and that is deliberate. There was a third
   value, "out", and a set of exit transitions to go with it. They worked and
   were never seen: the observer that drove them reports a 72vh group gone
   only once its bottom edge is above the viewport, so every exit animated
   off-screen. Leaving is now the group's own opacity, written from its
   scroll position by js/stage.js, and a scroll position cannot run out of
   sight the way a timed transition can.

   That opacity is on the GROUP, and a parent's opacity multiplies through
   its children, so it composes with everything below rather than competing
   with it. Nothing in this block needs to know it exists.

   Scoped to .stage-ready, which only js/stage.js adds. No JavaScript, or
   reduced motion, and none of these rules match: the page is simply
   visible. Same contract reveal.js and hero-intro.js keep.
   ========================================================================== */

/* --- heading -------------------------------------------------------------
   .hero-type is the wrapper js/stage.js builds around the split characters,
   the same class and structure hero-intro.js uses. The characters carry the
   entrance; the wrapper only has to be out of the way before they start. */
.stage-ready [data-stage-head] .hero-type { opacity: 0; }
.stage-ready [data-stage-head] .hero-type__c { opacity: 0; }

.stage-ready [data-stage-group][data-stage-state="in"] [data-stage-head] .hero-type {
  opacity: 1;
}
.stage-ready [data-stage-group][data-stage-state="in"] [data-stage-head] .hero-type__c {
  animation-name: hero-type-in;
  animation-timing-function: ease-out;
  animation-fill-mode: both;
}

/* --- body content --------------------------------------------------------
   PURE OPACITY. NO TRANSFORM. This carried translateY(28px), borrowed from
   [data-reveal], and it was wrong here for a reason that only shows up once
   the animation runs in both directions.

   A one-way reveal rises INTO place, which reads as arrival. Reversed, the
   same 28px becomes travel on the way out -- so a block that was already
   moving up the screen because the page is scrolling got a second, faster
   upward motion on top of it. Two speeds in the same direction do not read
   as one gesture; they read as the content sliding away before it fades,
   which is exactly the complaint.

   So content now arrives and leaves WHERE IT IS. The only thing that moves
   on this page is the page. Everything else changes opacity in place.

   transition-delay is declared here at 0ms so the "in" rule below can
   override it and the "out" state inherits this value: the entrance is
   staggered, the exit goes all at once. */
.stage-ready [data-stage-body] {
  opacity: 0;
  transition: opacity 620ms ease;
  transition-delay: 0ms;
}
.stage-ready [data-stage-group][data-stage-state="in"] [data-stage-body] {
  opacity: 1;
  /* js/stage.js sets --stage-delay per element. Applied on the way IN only:
     the heading's flicker is most of a second long and everything after it
     waits that out, which is right for an entrance and much too slow for an
     exit. Leaving, the whole group goes at once. */
  transition-delay: var(--stage-delay, 0ms);
}

/* --- artwork -------------------------------------------------------------

   A PLAIN FADE, NOT THE FLICKER, and this is a correction rather than a
   preference. The brief asked for the artwork to flicker in like the
   headings, and at a heading's size the dip at 62% of the old
   @keyframes hero-flicker read as a character resolving. At the size of a 4:3 canvas
   it reads as the picture failing: the map appeared, vanished for about a
   tenth of a second, and came back. Big objects cannot blink the way small
   ones can, so the artwork simply arrives.

   A PLAIN FADE ON THE CANVAS ELEMENT, with the flicker now happening INSIDE
   it. js/art-intro.js gates each particle's alpha on its own arrival, so
   the picture flickers itself in dot by dot; this rule only has to get the
   canvas from invisible to present without fighting that. Flickering the
   element as well would be the same dip twice at two scales.

   An animation rather than a transition, because only animation-delay can
   hold the canvas invisible through the wait for the heading -- a
   transition-delay would leave it at its start value but with no way to
   also set that value per element.

   The child selector reaches both the injected canvas and the <img> it
   replaces, so the entrance is there whether or not globe.js, bars.js and
   network-map.js ran. */
.stage-ready [data-stage-art] { opacity: 0; }
.stage-ready [data-stage-group][data-stage-state="in"] [data-stage-art] {
  opacity: 1;
}
/* THE CANVAS IS NOT IN THIS RULE, and that is what makes the particle
   entrance visible at all.

   It was, and the two entrances fought: the element stayed invisible for
   its --stage-delay and then faded over 760ms, while js/art-intro.js ran
   the per-particle flicker underneath across the first 900ms. The whole
   effect played out behind an element at opacity 0, and what arrived was a
   finished picture fading in -- indistinguishable from the plain fade this
   replaced.

   A canvas needs no fade. Its dots are all at alpha 0 before they are
   cued, so it is already invisible without any help from CSS, and the
   particles are the entrance. js/stage.js cues them at the same
   --stage-delay this rule used, so the timing is unchanged -- it just has
   one owner now instead of two that had to agree.

   THE <img> KEEPS IT. That is the no-JavaScript fallback, a flat PNG with
   no particles to flicker, so a fade is the only entrance available to it. */
/* :not([data-logo-dots]) IS LOAD-BEARING, and the reason is a cascade rule
   rather than a specificity one.

   This hands the slot's <img> a `stage-arrive` animation with
   animation-fill-mode: both, so the moment the group lights, the image is
   pinned to opacity 1 for the rest of the page's life. AN ANIMATED VALUE
   BEATS EVERY NORMAL AUTHOR DECLARATION, whatever its specificity -- so
   `.logodots--live > img { opacity: 0 }` was simply ignored, and raising
   its specificity would not have helped either.

   The visible result was that the two logo slots showed their flat
   artwork the instant the section arrived, with the dot field animating
   underneath something already fully opaque. It read as "no animation at
   all", which is a long way from the cause.

   The logo slots own their image's visibility, so the stage entrance must
   not touch it. The HOST still fades in with everything else -- that is
   the rule above, on [data-stage-art] itself, and it is untouched. */
.stage-ready [data-stage-group][data-stage-state="in"]
  [data-stage-art]:not([data-logo-dots]) > img {
  animation-name: stage-arrive;
  animation-duration: 760ms;
  animation-timing-function: ease-out;
  animation-fill-mode: both;
  animation-delay: var(--stage-delay, 0ms);
}

/* One way up, no dip. The `both` fill is what keeps the canvas invisible
   through its delay instead of flashing on at full opacity and then
   starting, and what holds it at 1 while the group leaves. */
@keyframes stage-arrive {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* --- room to breathe -----------------------------------------------------
   Content that animates in and out needs to be the only thing doing it. At
   the old spacing two solutions were in the active zone at once and the
   second one's heading flickered while the first was still arriving, which
   read as noise rather than choreography.

   min-height is in vh and align-content centres within it, so a block
   shorter than the zone sits in the middle of its own space instead of at
   the top of it. Not 100vh: a full viewport per block turns four solutions
   into four screens of mostly nothing, and the point is pacing, not
   emptiness. */
body[data-ambient] .solution + .solution { margin-top: clamp(5.5rem, 12vw, 11rem); }

@media (min-width: 800px) {
  body[data-ambient] .solution {
    min-height: 72vh;
    align-content: center;
  }
}

/* ==========================================================================
   HERO INTRO
   The landing band's own sequence: the headline types in, the label fades,
   then the audience items slide leftward into place one after another. Timed
   in js/hero-intro.js, which sets every transition-delay inline because the
   delays depend on the character count and the item count.

   Scoped to .hero-intro-ready, which only that script adds. No JavaScript,
   or reduced motion, means these rules never match and the hero is simply
   visible -- the same contract js/reveal.js keeps.

   The released state is keyed to .is-playing on the BAND rather than a class
   per element, so one class toggle releases all three beats at once and the
   staggering is entirely in the delays.
   ========================================================================== */

/* PER-LETTER FLICKER. An ANIMATION, not a transition, and the difference
   is what makes the effect possible at all: a transition can only run
   from the old value to the new one, so it cannot dip part way through.
   The dip at 62% is what reads as a flicker rather than a fade.

   No will-change on the characters. There can be fifty of them, and
   promoting fifty inline boxes to their own layers to animate opacity
   costs more than it saves; the line is one paint either way.

   The delay and duration come from js/hero-intro.js, per character --
   the delay is a random 0-200ms, which CSS cannot generate. `both` keeps
   the first keyframe applied during that delay, so a character with a
   180ms offset stays invisible until its turn instead of flashing on at
   full opacity and then starting. */
.hero-intro-ready [data-hero-type] .hero-type__c {
  opacity: 0;
}
.hero-intro-ready [data-hero-intro].is-playing [data-hero-type] .hero-type__c {
  animation-name: hero-type-in;
  animation-timing-function: ease-out;
  animation-fill-mode: both;
}

/* THE DIGITAL FLICKER IS GONE. This was:

     0% opacity 0, 45% opacity 1, 62% opacity 0.28, 100% opacity 1

   -- a character resolving, dipping, and settling. The dip is what made it
   read as a signal rather than a fade, and it is the part that was
   removed. What is left is the TYPED REVEAL: the characters still arrive
   one after another, staggered by js/stage.js and js/hero-intro.js, each
   one simply fading up.

   SO THE STAGGER IS THE EFFECT NOW, and all of it lives in those two
   modules -- CFG.flickerSpread, flickerJitter and flickerDur. Those names
   are kept deliberately rather than renamed: they are read by
   tools/test_stage.js and tools/test_herointro.js and derive the beat the
   body copy waits for, so renaming them is churn with a real chance of
   breaking the derivation. They time a typed reveal now, not a flicker.

   MONOTONIC, WHICH ALSO ENDS A WCAG QUESTION. The old keyframe was one
   blink over a large area, held to a single play precisely because 2.3.1
   puts the threshold at more than three flashes per second and a looping
   version would have been a genuine hazard. Nothing here flashes at all
   any more, so that constraint is satisfied by construction rather than by
   a rule someone has to remember not to break.

   NOT USED BY THE ARTWORK. js/art-intro.js has its own curve() with the
   dip still in it, for the per-particle canvas entrance. It used to be a
   transcription of this keyframe and had to be kept in step with it; it is
   now standalone. See the note at the top of that file. */
@keyframes hero-type-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* FADE AND RISE, borrowed wholesale from js/reveal.js: opacity plus
   translateY(30%), 0.6s, ease. The 30% is a CSS percentage transform, so it
   resolves against the element's own height and needs no measuring.

   Deliberately the same motion the rest of the site uses rather than
   something tuned for the hero. The beat before it is bespoke -- characters
   typing -- and this is the familiar gesture arriving after it, which is
   what stops the sequence reading as two unrelated effects.

   BOTH the subline and the button use it, staggered by js/hero-intro.js.
   A percentage transform suits that: 30% of a one-line paragraph and 30%
   of a button are different distances in pixels but the same gesture, so
   the pair travels in proportion rather than the button sliding further
   than the line it follows. */
.hero-intro-ready [data-hero-rise] {
  opacity: 0;
  transform: translateY(30%);
  transition-property: opacity, transform;
  transition-timing-function: ease;
}
.hero-intro-ready [data-hero-intro].is-playing [data-hero-rise] {
  opacity: 1;
  transform: none;
}

/* [data-hero-slide] is gone. It was a leftward travel for the five client
   types that used to sit beside the headline, back when the hero was two
   columns. That list is off the page entirely now. Removed rather than
   left in place, because an unused start-state rule is one that hides an
   element if the attribute is ever reintroduced without the matching
   script. */

/* ==========================================================================
   MOTION + PRINT
   ========================================================================== */

@media (prefers-reduced-motion: reduce) {
  /* Belt and braces. reveal.js already bails out before adding the class,
     but if the setting changes after load nothing should stay hidden. */
  .reveal-ready [data-reveal],
  .hero-intro-ready [data-hero-rise],
  .hero-intro-ready [data-hero-type] .hero-type__c {
    opacity: 1 !important;
    transform: none !important;
    transition: none !important;
    /* animation too, not just transition: the characters flicker with a
       keyframe animation now, and the blanket 0.01ms duration lower down
       would still RUN it -- landing on the final keyframe, which is
       opacity 1, but running nonetheless. Naming it none here stops it
       outright rather than relying on that. */
    animation: none !important;
  }

  /* The stage's three roles, and the wrapper js/stage.js builds around the
     split characters. Same belt and braces as the rule above: stage.js
     bails before adding .stage-ready, but if the setting changes AFTER
     load, a group caught mid-exit must not be left at opacity 0.

     A SECOND RULE RATHER THAN SIX MORE SELECTORS ON THE FIRST, because
     tools/test_heroflow.js rejects any selector over 220 characters. That
     is a guard against prose escaping a comment and being parsed as a
     selector -- a real bug this file has had before -- and the honest
     answer to tripping it is to split the rule, not to raise the limit.

     NO .stage-ready PREFIX, which is what brings it under that limit. It
     would be redundant here anyway: without the class nothing has a start
     state, so forcing these to opacity 1 is a no-op on a page stage.js
     never touched. */
  [data-stage-body],
  [data-stage-art],
  [data-stage-art] > canvas,
  [data-stage-art] > img,
  /* THE WRAPPER AND THE CHARACTERS BY CLASS, not by which attribute they
     sit under. Naming all four combinations -- head and body, wrapper and
     character -- pushed this selector past the 220-character ceiling
     tools/test_heroflow.js enforces against prose escaping a comment.

     Bare classes are also the more robust form. .hero-type and
     .hero-type__c exist only where js/stage.js or js/hero-intro.js put
     them, so this covers anything either module splits in future without
     the list having to be extended again. */
  .hero-type,
  .hero-type__c {
    opacity: 1 !important;
    transform: none !important;
    transition: none !important;
    animation: none !important;
  }

  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

@media print {
  /* Never print a half-revealed element as a blank gap. */
  [data-reveal],
  [data-hero-rise],
  [data-stage-body],
  [data-stage-art],
  .hero-type,
  .hero-type__c { opacity: 1 !important; transform: none !important; }
  /* The ambient field is screen decoration and has no business on paper. */
  body[data-ambient]::before,
  body[data-ambient]::after { display: none !important; }.site-header,
  .nav-toggle { display: none; }
  body, .band, .doc, .site-footer { background: #fff !important; color: #000; }
  .band::before { display: none; }
  .band__mesh { display: none; }        /* never print the animated canvas */
  .band { background-image: none !important; }
  h1, h2, h3, h4, .band p, .site-footer, .site-footer a, .contact-details a { color: #000 !important; }
  a { color: #000 !important; }
}

/* ==========================================================================
   THE INTRODUCTION SPLASH
   --------------------------------------------------------------------------
   introduction.html only. A one-screen rebrand announcement reached by
   direct link: logo, headline, two paragraphs and a single button, centred
   in the viewport over the ambient field, with a copyright line and the
   motion switch under it.

   IT BORROWS RATHER THAN REDEFINES. The centring the stack needs already
   exists on .event-hero -- text-align:center, the auto-margined
   .event-hero__logo, the 56ch cap on .event-hero p -- and .event-measure is
   the same 783px column the SFF pages sit on. Everything below is the
   vertical centring and the foot, which is all that was actually missing.

   BODY IS THE FLEX COLUMN, not <main>, and the page is one screen because
   of it: main takes the leftover height and centres its contents in it, so
   the foot sits on the bottom edge instead of pushing the stack up or
   hanging below the fold. Doing it on main alone would have meant
   subtracting a guessed foot height from 100svh.

   display:flex ON BODY IS SAFE HERE, and it is worth saying why, because
   the AMBIENT FIELD block above forbids four other properties on this exact
   element. The field is two fixed pseudo-elements at z-index -1 and they
   stay behind the content only while body establishes no stacking context.
   transform, filter, opacity and z-index each create one. display:flex does
   not -- it changes how body's children are laid out, not how body paints.
   .rays and .skip-link are both out of flow, so neither becomes a flex item
   and the column contains exactly main and the foot.

   svh, NOT vh, for the same reason it is used on the home page hero: on
   mobile browsers vh resolves against the largest viewport, measured with
   the toolbar retracted, so a 100vh page overflows by the height of the
   toolbar until the first scroll. The vh line stays as the fallback for
   anything without svh and must come first.
   ========================================================================== */

body.splash {
  min-height: 100vh;          /* fallback for anything without svh */
  min-height: 100svh;
  display: flex;
  flex-direction: column;
}

.splash__main {
  flex: 1;
  display: flex;
  align-items: center;
}

/* The flex item is the <section>, and a flex item sized by its content will
   not stretch to the container's width on its own. .band is a full-bleed
   element everywhere else on the site, so it has never needed this. */
.splash__main > .band { width: 100%; }

/* Live opens on about 100px of air above the logo. .band's own padding-block
   gets close, but on a short viewport the centred stack can run out of room
   and clip against the top; min-height on the section rather than a fixed
   height lets it grow and scroll instead. */
.splash__main .band { padding-block: clamp(2rem, 6vh, 4rem); }

/* ONE COLUMN, 560px, AND THE LOGO IS WHAT SETS IT.

   The stack is a left-aligned block centred in the viewport: the mark's
   left and right edges are the margin every line of type below it starts
   and ends on. That only reads as deliberate if there is exactly ONE width
   in play, so it is set here, on the container, and everything inside
   inherits it by filling its parent. Four separate max-widths that happen
   to agree would drift the first time one of them was touched.

   560px IS THE ASK, measured as the OUTER edge of the two visible objects:

     logo          560                       width: 100%
     panel outer   560                       width: 100%, the glass
     panel inner   560 - 2 x --panel-pad     the type, inset by the padding

   So the mark and the glass are the same width and share a vertical edge,
   and the type is inset inside the frame. THIS IS THE REVERSE OF THE FIRST
   ATTEMPT, which widened the container by the padding and narrowed the
   logo by it so that the TYPE matched the logo and the glass overhung them
   both. Two defensible readings of the same alignment, and this is the one
   that was wanted: the frame is the aligned object, not its contents.

   NOTHING NEEDS A NEGATIVE MARGIN NOW. Both elements are simply 100% of
   the container, which is also why this survives the narrow end: on a
   phone 100% is the viewport minus gutters and the two still agree.

   .container carries padding-inline: var(--gutter) and max-width is the
   BORDER box, so the gutters are added back or they eat inward -- the same
   correction .event-measure makes for its own 783px, and the reason that
   class is overridden here rather than edited: sff2026, privacy and
   accessibility all sit on its 783.

   AND IT SCALES BY DOING NOTHING. max-width is a cap, not a width, so
   below about 600px the container is simply narrower than the cap and the
   column becomes the viewport minus its gutters -- ~343px on a 375px
   phone. The logo is width:100% of that, the type wraps inside it, and the
   whole block stays edge-aligned the entire way down. No media query, and
   nothing here needs one.

   THE SHARED RULES THIS OVERRIDES, all three on .event-hero and all three
   written for a CENTRED hero that cibcmellon2025.html and sff2024.html
   still are: text-align:center, the auto inline margins, and the 56ch cap
   on paragraphs. 56ch at 19.24px is about 620px, which is wider than this
   column -- so left unset it, not the 560, would have decided where lines
   broke on a desktop. */
.splash {
  /* The panel's inner padding, and now ONLY that. It fed the container's
     max-width and the logo's width while the glass overhung the column;
     with the panel sized to the logo instead, both of those are a plain
     100% and this is read in one place. Scales with the viewport so the
     panel does not sit on a phone with desktop-sized padding. */
  --panel-pad: clamp(1.5rem, 2.6vw, 2.5rem);
}

.splash .event-measure { max-width: calc(560px + 2 * var(--gutter)); }

.splash .event-hero { text-align: left; }

.splash .event-hero h1,
.splash .event-hero p {
  margin-inline: 0;
  max-width: none;
}

/* FILLS THE COLUMN, which is also what the panel below it does, so the two
   share a vertical edge at every width without either naming a number. It
   was briefly calc(100% - 2 x --panel-pad), from the version where the
   glass overhung the column and the logo lined up with the TYPE instead of
   with the frame.

   The shared rule is min(240px, 62vw), so this more than doubles it. Those
   two other pages pair our mark with a partner's in a measured row; this
   page has one mark and nothing to balance it against, which is why it can
   take the room.

   NO HEIGHT HERE. The global `img { height: auto }` plus the width and
   height attributes on the tag keep the 4.71:1 ratio, so 560px wide is
   119px tall. */
.splash .event-hero__logo { width: 100%; }

/* AIR ON BOTH SIDES OF THE MARK. The shared rule is `margin: 0 auto
   clamp(2rem, 4vw, 3rem)` -- bottom only, because on the SFF and CIBC
   pages the logo row opens the page directly under a fixed header that is
   already providing the top gap. Nothing is above it here, so it needs its
   own.

   vh RATHER THAN vw, which is the opposite of the shared rule and is the
   point of restating it. This page is height-constrained: the whole stack
   has to fit one viewport, so the space around the logo should give way on
   a short screen and not on a narrow one.

   OPENED UP THREE TIMES -- 5vh/5.5vh, then 8vh/9vh, then 10vh/11.5vh --
   and THE BOTTOM ONE IS NOW BACK DOWN, because the panel changed what the
   gap is doing. It was holding the mark apart from loose text on an open
   field, and loose text has no edge, so the gap had to be large to read as
   separation at all. The glass has an edge. The panel's own top padding
   (--panel-pad, up to 2.5rem) sits inside that edge and adds to whatever
   this margin is, so the measured distance from logo to heading is the sum
   of the two -- and at 11.5vh the pair came out as a hole.

   12vh above and 10vh below, which lands at ~104px and ~87px + the panel's
   padding on an 865-tall screen. Both were raised together this time, so
   the mark keeps its position in the composition and simply gets more room
   on each side.

   WHICH MARGIN TO REACH FOR, because the stack is centred in the viewport
   and that makes the two behave differently -- this is worth having
   written down before the next adjustment:

     margin-BOTTOM up by Y   logo rises Y/2, panel falls Y/2.
                             Moves the mark up the screen. Opens the gap.
     margin-TOP down by X    logo AND panel both rise X/2 together.
                             Moves the whole composition up. Gap unchanged.

   The bottom margin was the one raised here, from 6vh, to lift the mark
   rather than the block. If the whole stack wants to sit higher instead,
   the top margin is the lever.

   STILL MORE BELOW THAN ABOVE once the padding is counted, which is the
   relationship this has always wanted: the mark reads as sitting above the
   block rather than floating between two identical gaps.

   WHERE THE LIMIT IS, since the stack now has one more box in it: logo +
   panel inside one 100svh screen, and the panel's vertical padding is new
   height that did not exist before. If more air is ever wanted here, take
   it from the heading size or the band padding -- these two margins are
   the first thing that pushes the button under the fold on a 768px-tall
   laptop. */
.splash .event-hero__logo {
  margin-top: clamp(3.5rem, 12vh, 7.5rem);
  margin-bottom: clamp(3rem, 10vh, 5.75rem);
}

/* --------------------------------------------------------------------------
   THE FROSTED PANEL

   A darkened glass box behind the words and not behind the mark. The
   ambient field is the reason it exists: two gradient layers and four light
   shafts all moving independently means the ground under any given line of
   type is never the same colour twice, and small text over that is harder
   to read than the contrast figures suggest. The panel gives the copy one
   surface to sit on without putting a flat rectangle on the page.

   DARKEN PLUS BLUR, AND BOTH ARE DOING A JOB. The blur alone would read as
   merely out of focus -- this is the same note the retired map facade left
   above, and it held true here. The black wash is what makes it a surface.
   Black rather than the 5% white that facade used, because this one is
   asked to DARKEN: the field runs green and teal behind it and a white wash
   lifted it toward grey.

   0.38 IS AS DARK AS IT USEFULLY GOES. Past roughly 0.5 the blur stops
   being visible through the wash and the panel is just a black box with
   rounded corners, which is the thing this is not meant to be.

   CONTRAST IS IMPROVED BY THIS, NOT RISKED. The panel only ever subtracts
   light from the ground beneath the type, so every figure
   tools/ambient_contrast.py reports -- which it measures WITHOUT the panel,
   against the brightest the field and rays can get -- remains the honest
   worst case for this page. Nothing here needs re-measuring.

   NO BORDER. A 1px white rim at 7% was here to catch the light along the
   top edge, which is the conventional way to make glass read as glass. It
   was removed on purpose: at this size the rim drew the box as an object
   with an outline rather than as a darker region of the page, and the
   whole intent is a surface the type sits on, not a card. The edge is
   carried by the wash alone, which is enough against a field this light.

   SIZED TO THE LOGO, not to the type. width is a plain 100% of the column,
   the same as .event-hero__logo above, so the mark and the glass share a
   vertical edge and the copy is inset from it by --panel-pad.

   --radius IS THE SITE'S 10px, taken from the live button radius. overflow
   is NOT needed to clip the blur the way it is on the map pane: that one
   clips a ::before shimmer child. There is no child to clip here.

   THE -webkit- PREFIX stays for Safari, which still wants it. */
.splash__panel {
  padding: var(--panel-pad);
  border-radius: var(--radius);
  background: rgba(0, 0, 0, 0.38);
  -webkit-backdrop-filter: blur(16px) saturate(1.1);
  backdrop-filter: blur(16px) saturate(1.1);
}

/* --------------------------------------------------------------------------
   THE PANEL ARRIVES -- BY MATERIALISING, NOT BY FADING

   THE CONSTRAINT FIRST, because it decides the whole shape of this. An
   element whose opacity is below 1 is a backdrop root, and a
   backdrop-filter can only sample within its own root. The site has already
   paid for this twice, both written up against the retired map facade
   above: a filter inside a scene resting at 0.996 never appeared, and one
   inside a panel fading in on the body stagger arrived a second and a half
   late. The default [data-reveal] animates opacity from 0. Putting it on
   this element unmodified would have produced a panel that slid in with no
   glass in it and then snapped to frosted at the end -- a valid, applied,
   inert declaration, which is the hardest kind of bug to see.

   SO OPACITY IS PINNED AT 1 and three other things animate instead:

     wash      rgba(0,0,0,0) -> 0.38     the surface arrives
     blur      0px -> 16px               it becomes glass
     transform translateY(14px) -> none  it settles

   Which is a better entrance than a fade anyway. A fade would bring the
   finished panel in at low alpha; this brings the field itself into focus
   under the type, so the glass reads as forming rather than as appearing.

   :not(.is-revealed) FOR THE START STATE, rather than restating the end
   values in an .is-revealed rule. When reveal.js adds the class this rule
   simply stops matching and the element falls back to .splash__panel above
   -- so the finished appearance is declared exactly once and cannot drift
   from its own animation. The transition lives on the unconditional rule so
   it is present in both states, which is what makes the fallback animate
   rather than snap.

   14px, NOT THE DEFAULT translateY(30%). 30% of a panel this tall is over
   80px, which is a slab dropping into place; the panel is the ground the
   type sits on and should settle, not travel.

   0.7s AGAINST THE CHILDREN'S 0.6s, so the glass is still resolving for a
   breath after the words have finished arriving. The children are all on
   "fade" now -- see the note in the page -- so nothing moves inside a box
   that is itself moving.

   -webkit- FOR SAFARI in both the property and the transition list. Where
   the blur is not animatable it snaps to final and the wash and the lift
   still carry the entrance.

   NO reveal-ready, NO ANIMATION AT ALL. js/reveal.js returns early for
   prefers-reduced-motion and for the footer motion switch, so the class
   never lands, this rule never matches, and the panel is simply there --
   fully frosted, WCAG 2.2.2 satisfied by the same mechanism as every other
   animation on the site. */
.reveal-ready .splash__panel {
  transition:
    transform 0.7s ease,
    background-color 0.7s ease,
    -webkit-backdrop-filter 0.7s ease,
    backdrop-filter 0.7s ease;
  will-change: transform, backdrop-filter;
}

.reveal-ready .splash__panel:not(.is-revealed) {
  /* Explicit, and the single most important declaration in this block: it
     overrides the opacity:0 that .reveal-ready [data-reveal] would
     otherwise apply, which is what would cost the panel its backdrop. */
  opacity: 1;
  transform: translateY(14px);
  background-color: rgba(0, 0, 0, 0);
  -webkit-backdrop-filter: blur(0px) saturate(1);
  backdrop-filter: blur(0px) saturate(1);
}

/* WHERE THERE IS NO backdrop-filter the wash alone applies, and a 0.38
   wash with nothing blurred behind it leaves the moving gradient legible
   straight through the panel -- which is the one thing it was added to
   stop. So the fallback goes darker and nearly opaque: a plain dark card,
   which is a worse picture and an equally readable one.

   `not (backdrop-filter:)` AND the -webkit- form, both, or Safari takes
   the fallback it does not need. */
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .splash__panel { background: rgba(0, 0, 0, 0.82); }
}

/* The heading is the first thing in the panel and its own margin-top is
   already 0 from the base h1-h4 rule; this is the bottom edge. The base
   gives 0.5em, which at a 45px heading is 22px and was tuned for a heading
   sitting on an open band. Inside a padded box it reads tight against the
   paragraph that follows, so it is restated rather than inherited. */
.splash__panel h1 { margin-bottom: 0.72em; }

/* DOWN TO THE h2 STEP, which is a size the scale already has rather than a
   number invented for this page. The base h1 is clamp(2.775rem, 3.36vw +
   1rem, 3.835rem) -- about 61px on a desktop -- and that was set when the
   headline was the largest thing in the hero. It is not any more: the mark
   above it is 560px wide, and two elements competing to be the first thing
   read is what made the heading feel oversized.

   h2's clamp is ~45px at the ceiling, a 26% reduction, and it keeps the
   headline clearly ahead of the 19px body copy underneath. It also fits
   the column: at 45px, "We've rebranded under a new name!" breaks to two
   lines inside 560px, which is the shape the block wants.

   THE TAG STAYS h1. This is the page's only heading and the top of its
   outline; the change is to how it is drawn, not to what it is. */
.splash .event-hero h1 {
  font-size: clamp(2.14rem, 2.16vw + 1rem, 2.8225rem);
  line-height: 1.2;
}

/* CENTRED, AND BALANCED, AND THE SECOND ONE IS A REVERSION.

   `text-wrap: normal` was set here for one round, on the reading that the
   base h1-h4 rule's `text-wrap: balance` was forcing the heading onto two
   lines. IT WAS NOT. The numbers, measured off assets/fonts/outfit-var.woff2
   at weight 300, 45.16px, +0.02em tracking:

     one line, unwrapped            773px
     the column                     560px

   So the heading cannot fit on one line at this size in this column no
   matter what text-wrap says. Two lines are not a choice the property is
   making; they are arithmetic. All text-wrap decides is WHERE, and the two
   answers are very different:

     normal   [537px] We've rebranded under a     <- ends on an orphan article
              [226px] new name!

     balance  [367px] We've rebranded             <- breaks on the phrase
              [396px] under a new name!

   `normal` is greedy: it fills the line until the next word will not fit,
   which here strands the article "a" at the end of line one and leaves the
   lines 537 against 226. That is the "unnatural" break, and it is what
   setting normal produced. `balance` minimises the longest line, lands on
   the phrase boundary, and comes out 367 against 396.

   THE LESSON, since this was got wrong once: when a heading wraps and the
   wrap looks wrong, measure whether it CAN fit on one line before touching
   text-wrap. If it cannot, balance is almost always the better of the two
   and the real lever is the font size or the column width.

   CENTRED is the other half. The body copy is left aligned against the
   logo's edges, and the heading is not: it is two short lines of display
   type, and ragging them left under a full-width mark left a visibly uneven
   right edge at the top of the block. Centred, the two balanced lines read
   as a single centred object, which is also what the button below them
   does. The paragraphs keep their left edge. */
.splash .event-hero h1 {
  text-align: center;
  text-wrap: balance;
}

/* 2.5rem under the last paragraph, which is what the inline style this
   replaced was doing. The button itself is .btn .btn--primary, unchanged.

   CENTRED, WHICH IS THE ONE THING ON THIS PAGE THAT IS NOT LEFT ALIGNED.
   .event-hero is text-align:left so the type shares the logo's edges, and
   this paragraph opts back out. A button is an object rather than a line of
   prose: it has no left margin to agree with the paragraph above it, and
   sitting hard left under a full-width block of text read as a leftover
   rather than the page's one call to action.

   text-align AND NOT margin:auto, because .btn is display:inline-flex. It
   is an inline-level box, so it is positioned by the alignment of the block
   that contains it; auto margins on an inline-level element do nothing. */
.splash__cta {
  margin-top: 2.5rem;
  text-align: center;
}

/* --------------------------------------------------------------------------
   THE FOOT

   One row of small print on a solid black bar: copyright hard left, the
   motion switch hard right. No rule above it and no grid -- the site
   footer's 12-column layout would be four columns of air here, and
   .site-footer__copy is scoped into that grid, so the copyright gets its
   own rule rather than borrowing one it would have to fight.

   .motion-toggle is NOT scoped to the site footer and never was, so the
   switch is reused exactly as written.

   BLACK, WHICH MEANS IT COVERS THE FIELD RATHER THAN SITTING ON IT. This is
   the one opaque element on the page: the ambient gradient runs edge to
   edge behind everything else and stops at this bar. var(--ink) is the same
   black the site header uses on the interior pages, so the two ends of a
   page match when both are present.

   IT IS NOT A .band, so the body[data-ambient] .band rule that makes every
   band transparent does not reach it and the background survives. Do not
   add .band here. */
.splash__foot {
  padding-block: clamp(1.25rem, 3vh, 2rem);
  background: var(--ink);
}

/* SPACE-BETWEEN, NOT A TWO-COLUMN GRID. The two items have very different
   widths and neither should be measured against the other -- the copyright
   is as long as the sentence is and the switch is a fixed 152px control.
   space-between pins each to its own edge and puts the slack in the middle,
   which is the only place it is not visible as misalignment.

   NOWRAP IS THE ASK, and it holds at every width because the copyright
   wraps its TEXT rather than the row wrapping its items: at 375px the
   sentence takes two lines and the switch stays on the right of both.
   align-items:center keeps the switch centred against that taller block.

   flex-shrink:0 on the switch is what makes that true. .motion-toggle is
   white-space:nowrap and needs 152px; without this, flex would compress it
   below its content width and the label would be clipped rather than the
   sentence rewrapping. */
.splash__foot .container {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  justify-content: space-between;
  gap: 1.5rem;
}

.splash__copy {
  margin: 0;
  font-size: 0.875rem;
  color: var(--on-dark);
}

.splash__motion {
  margin: 0;
  flex-shrink: 0;
}
