/* ==========================================================================
   Wellborn Studio — homepage
   Direction: Webflow Aesthetic. Built as v3 and now the live build; v2 is kept
   complete and openable in _archive/v2/ and is what the notes below compare
   against.

   What changed from v2, and why:

     GRID          Bootstrap's grid layer is gone. Every structural layout is
                   CSS Grid declared by the component that needs it; Flexbox
                   is kept for the inside of components — rows of things that
                   size to their content. That removes 51 KB of stylesheet and
                   nine wrapper divs whose only job was to be a .col.

     MOTION        Transitions animate transform and opacity only. Nothing
                   here tweens a colour, a shadow, a width or a position, so
                   every interactive state runs on the compositor. Where a
                   hover genuinely inverts a panel, the fill is a pseudo-
                   element that scales and the text colour is swapped
                   discretely behind it — see §9.

     EASING        cubic-bezier(0.25, 1, 0.5, 1) throughout. Browser-default
                   ease is used nowhere. The one exception is the scroll-driven
                   work in §16, which stays linear for a measured reason.

     SURFACE       A glass header, layered shadows rather than one heavy drop,
                   and crisp gradients on the rules and the closing panel.

     ICONOGRAPHY   SVG, inline, currentColor. The ↗ that used to be a text
                   glyph is a real path that can be animated.

   Layer order: this file only. There is no framework stylesheet above it.

   TYPE ARCHITECTURE (unchanged, and load-bearing)
   No heading, paragraph, or list item in this document carries a class.
   BEM names live on the wrappers and components — .service-card__body, not
   .service-card__title — so markup pasted out of a CMS rich-text field still
   renders correctly with no classes on it. Where a section needs a different
   scale it redeclares a size token that the flat element rule already reads.
   ========================================================================== */

/* --------------------------------------------------------------------------
   1. Tokens
   -------------------------------------------------------------------------- */

:root {
  /* Color — surfaces */
  --color-canvas: #ffffff;
  --color-surface: #ffffff;
  /* A recessed panel on white, for cards that read as inset rather than
     raised. Body text clears 9:1 on it, the muted gray 5.8:1. */
  --color-surface-sunken: #f3f2f2;
  /* The glass header's own tint. Opaque enough that 11px uppercase nav type
     stays legible over the red panel behind it, translucent enough to be
     worth blurring. Measured in §5. */
  --color-glass: rgba(255, 255, 255, 0.82);

  /* Color — text.
     #605d5d is the lightest gray permitted for text: 6.5:1 on white, 5.9:1
     on the accent tint. Anything lighter fails AA on one of the two. */
  --color-text: #201e1d;
  --color-body: #444141;
  --color-muted: #605d5d;
  --color-on-accent: #ffffff;

  /* Color — accent. Three steps of one ramp, chosen by contrast duty:
       --color-accent         4.2:1 on white. Display type and decoration only.
       --color-accent-strong  4.7:1 against white text. Accent surfaces.
       --color-accent-text    7.2:1 on white. Small accent-colored text.
     The two darker steps exist so 15px accent text and the contact band clear
     WCAG AA; the source design used the base accent for all three. */
  --color-accent: #ec3013;
  /* The closing panel, sampled from the comp: a flat #bf250d, no gradient.
     v3 arrived at #be250d independently as the foot of a darkening ramp, which
     is the same colour to within one step — so the ramp is gone and the value
     it ended on is now the surface. White on it is 6.0:1, against 4.74:1 on
     the old #dd2b0f, which is a full step of headroom the closing headline
     did not have before. */
  --color-accent-strong: #bf250d;
  --color-accent-text: #ae1800;
  --color-accent-tint: #fff2ef;
  /* Small accent text on a dark ground. The base accent manages only 3.9:1
     against --color-text, so an inverted card needs a lighter step: 5.3:1.
     On the black panel the base accent clears 5.0:1 on its own and is used
     directly — see §11. */
  --color-accent-on-dark: #ff563c;

  /* The About panel. Sampled at #000000 — not --color-text's #201e1d, which
     would read as a dark grey card next to the closing panel's saturated red.
     True black is the only value that holds its own beside it. */
  --color-ink: #000000;
  --color-on-ink: #ffffff;
  /* Body copy on the panel, sampled at #cfcfcf. 13.7:1 on black. */
  --color-body-on-ink: #cfcfcf;

  /* Heading color as a token rather than a descendant override, so an
     inverted surface re-points it once instead of restating h1–h6. */
  --color-heading: var(--color-text);

  /* Color — rules. Decorative; they do not encode state, so they are not held
     to 3:1. A rule that ever indicates state moves to --color-muted. */
  --color-rule: #201e1d;
  --color-rule-hairline: #cbc9c9;

  /* Focus. The ring is re-pointed to white inside the accent band; the halo
     is the opposite value, so the pair stays visible on either ground. */
  --focus-ring: #201e1d;
  --focus-halo: #ffffff;

  /* Typography */
  /* Neue Haas Grotesk used as the optical-size family it is: Display is cut
     for large settings, Text for reading sizes, with the line at roughly 24px.
     The fallbacks are metrically near-identical, so a kit that fails to load
     shifts the texture without reflowing the page — which matters more than
     usual, because Adobe Fonts kits are domain-locked and will not serve to an
     unregistered host or a file:// preview. */
  --font-display: "neue-haas-grotesk-display", "Helvetica Neue", Helvetica, Arial, sans-serif;
  --font-body: "neue-haas-grotesk-text", "Helvetica Neue", Helvetica, Arial, sans-serif;

  /* Reading sizes run one step larger than they did under Archivo: Neue Haas
     Grotesk has a 0.517em x-height against Archivo's 0.548, about 6% less
     visible letter for the same nominal size. Uppercase sizes are deliberately
     NOT bumped — the two faces share a cap height, so labels and tiles already
     match at their old values and compensating them would make them grow. */
  --text-xs: 0.6875rem;   /* 11px — uppercase labels, cap-height driven */
  /* --text-tile (17px) lived here for the sector cells. The cells are gone and
     so is the token: the index is set at display scale now, from --size-sector
     below. A token kept alive for a component that no longer exists is the
     start of a stylesheet nobody trusts. */
  --text-sm: 0.9375rem;   /* 15px */
  --text-base: 1rem;      /* 16px */
  --text-md: 1.0625rem;   /* 17px */
  --text-lg: 1.125rem;    /* 18px */
  --text-xl: 1.3125rem;   /* 21px */

  --tracking-label: 0.14em;
  --tracking-label-wide: 0.2em;

  /* ---- Display scale -----------------------------------------------------
     Defaults the bare h1/h2/h3 rules read; a component overrides one by
     redeclaring the token on itself. Every step is a clamp() — no display
     size in this document is a fixed number at any viewport.

     Measured off the comp, which is drawn 1:1 at a 1144px content column —
     the column this build produces at a 1440px viewport — so the comp's pixel
     values are read here as the ceilings, and the vw terms are set to reach
     them at exactly 1440.

                          390px    1440px    (was)
       h1    page           48       180      135   ← the one real change
       h2    contact        40       100      100
       h2    feature        38        80       76
       h2    section        33        62       62
       h3    item           22        40       40
       h3    compact        19        22       22

     The pair that has to work hardest is the section h2 against the repeated
     item h3 in Services — the only place two display levels sit inside one
     section. Their clamps are deliberately proportional: 4.3vw against 2.8vw
     is a fixed 1.54 wherever both are scaling, and the two floors (33/22) are
     1.50, so the contrast never collapses at one end of the range the way a
     pair of independently-chosen clamps does.                               */

  /* The h1 is cut so the longest word of the first line ("building,") fills the
     content column on a phone — that edge-to-edge fit is what the mobile comp
     is actually specifying, not a number. "building," cannot break, so it is
     the word that sets the ceiling: whatever slope is chosen, that one word
     has to fit the column in every face this stack can resolve to, because a
     line that does not fit is clipped by the reveal mask (§7) rather than
     scrolled — letters silently cut off, no scrollbar to warn anyone.

     Measured with Archivo and again with the fallback (Liberation Sans, Arial
     metrics) at 320–820:
       26.4vw (the comp's literal 128px at 493)  over by 15–27px everywhere
       25vw                                      over by 16–29px everywhere
       24vw                                      over by 1–5px at 10 of 12 widths
       23vw                                      clears by 7–26px everywhere
     So 23vw. The phone lands at 110px at 493 rather than the comp's 128, and
     "building," fills 96% of the column instead of 101% — the edge-to-edge
     read the comp is after, held one notch back from the edge so it survives a
     font swap. The number that matters is 390px going from 62px to 86px.

     The steeper slope reaches the 180px ceiling at ~820px instead of ~1440, so
     tablets up now sit at the comp's display size rather than ramping toward
     it. That is a consequence of sizing off the phone, and the right one:
     180px is the designed size, and the widths that used to interpolate below
     it were the ones where the headline read as merely large. */
  --size-h1: clamp(3.5rem, calc(23vw - 0.22rem), 11.25rem);
  --size-h2: clamp(2.0625rem, 4.3vw, 3.875rem);
  --size-h3: clamp(1.1875rem, 1.55vw, 1.375rem);

  /* The sector index (§12). Not a heading — it is a set of names read as one
     block — so it gets its own step rather than borrowing h3's.

     Dropped from 46px to 32px, which takes the h2-to-index ratio from 1.35 to
     1.94. At 1.35 the index was reading as a second heading competing with the
     one above it; 1.94 is within a hair of the 2.2 this page already uses
     between a section h2 and its repeated items (62px against the 28px card
     titles), so the index now sits at the level the rest of the page would
     expect for a set of things underneath a heading.

     Proportional to the section h2 by the same rule as everything else:
     4.3vw against 2.22vw is a fixed 1.94 wherever both scale, and the floors
     (2.0625 / 1.375) are 1.50. It sits clearly under the h2 that introduces
     it and clearly over the 17px it used to be set at.
                          390px    1440px
       h2   section         33        62
       sector index         17        32                                     */
  --size-sector: clamp(1.0625rem, 2.22vw, 2rem);

  /* 0.9 on the h1, measured: 180px type on a 162px line. The other display
     levels stay at 0.88. Sentence case wants marginally more room than caps
     did — ascenders and descenders now occupy the leading that caps left
     empty — and the h1 is where that shows. */
  --leading-h1: 0.9;
  --leading-display: 0.88;
  --leading-h3: 1.1;

  /* Optical tracking, retuned for sentence case. Caps carry their own space
     and want it opened as they shrink; lowercase is already fitted, so the
     same sizes want a tighter setting than they did in caps — most of all at
     180px, where default fitting reads loose enough to look unset. h3 stays
     positive because it is still uppercase. */
  --tracking-h1: -0.03em;
  --tracking-h2: -0.022em;
  --tracking-h3: 0.01em;

  /* Spacing. Every value is rem — no layout dimension in this file is
     expressed in pixels. The only px left anywhere are hairlines, which are
     device rules rather than layout and should not scale with the type. */
  --space-3xs: 0.25rem;
  --space-2xs: 0.5rem;
  --space-xs: 0.75rem;
  --space-sm: 1rem;
  --space-md: 1.5rem;
  --space-lg: 2rem;
  --space-xl: 3rem;
  --space-2xl: 4rem;

  /* The footer's floor, and the figure the closing panel subtracts from the
     viewport so the two together make exactly one screen. */
  --footer-h: 4.5rem;

  --page-max: 80rem;      /* 1280px */
  /* Both of these were fixed floors that made sense on a laptop and took a
     quarter of a phone. Together they used to reserve 48px a side at 390px —
     of a 390px screen — leaving 294px of usable measure.

     Retuned as two-point lines rather than plain clamps, so the desktop values
     are untouched and only the small end moves:

                       390px          1440px
       page gutter      12              24     (unchanged)
       section pad       8              44     (unchanged)
       total a side     20              68     (unchanged)
       content         350            1144     (was 294 / 1144)

     56px of measure returned on a phone, which is a 19% wider column. */
  --page-gutter: clamp(0.75rem, 1.7vw, 1.5rem);
  --section-pad-inline: clamp(0.5rem, calc(3.43vw - 0.34rem), 2.75rem);

  /* Motion */
  --duration-fast: 180ms;
  --duration-base: 300ms;
  --duration-reveal: 600ms;

  /* One easing curve, used everywhere. It leaves immediately and settles
     long — the reason a Webflow interaction feels answered rather than
     played back. Browser-default `ease` appears nowhere in this file, and
     no rule below transitions anything but transform and opacity except the
     two documented discrete colour swaps in §6 and §10.

     There is no --transition-ui shorthand any more. v2 had one and it grew to
     five properties because it was easy to add to; naming the duration and
     the curve at each use site is three more characters and one fewer place
     for a repaint to hide. */
  /* THREE CURVES, each with a job, and no fourth without one.

       --ease-out    interface states. The 2-16px moves a hover makes.
       --ease-glide  long travel that has to land flat against an alignment.
                     Expo-out: spends its speed early and arrives dead flat,
                     which is what the sector rows need now that they finish
                     on the same left edge as the heading. An overshoot there
                     would read as a misalignment blip against that edge.
       --ease-rise   the headline's masked lines. Expo-out with a small
                     overshoot, so the type rises past its resting position by
                     about 6px and catches. Nothing else on the page overshoots;
                     this is the one element big enough for it to read as
                     precision rather than bounce. */
  --ease-out: cubic-bezier(0.25, 1, 0.5, 1);
  --ease-glide: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-rise: cubic-bezier(0.22, 1.3, 0.36, 1);
  --duration-glide: 1100ms;

  --reveal-distance: 1.5rem;
  --reveal-stagger: 80ms;

  /* The whole-section ease-up (§14a). Further than an element's 1.5rem,
     because the thing moving is a screen-wide band and the same distance would
     barely register on it. */
  --band-rise: 2.5rem;
  --duration-band: 700ms;

  /* Masked line reveal (§8a). Was 900ms on the house curve, and measured at
     998ms before the last line came to rest — most of which was spent crawling
     the final few pixels, because cubic-bezier(0.25, 1, 0.5, 1) is tuned for
     short moves and has a long tail over a 210px one. Half of that budget was
     motion nobody could see finishing.

     500ms on --ease-rise settles the whole cascade in about 620ms and puts the
     speed where it reads: 94% of the travel is done by the halfway point,
     against 87% for the old curve, and the overshoot gives the arrival an edge
     the old one did not have. */
  --duration-mask: 500ms;
  --line-base: 20ms;
  --line-stagger: 60ms;

  /* Layered shadows. Three short casts stacked instead of one long blur:
     the near layer gives the edge, the far layer the ambient lift. A single
     `0 10px 30px rgba(0,0,0,.2)` reads as a sticker; this reads as a card
     sitting on paper. Radii in rem so they scale with the page at zoom. */
  --shadow-raised:
    0 0.0625rem 0.125rem rgba(32, 30, 29, 0.05),
    0 0.375rem 0.75rem rgba(32, 30, 29, 0.06),
    0 1rem 2rem rgba(32, 30, 29, 0.08);
}

/* --------------------------------------------------------------------------
   2. Elements
   -------------------------------------------------------------------------- */

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

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  /* Makes body a query container so the full-bleed panels can size themselves
     in cqw. Unlike vw, cqw resolves against this element's content box, which
     excludes the scrollbar — the difference between a clean bleed and a stray
     horizontal scrollbar on every classic-scrollbar platform. */
  container-type: inline-size;
  background: var(--color-canvas);
  color: var(--color-body);
  font-family: var(--font-body);
  font-size: var(--text-base);
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}

/* The studio's display voice, applied to headings as headings.

   CASE IS NOW SPLIT BY LEVEL. h1 and h2 are set sentence case; h3 and below
   stay uppercase, along with the kickers, nav, buttons and the sector index.
   That is not a compromise between two ideas — it is the idea: the display
   sizes are where the face's drawing shows, and caps flatten it, while at
   22px and 11px caps are doing structural work that lowercase cannot (a card
   title has to read as a label before it reads as a sentence). The dividing
   line lands in the same place as the optical-size switch, which is why it
   holds together. */
h1, h2, h3, h4, h5, h6 {
  margin: 0;
  font-family: var(--font-display);
  /* 700 is the kit's heaviest cut. No font-stretch: Neue Haas Grotesk has no
     width axis and browsers do not synthesize one, so a font-stretch value
     here would be dead CSS. */
  font-weight: 700;
  color: var(--color-heading);
  text-wrap: balance;
}

h3, h4, h5, h6 { text-transform: uppercase; }

h1 {
  font-size: var(--size-h1);
  line-height: var(--leading-h1);
  letter-spacing: var(--tracking-h1);
}

h2 {
  font-size: var(--size-h2);
  line-height: var(--leading-display);
  letter-spacing: var(--tracking-h2);
}

h3 {
  font-size: var(--size-h3);
  line-height: var(--leading-h3);
  letter-spacing: var(--tracking-h3);
}

p { margin: 0; }

ul, ol { margin: 0; padding: 0; }

/* A crisp two-tone rule rather than a flat bar: the first third carries the
   full weight of the accent-dark, the rest drops to a hairline value. Hard
   stops, so it stays a drawn rule and not a smear. */
hr {
  height: 0.125rem;
  border: 0;
  margin: 0;
  background-image: linear-gradient(
    90deg,
    var(--color-rule) 0 28%,
    var(--color-rule-hairline) 28% 100%
  );
}

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

/* The single presentational hook on a content element in the whole document.
   A span exists only to carry it, so nothing semantic is being reclassified
   for appearance. */
.accent { color: var(--color-accent); }

:focus { outline: none; }

/* Two-tone focus. The outline is the indicator; the halo behind it guarantees
   the indicator itself has an edge on whatever it lands on — dark type, a red
   panel, an inverted card. Neither is transitioned: a focus ring that fades in
   is a focus ring that is briefly invisible. */
:focus-visible {
  outline: 0.125rem solid var(--focus-ring);
  outline-offset: 0.1875rem;
  box-shadow: 0 0 0 0.3125rem var(--focus-halo);
}

::selection { background: #ffe0d9; }

/* fixed, not absolute. An absolute skip link is anchored to the document, so
   it only lands on screen while the page happens to be scrolled to the very
   top — a restored position or a deep link pushes it off screen. Fixed keeps
   it at the top of the viewport whenever it takes focus, at any scroll
   position. Verified at 0, 1500 and 3000px. */
.skip-link {
  position: fixed;
  inset-inline-start: var(--space-sm);
  inset-block-start: var(--space-sm);
  z-index: 20;
  padding: var(--space-xs) var(--space-sm);
  background: var(--color-text);
  color: var(--color-surface);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  transform: translateY(-200%);
  transition: transform var(--duration-fast) var(--ease-out);
}

.skip-link:focus-visible {
  transform: translateY(0);
  outline-color: var(--color-accent-text);
}

/* --------------------------------------------------------------------------
   3. Icon
   Inline SVG, sized in em so it tracks the label it sits beside, and painted
   with currentColor so it inherits every state the text already has. The
   diagonal arrow used to be a ↗ character; as a path it can be animated, and
   it renders identically on a machine with no arrow glyph in its fallback.
   -------------------------------------------------------------------------- */

/* The sprite itself. display:none would stop <use> resolving in some engines,
   so it is taken out of flow and given no size instead. */
.svg-sprite {
  position: absolute;
  inline-size: 0;
  block-size: 0;
  overflow: hidden;
}

.icon {
  /* aspect-ratio rather than a matching height: the box is reserved from the
     width alone, so nothing shifts if the SVG resolves late. */
  inline-size: 0.875em;
  aspect-ratio: 1;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.75;
  stroke-linecap: square;
  transition: transform var(--duration-base) var(--ease-out);
}

/* --------------------------------------------------------------------------
   4. Page shell and bands
   -------------------------------------------------------------------------- */

.page {
  max-inline-size: var(--page-max);
  margin-inline: auto;
  padding: var(--page-gutter);
  /* No gutter under the last band: the closing panel has to reach the bottom
     edge of the document, or a white strip sits below the red. */
  padding-block-end: 0;
}

.band {
  background: var(--color-surface);
  /* The section rhythm, and with the slide framing gone this is the only thing
     setting it. Two adjacent bands put 2x this between them; the comp's
     content-to-content seams run 104-158px, so 80px a side lands in the middle
     of that range at the width it was drawn for. */
  padding-block: clamp(2.5rem, 5.4vw, 5rem);
  padding-inline: var(--section-pad-inline);
}

/* The hero starts well below the header — but only as far as the screen can
   afford it.

   The width-based clamp on its own was the right idea and the wrong axis. At
   152px it opened the page nicely on a big display and pushed the lede 93px
   below the fold of a 13-inch MacBook running Chrome, because the thing that
   actually varies here is vertical room and the clamp was reading horizontal.
   The min() against 11vh is the correction: the opening keeps its generosity
   where there is height to spend and gives it back where there is not.

       788px tall (13" + browser chrome)    87px
       870px tall (14" + browser chrome)    96px
       900px                                99px
      1080px                               119px
      1400px                               152px  ← the clamp's own ceiling
*/
.band--hero { padding-block-start: min(clamp(3rem, 10.5vw, 9.5rem), 11vh); }

/* The sector rows deliberately overrun the content column — the middle row
   reaches the viewport edge — so this band, and only this band, bleeds to the
   viewport and clips there. Without it the overrun becomes document width and
   hangs a horizontal scrollbar off every page.

   x clip, y visible: the vertical axis has to stay open for the scroll-driven
   cross-fade and for anything that overhangs a line box. */
.band--sectors {
  margin-inline: calc(50% - 50cqw);
  padding-inline: calc(50cqw - 50% + var(--section-pad-inline));
  overflow: clip visible;
}

/* "Small by design." is a three-word thesis in a five-column measure, not a
   section label. At desktop, section scale would leave it on one short line
   inside a 450px column; at this scale it breaks in two and fills the measure.
   Presence comes from the ratio of size to measure, which is why this heading
   runs larger than one twice its length two sections away. */
/* The About section is a black panel now, not a white band: full bleed to
   within 5rem of the viewport on each side, 1rem radius, content pulled back
   onto the same column every other section uses.

   The bleed maths are the accent band's, plus an inset. `min(0px, …)` is the
   guard that makes it responsive: once the viewport is narrow enough that the
   sum turns positive, the panel would start insetting *inward* past the page
   box, so it clamps to the page box instead and the inset quietly stops
   applying. The padding subtracts the same value, so content stays on the
   column at every width. */
.band--about {
  --size-h2: clamp(2.375rem, 5.55vw, 5rem);
  --panel-inset: 5rem;
  --panel-bleed: min(0px, calc(50% - 50cqw + var(--panel-inset)));

  --color-heading: var(--color-on-ink);
  --color-accent-text: var(--color-accent);
  --focus-ring: #ffffff;
  --focus-halo: #000000;

  background: var(--color-ink);
  color: var(--color-body-on-ink);
  border-radius: 1rem;
  /* The panel is a drawn box, so its edge is the boundary a reader sees — it
     needs air of its own on top of the neighbouring bands' padding, or it
     sits closer to them than two white bands sit to each other. */
  margin-block: clamp(1.5rem, 3vw, 2.75rem);
  margin-inline: var(--panel-bleed);
  /* max(), not the bare calc. The calc exists to pull content back onto the
     column after the bleed, and on a wide screen it resolves to 182px. On a
     phone the bleed is clamped to zero, so it collapses to whatever the
     section pad is — which the mobile pass just took down to 8px. A bare band
     can carry 8px because nothing marks its edge; a panel cannot, because its
     edge is drawn and the text ends up sitting on it. The floor is the panel's
     own padding, and it only ever wins where the calc has stopped meaning
     anything. */
  padding-inline: max(1.5rem, calc(var(--section-pad-inline) - var(--panel-bleed)));
  padding-block: clamp(3rem, 7.5vw, 8.5rem);
}

.band--accent {
  /* The closing ask is the page's second loudest moment, and the only other
     one allowed above feature scale.

     Re-cut through 64px at 390 — the old 7vw curve left the phone at 40px,
     which is under the page's own h2 step and read as a caption on a full
     screen of red. Straight line from there to the comp's 100px at 1440, so
     the middle widths ramp instead of stepping at a breakpoint.

     The min() is the fit guard. "something" cannot break, and at 64px it is
     324px wide against a 280px column at 320 — 11px of horizontal scroll on
     the narrowest phone still in use. The column is a near-constant 0.88 of
     the viewport and the word runs 5.06x the font size, so the largest size
     that always fits is ~17.4vw; 16.5vw keeps a margin and only ever binds
     below 390, where the line would otherwise overflow. Above that the clamp
     governs and the guard is inert. */
  --size-h2: min(16.5vw, clamp(2.5rem, calc(3.43vw + 3.16rem), 6.25rem));
  --focus-ring: #ffffff;
  --focus-halo: #7a1707;
  --color-heading: var(--color-on-accent);
  color: var(--color-on-accent);
  /* The headline was starting 40px under the panel's top edge on a phone,
     which is not enough for the loudest thing on the page to look placed
     rather than pinned. 6rem at every width; the foot keeps its old fluid
     value because the wordmark down there is already sitting on a wider
     margin of its own. */
  padding-block: 6rem clamp(2.5rem, 5.4vw, 5rem);
  margin-block-start: clamp(1.5rem, 3vw, 2.75rem);

  /* One screen of red, top to bottom. The closing panel and the footer are two
     elements but one surface, so the panel takes the viewport less whatever
     the footer occupies, and the footer is given a floor so that subtraction
     has something real to subtract. Landing on #contact therefore fills the
     screen edge to edge with nothing white at either end.

     min-block-size, never a fixed height: on a short screen, or once the
     footer wraps to two lines, the panel grows past a screen rather than
     overflowing itself. More red than a screen is not a failure; less is.

     svh, not vh — on mobile vh is the *largest* viewport height, measured with
     the browser chrome retracted, so a vh-sized panel is taller than the
     screen at rest and the footer starts below the fold. */
  min-block-size: calc(100vh - var(--footer-h));
  min-block-size: calc(100svh - var(--footer-h));

  /* With a floor on the height there is now slack to place, and the comp puts
     the wordmark at the foot of the panel with the headline at its head. */
  display: flex;
  flex-direction: column;
  justify-content: space-between;

  /* Flat, no gradient. The ramp existed to add depth to a #dd2b0f panel while
     only ever darkening it, because white on that red had no contrast to
     spare. The comp resolves it the other way: one darker red, flat, at
     6.0:1. Depth was the consolation prize for a colour that was too light —
     fixing the colour retires the gradient. */
  background-color: var(--color-accent-strong);

  /* Full bleed out of the 80rem page, with the content pulled back to where
     it would have sat: the negative margin is the distance from the page's
     content edge to the viewport edge, and the padding adds that same distance
     back plus the band's usual inset — so the colour runs edge to edge while
     the heading and the CTA stay on the same line as every other section.

     cqw, not vw. `vw` counts the scrollbar while clientWidth does not, so a vw
     breakout overshoots by half the scrollbar on Windows and most Linux setups
     and hangs a horizontal scrollbar off the page. Tested here: `overflow-x:
     clip` on html or body does NOT contain that overshoot — the page still
     scrolls sideways — so the usual guard would have been dead CSS. cqw
     resolves against body's content box and never overshoots.

     Where container queries are unsupported the whole declaration is invalid
     and drops, leaving an ordinary inset band. Degrading to no bleed is the
     right failure. */
  margin-inline: calc(50% - 50cqw);
  padding-inline: calc(50cqw - 50% + var(--section-pad-inline));
}

/* --------------------------------------------------------------------------
   5. Header — glass
   Nav wraps rather than collapsing. There is no width at which the links
   become unreachable, which is why this page has no disclosure button and
   therefore nothing for aria-expanded to describe: the honest way to satisfy
   that requirement is not to build the control it applies to.
   -------------------------------------------------------------------------- */

/* Full bleed, like the closing panel. Over the white sections the tint is
   invisible; over the red it is the difference between a header bar and a
   white rectangle floating with colour showing past both ends.

   The blur only does anything once the header is stuck and content is passing
   underneath it — which is exactly when a solid bar would read as a lid. */
.site-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm) var(--space-lg);
  background: var(--color-glass);
  -webkit-backdrop-filter: blur(0.875rem) saturate(160%);
  backdrop-filter: blur(0.875rem) saturate(160%);
  margin-inline: calc(50% - 50cqw);
  padding-inline: calc(50cqw - 50% + var(--section-pad-inline));
  padding-block: var(--space-xs);
}

/* Without backdrop-filter the tint alone would let the red read straight
   through at 82% and take the nav type down with it. Fall back to solid.
   Worth stating plainly: the glass is decoration, the legibility is not. */
@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
  .site-header { background: var(--color-surface); }
}

/* No rule under the header and no shadow on it. Both were there to separate
   the bar from the page, and the blur already does that — over white the line
   was invisible, and over the red panel it and the shadow together drew a
   second edge under a bar that already has one. Glass that needs a border is
   not doing its job. */

/* The wordmark is the identity. The red diamond that used to sit here was a
   placeholder from the canvas comp, and pairing an invented mark with a real
   logo would be inventing brand. */
.site-header__brand {
  display: inline-flex;
  align-items: center;
  padding-block: var(--space-2xs);
}

.site-header__brand img {
  display: block;
  block-size: 1.625rem;
  inline-size: auto;
  /* Exact viewBox ratio, so the box is reserved correctly before the file
     lands rather than reflowing the header by a couple of pixels. */
  aspect-ratio: 286.2 / 49.5;
}

.site-nav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2xs) var(--space-lg);
  /* Pushes itself and the CTA after it to the right, so three flex children
     still read as "logo left, everything else right" — the arrangement the
     comp shows — rather than as three things spread evenly. */
  margin-inline-start: auto;
}

/* On a phone the header was 184px tall: logo, nav and CTA each on their own
   row, 26% of the screen spent before the page began. Reordering the nav after
   the CTA pairs the CTA with the logo on the first row and drops the links to
   the second — two rows instead of three, and the nav keeps every link
   reachable, which is the whole reason it wraps rather than collapsing behind
   a button. */
@media (max-width: 48em) {
  .site-header { gap: var(--space-2xs) var(--space-sm); }
  .site-header__brand img { block-size: 1.375rem; }
  .site-nav { order: 1; margin-inline-start: 0; gap: var(--space-2xs) var(--space-md); }
  .site-header .button--primary { padding-inline: var(--space-sm); }
}

/* Below 640 the links no longer share a row with the logo and CTA — measured:
   one row at 640 and up, two rows at 600 and below — and the second row is the
   whole difference between the current header and the mobile comp, which shows
   logo and CTA alone with the kicker starting immediately under them.

   Dropping same-page jump links is normally the kind of thing that leaves a
   phone with no way to reach a section. It does not here, and only because of
   what this page is: five stacked bands on one continuous scroll, no routes,
   no content behind a click. Every target the nav points at is reachable by
   the gesture the reader is already making, the one action on the page (the
   CTA) stays in the header at every width, and the same three sections are
   passed in order on the way down. This is a display change on a one-page
   document, not a navigation removal — the day this page gains a second URL,
   the links have to come back as a disclosure button rather than a second row. */
@media (max-width: 39.9375em) {
  .site-nav { display: none; }
}

/* The link's hover is a rule that draws itself in from the left. It is a
   scaleX on a pseudo-element, so the animation is a transform and the link's
   own box never changes — no text reflows underneath it, and nothing repaints
   but the one composited layer. */
.site-nav__link {
  position: relative;
  padding-block: var(--space-xs);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  color: var(--color-text);
}

.site-nav__link::after {
  content: "";
  position: absolute;
  inset-inline: 0;
  inset-block-end: 0.375rem;
  block-size: 0.125rem;
  background: var(--color-accent);
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform var(--duration-base) var(--ease-out);
}

.site-nav__link:hover::after,
.site-nav__link:focus-visible::after { transform: scaleX(1); }

/* --------------------------------------------------------------------------
   6. Buttons and links
   -------------------------------------------------------------------------- */

/* The fill is a pseudo-element that scales up from the bottom edge, not a
   background-color transition. Two reasons, in order of importance:

     1. transform composites. background-color repaints the button, its label
        and its icon on every frame of every hover, in the middle of a page
        that is already running scroll-driven animation.
     2. it is a better hover. A colour crossfade has no direction; a fill that
        rises has one, and the direction is what makes it read as an answer to
        the pointer rather than a state change.

   isolation: isolate on the button lets the fill sit at z-index -1 — above the
   button's own background, below its label — without giving the label a
   stacking context of its own. */
.button {
  position: relative;
  isolation: isolate;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2xs);
  min-block-size: 2.75rem;
  padding: var(--space-xs) var(--space-md);
  border: 0.125rem solid transparent;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  text-align: center;
  cursor: pointer;
  transform: translateY(0);
  transition: transform var(--duration-base) var(--ease-out);
}

.button::before {
  content: "";
  position: absolute;
  z-index: -1;
  /* Inset by the border width so the fill lands inside the drawn edge rather
     than under it. */
  inset: -0.125rem;
  background: var(--button-fill, var(--color-accent-strong));
  transform: scaleY(0);
  transform-origin: 50% 100%;
  transition: transform var(--duration-base) var(--ease-out);
}

/* Scoped to real pointers and to the elements that actually move. will-change
   is a budget, not a hint you sprinkle: promoting every button on the page for
   the whole session costs memory for a layer that is idle 99% of the time.
   Two short-lived layers on the two CTAs is the whole of it. */
@media (hover: hover) {
  .button:hover { transform: translateY(-0.125rem); }
  .button:hover::before,
  .button:focus-visible::before { will-change: transform; }
}

.button:hover::before,
.button:focus-visible::before { transform: scaleY(1); }

.button:hover .icon,
.button:focus-visible .icon { transform: translate(0.125rem, -0.125rem); }

/* Dark ground, accent fill. The label is white in both states, so this hover
   changes no colour at all — the cleanest case, and the reason the primary
   button needed none of the machinery in §9. */
.button--primary {
  --button-fill: var(--color-accent-strong);
  background: var(--color-text);
  color: var(--color-surface);
}

/* White ground on the red panel, filling to near-black. This one does invert
   its label, so it uses the discrete colour swap documented in §10. */
.button--invert {
  --button-fill: var(--color-text);
  background: var(--color-surface);
  color: var(--color-text);
  min-block-size: 3.25rem;
  padding-inline: var(--space-lg);
  font-size: var(--text-sm);
  letter-spacing: 0.1em;
  box-shadow: var(--shadow-raised);
  /* 1ms is a swap, not a tween: the delay is the whole point. It waits for the
     fill to cover the label, then flips. See §9 for the timing. */
  transition:
    transform var(--duration-base) var(--ease-out),
    color 1ms linear 60ms;
}

.button--invert:hover,
.button--invert:focus-visible {
  color: var(--color-surface);
  transition-delay: 0s, 140ms;
}

/* Inline text link. The underline is the affordance; colour alone never is.
   The tint that used to arrive as a background-color transition is now a
   pseudo-element fading in behind the text. */
.link-inline {
  position: relative;
  isolation: isolate;
  color: var(--color-accent-text);
  /* 700, not 600: the kit ships 400 and 700 only, and asking for a weight the
     family does not have resolves to 700 while reading as intentional. */
  font-weight: 700;
  border-block-end: 0.125rem solid var(--color-accent);
}

.link-inline::before {
  content: "";
  position: absolute;
  z-index: -1;
  inset: -0.125rem -0.25rem;
  background: var(--color-accent-tint);
  opacity: 0;
  transition: opacity var(--duration-fast) var(--ease-out);
}

.link-inline:hover::before,
.link-inline:focus-visible::before { opacity: 1; }

/* --------------------------------------------------------------------------
   7. Kicker — the small tracked uppercase label above a heading
   A block in its own right, reused in three places. A <span> carries it rather
   than a <p>, which is both more honest (a two-word label is not a paragraph)
   and one element flatter than wrapping a classless <p> in a classed div.
   -------------------------------------------------------------------------- */

.kicker {
  display: block;
  margin-block-end: var(--space-md);
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-label-wide);
  text-transform: uppercase;
  color: var(--color-muted);
}

/* --------------------------------------------------------------------------
   8. Hero
   -------------------------------------------------------------------------- */

/* No rule between the headline and this band — the gap carries the separation
   on its own. The padding stays: it was the rule's breathing room, and pulling
   it too would close the two blocks up.

   Grid, and a third empty track. The two content columns are 5 and 4 of the
   old twelve; the trailing 3 keeps the discipline list off the right edge
   without a margin that would have to be unwound at every breakpoint. */
/* Close under the headline. The 48px margin plus 24px padding was left over
   from a version with a rule between the two, and 72px of air under a 180px
   headline reads as a separate block rather than as its subtitle. */
.hero__meta { margin-block-start: var(--space-md); }

/* One child since the discipline list came out, so the column definition that
   used to place the lede against it is gone too. The measure now comes from
   the paragraph's own max-inline-size, which is the honest place for it. */

/* --------------------------------------------------------------------------
   8a. Masked line reveal — the headline

   Each rendered line gets its own mask, and rises into it from below. The
   masks are built by script (see wellborn-v3.js) because a line is a rendering
   outcome, not a thing that exists in markup: "IF IT'S WORTH BUILDING," is one
   authored line and two rendered ones at 1440, three at 1024, and the whole
   count changes again when the webfont swaps in. Wrapping authored lines by
   hand would mask the wrong things at every width but one.

   Why per line rather than one mask around the whole headline. A single mask
   makes the block slide as a slab: at three lines and 135px that is a 350px
   move, and the eye reads it as the page shifting rather than as type
   arriving. Per line, each mask travels one line height, the stagger gives the
   headline a reading direction, and the effect is over in the time the slab
   would still be moving.

   Everything here is a transform inside a clip. No layout property animates,
   the h1's box never changes size, and nothing below it moves.
   -------------------------------------------------------------------------- */

/* The h1 stops doing its own fade once the lines exist — the mask is the
   reveal now, and running both fades type that is already rising.

   `transition: none` is the load-bearing declaration here, and it was missing
   in the first pass. Specificity (0,2,1 against the generic rule's 0,2,0) is
   enough to win the *value* — opacity resolves to 1 — but the generic rule
   still supplied the transition, so the element dutifully animated from 0 to
   its new target over 600ms. Measured: 0 at 300ms, 0.78 at 500ms, 0.98 at
   900ms, on top of the mask. Overriding a property is not the same as
   cancelling the animation of it. */
.js h1[data-lines] {
  opacity: 1;
  transform: none;
  transition: none;
  /* Flex, purely to stop adjacent line masks collapsing their margins. Each
     mask pads itself out and pulls the padding back with an equal negative
     margin (below); between two block siblings those two negatives would
     collapse to one, and every line after the first would sit a padding
     short. Flex items do not collapse margins. */
  display: flex;
  flex-direction: column;
}

.hero__line {
  display: block;

  /* Two values: x visible, y clipped. A mask that clipped both axes would
     shave the side bearings off the first and last letter of every line —
     most visible on the 'w' of "worth", whose overhang sits outside its
     advance width. Where the two-value syntax is unsupported the declaration
     drops and the lines simply do not clip, which costs the effect and
     nothing else.

     THE PADDING IS NOT DECORATION. A 0.9 line height pulls the line box in
     tighter than the glyphs on both edges, so the clip would cut real ink:
     about 0.03em of ascender and apostrophe off the top, and — now that the
     headline is set sentence case — the whole descender of the 'g' in
     "building," off the bottom. Caps had no descenders, which is why the
     first version of this rule only padded the top and got away with it.

     The equal negative margin takes the padding straight back out of layout,
     so the rag lands exactly where it did before splitting. Asserted, not
     assumed: the split render is pixel-identical to the unsplit one at six
     viewports, which is a test that fails the moment a descender is clipped. */
  overflow: visible clip;
  padding-block: 0.14em;
  margin-block: -0.14em;
}

.hero__line-inner {
  display: block;
  transform: translateY(0);
}

/* 130%, not 100%: the mask now extends 0.14em past the line box at each edge,
   and the glyphs overhang it too, so anything less leaves a sliver of the next
   line's ascenders showing before the animation starts. Derived as
   (0.9 + 0.14 + 0.05) / 0.9 = 1.21 and then given margin. */
.js [data-lines] .hero__line-inner {
  transform: translateY(130%);
  transition: transform var(--duration-mask) var(--ease-rise);
  transition-delay: var(--line-delay, var(--line-base));
}

.js [data-lines][data-revealed] .hero__line-inner { transform: translateY(0); }

/* Enumerated rather than computed, because the delay has to come from CSS: an
   inline style is the obvious way to pass a per-line index and this document
   carries none. Six steps covers the deepest wrap measured (five lines at
   390px); anything past that shares the last delay rather than trailing. */
.hero__line:nth-child(2) .hero__line-inner { --line-delay: calc(var(--line-base) + 1 * var(--line-stagger)); }
.hero__line:nth-child(3) .hero__line-inner { --line-delay: calc(var(--line-base) + 2 * var(--line-stagger)); }
.hero__line:nth-child(4) .hero__line-inner { --line-delay: calc(var(--line-base) + 3 * var(--line-stagger)); }
.hero__line:nth-child(5) .hero__line-inner { --line-delay: calc(var(--line-base) + 4 * var(--line-stagger)); }
.hero__line:nth-child(6) .hero__line-inner { --line-delay: calc(var(--line-base) + 5 * var(--line-stagger)); }
.hero__line:nth-child(n + 7) .hero__line-inner { --line-delay: calc(var(--line-base) + 5 * var(--line-stagger)); }

/* The per-word spans the splitter leaves behind carry no styling of their own
   and must not acquire any — anything that changes their box would change the
   line breaking the split was measured against. The only class they can pick
   up is .accent, copied across so "WELL." keeps its colour wherever the line
   grouping puts it. */

/* One sentence, one line, at --text-xl.

   The size is a consequence of the cut, not a preference. Two sentences at
   18px filled a 58ch measure across two lines and had presence from sheer
   quantity. One sentence at that size is 580px of 18px type sitting alone
   under three lines of 180px display — it reads as a caption someone left
   behind rather than as the line that says who this is for. 21px is the step
   that holds its own there, and it is the step --about-lede already uses, so
   the two ledes on the page now rhyme instead of being two different sizes for
   the same job.

   The measure stays generous so the sentence keeps its single line; it is a
   guard against a longer one, not a shaping device. */
.hero__lede p {
  font-size: var(--text-xl);
  line-height: 1.45;
  max-inline-size: 60ch;
}

/* The hero's discipline list — four ruled links under the lede — is gone from
   the comp, and its styles went with it rather than sitting here unused. Worth
   flagging rather than burying: it was the only in-page navigation to the
   Services section besides the header, so the hero now has no onward link at
   all. Easy to restore; it should be a decision, not an oversight. */

/* --------------------------------------------------------------------------
   9. Section head — heading left, one line of support copy right
   -------------------------------------------------------------------------- */

.section-head {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-md);
  margin-block-end: var(--space-lg);
}

@media (min-width: 62em) {
  .section-head {
    grid-template-columns: minmax(0, 3fr) minmax(0, 1fr);
    align-items: end;
    gap: var(--space-lg);
  }
}

.section-head .kicker { margin-block-end: var(--space-sm); }

.section-head__support p {
  font-size: var(--text-base);
  line-height: 1.5;
}

/* --------------------------------------------------------------------------
   10. Service cards
   Four sunken panels, numeral pinned top and title/copy pinned bottom. The
   card form solves the contrast problem the stacked rows had for free: at 28px
   against a 62px section heading the ratio is 2.2, so the h3 no longer
   competes with the h2 that introduces it.

   THE INVERSION, AND HOW IT STAYS ON THE COMPOSITOR

   The hover flips a light card to a dark one. The fill is a pseudo-element
   scaling up from the bottom edge — a transform, one layer, no repaint. What
   a transform cannot do is carry the text with it: colour is a paint property,
   and tweening three of them per card is exactly the work this rule set exists
   to avoid.

   So the colours are not tweened. They are swapped, once, behind the fill.
   `color 1ms linear <delay>` is a discrete flip scheduled to land while that
   part of the card is already covered, and the delays differ by where the
   element sits, because the fill arrives at the bottom first:

       body copy, heading   bottom of the card   covered by  ~60ms   swap  90ms
       index numeral        top of the card      covered by ~240ms   swap 210ms

   On the way out the fill retreats downward, so the top uncovers first and the
   order reverses — the numeral flips back immediately, the copy waits.

   The effect reads as the fill passing over the type, which is what it looks
   like in the comp. It costs two composited layers and zero tweened paint.
   -------------------------------------------------------------------------- */

.services__grid {
  /* One at a time, not one wave. The page default is 80ms, which against a
     600ms reveal means card 2 starts while card 1 is barely 15% in — four
     cards read as a single ripple. At 240ms each card is about 80% settled
     before the next moves, which is the threshold where the eye stops seeing
     a group animate and starts counting: one, two, three, four. The whole run
     lands at 1.3s, which is long for a hover and right for an entrance. */
  --reveal-stagger: 240ms;

  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-xs);
}

@media (min-width: 48em) {
  .services__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

@media (min-width: 75em) {
  .services__grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}

.service-card {
  --size-h3: clamp(1.375rem, 2.2vw, 1.75rem);
  --tracking-h3: -0.005em;
  --color-index: var(--color-accent-text);

  position: relative;
  isolation: isolate;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  gap: var(--space-md);
  block-size: 100%;
  padding: clamp(1.25rem, 2.1vw, 1.875rem);
  background: var(--color-surface-sunken);
  /* 16px, measured off the comp — the same radius the About panel uses, so
     the two rounded surfaces on the page read as one decision. */
  border-radius: 1rem;
  transition: color 1ms linear 0ms;
}

/* The fill. */
.service-card::before {
  content: "";
  position: absolute;
  z-index: -1;
  inset: 0;
  border-radius: inherit;
  background: var(--color-text);
  transform: scaleY(0);
  transform-origin: 50% 100%;
  transition: transform var(--duration-base) var(--ease-out);
}

/* The lift. A separate layer whose opacity fades, because box-shadow is not
   in the list of things this file is allowed to transition — and because
   animating a shadow means re-rasterising the blur every frame, which is the
   single most expensive thing a hover can ask for. */
.service-card::after {
  content: "";
  position: absolute;
  z-index: -1;
  inset: 0;
  border-radius: inherit;
  box-shadow: var(--shadow-raised);
  opacity: 0;
  transition: opacity var(--duration-base) var(--ease-out);
}

/* A floor only where cards sit side by side and need to agree. Stacked at one
   column, space-between would just open a gap under a two-line card. */
/* 26rem, measured: the comp's cards are 416px tall against the 288px they
   were, which is what turns the numeral-to-title gap from a detail into the
   card's main gesture. */
@media (min-width: 48em) {
  .service-card { min-block-size: 26rem; }
}

/* Gated on a real pointer: on touch, :hover latches after a tap and would
   leave one card inverted with no way to clear it. */
@media (hover: hover) {
  .service-card:hover {
    --color-heading: var(--color-on-accent);
    --color-index: var(--color-accent-on-dark);
    color: var(--color-on-accent);
    transition-delay: 90ms;
  }

  .service-card:hover::before {
    transform: scaleY(1);
    will-change: transform;
  }

  .service-card:hover::after { opacity: 1; }
}

.service-card__index {
  display: block;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-label);
  color: var(--color-index);
  /* Top of the card, so it is covered last on the way in and uncovered first
     on the way out. */
  transition: color 1ms linear 0ms;
}

@media (hover: hover) {
  .service-card:hover .service-card__index { transition-delay: 210ms; }
}

.service-card__body p {
  margin-block-start: var(--space-xs);
  font-size: var(--text-sm);
  line-height: 1.55;
}

/* --------------------------------------------------------------------------
   11. About
   -------------------------------------------------------------------------- */

.about__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-xl);
}

@media (min-width: 62em) {
  .about__grid {
    grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
    gap: var(--space-2xl);
  }
}

/* The heading column pins while the list scrolls past it, and releases as the
   next section pushes in. Two things make it work:

   align-self: start — a grid column stretches to the row's full height by
   default, and a sticky element that already fills its container has nowhere
   to travel. It has to be its natural height first.

   var(--header-h, 0px) — the token only exists where the header is sticky.
   Below that breakpoint the fallback pins it to the top of the viewport
   instead, which is correct there. */
@media (min-width: 62em) {
  .about__intro {
    position: sticky;
    inset-block-start: var(--header-h, 0px);
    align-self: start;
  }
}

.about__intro > p {
  max-inline-size: 42ch;
  line-height: 1.6;
}

/* The rule under the lede is gone with the rest of the hairlines — on black it
   read as a scratch rather than a division, and the panel's own edge is
   already doing the work a rule was there to do. The space it occupied stays. */
.about__intro hr { display: none; }

.about__intro > p:last-child { margin-block-start: var(--space-xl); }

.about__lede { margin-block-start: var(--space-md); }

.about__lede p {
  font-size: var(--text-xl);
  line-height: 1.3;
  letter-spacing: -0.01em;
  color: var(--color-accent-text);
  max-inline-size: 26ch;
}

.pillar-list { list-style: none; }

/* The number/text pair is component-internal and two-dimensional, so it is a
   grid of its own. */
/* No hairline between pillars any more. A 1px #cbc9c9 rule on black is either
   invisible or a scratch, and once the panel gives the list an edge of its
   own the rules were separating things nothing was running together. Spacing
   does the work instead. */
.pillar {
  display: grid;
  grid-template-columns: 3.5rem minmax(0, 1fr);
  /* With the hairlines gone this padding is the only thing separating one
     pillar from the next, so it does the work the rule did — and because it
     applies to both edges, the gap a reader sees is twice the value here.

     48px a side (96px of gap) matches the comp's ~159px row pitch and is right
     on a wide screen, where a pillar is one or two lines tall and sits in a
     column three times the width. It was wrong on a phone: the same 96px
     against pillars 120–220px tall meant the space between two items was most
     of an item, and the list stopped reading as a list — you scrolled through
     four separate things instead of down one set. Held fluid rather than
     stepped at a breakpoint so nothing jumps: 18px a side at 390 (36px of gap,
     4.5x the 8px that separates a pillar's heading from its own text, so the
     grouping is never in question), reaching the comp's 48 at 1440. */
  padding-block: clamp(1.125rem, calc(2.86vw + 0.43rem), 3rem);
}

.pillar-list li:first-child .pillar { padding-block-start: 0; }

.pillar-list li:last-child .pillar { padding-block-end: 0; }

.pillar__number {
  display: block;
  font-size: var(--text-xs);
  font-weight: 700;
  color: var(--color-accent-text);
  padding-block-start: 0.35em;
}

.pillar__content p {
  margin-block-start: var(--space-2xs);
  font-size: var(--text-sm);
  line-height: 1.55;
  max-inline-size: 52ch;
}

/* --------------------------------------------------------------------------
   12. Sector index

   This was an eight-cell grid with 1px gaps letting a hairline show between
   the cells. It was replaced, and the reason is worth recording because it is
   a composition problem rather than a taste one.

   By the time a reader reaches this section the page has already shown them a
   card grid (Services) and hairline-ruled rows twice (the discipline list in
   the hero, the pillars in About). The sector grid was the second card grid
   AND the third ruled treatment — the one module on the page carrying no form
   of its own, which is exactly why it read as a spreadsheet: eight boxes ruled
   into a table is what a table looks like, and "complex fields where clarity
   is the hard part" is not a data set.

   What replaced it is the one thing this page does better than anything else
   and had not yet used for a *set*: type. The sectors run as a single flowing
   index at display scale, wrapping like a paragraph, with a small accent
   square between them. No boxes, no rules, no cells to align.

   It also removes a hover that should never have existed. These are not links.
   The old cells tinted on hover because they looked like they ought to do
   something; the honest version does not offer an affordance it cannot honour.
   -------------------------------------------------------------------------- */

.sector-list {
  /* Eight items at the page default would leave the last one 400ms behind the
     first. The cascade should read as one gesture, not a queue. */
  --reveal-stagger: 55ms;

  list-style: none;
  display: flex;
  flex-wrap: wrap;
  /* Baseline, not centre: the names sit on a shared line the way set type
     does, so a one-word item and a two-word item read as the same register. */
  align-items: baseline;
  /* The gap has to hold the separator and its air on both sides, because the
     square is positioned into it rather than laid out in it — see below. */
  column-gap: 0.82em;
  /* Uppercase has no descenders, so the rhythm can close right up. */
  row-gap: 0.14em;

  font-family: var(--font-display);
  font-weight: 700;
  text-transform: uppercase;
  font-size: var(--size-sector);
  line-height: 1;
  /* Positive now, and the tracking is doing as much work as the size drop.
     At 46px these were set tight like display type, which is what made them
     read as a heading. Caps at 32px want opening, not closing, and opened caps
     read as an index or a label rather than as a line of display — the same
     move the kickers make at 11px with 0.2em. It is a change of register, not
     just of scale. */
  letter-spacing: 0.06em;
  color: var(--color-text);
}

.sector-list li {
  position: relative;
  display: inline-flex;
  align-items: center;
  /* A long item must be able to wrap inside itself rather than force the row
     wider than the page. "PROFESSIONAL SERVICES" is the binding case at
     390px, where it lands within about 20px of the measure. */
  min-inline-size: 0;
  max-inline-size: 100%;
}

/* The separator, and the one detail in this section worth arguing about.

   It is an empty pseudo-element rather than a middot character, so nothing is
   added to the text — no screen reader announces a bullet between every
   sector — and a square reads as a mark set by the same hand as the grotesk
   rather than as punctuation borrowed from body copy.

   It leads every item — including the first — and it is positioned out of
   flow rather than laid out as a flex child. Both of those are about the rag.
   A separator laid out after each word leaves a mark dangling at the end of
   every wrapped line — two of them at 1440 — which reads as an accident. A
   separator laid out before each word just moves the problem to the start of
   the line. Taking it out of flow means it never participates in wrapping at
   all: between two names it sits in the gap, and at the start of a line it
   hangs into the margin, which is the oldest fix in typesetting for exactly
   this and looks deliberate because it is. The hanging marks also line up
   into a short red column down the left edge, which the section had no
   vertical accent for otherwise.

   Marking every item rather than only the ones with something before them is
   the same argument one step further. At 390px seven of the eight names start
   their own line, so a separator that skips the first leaves one entry
   unmarked in a column of seven that are — which reads as a misalignment, not
   as a rule being followed. Marking all eight turns the square from a
   separator into an index mark: consistent at every width, and at desktop it
   still falls between the names exactly where a separator would.

   Rendered all four ways and compared before choosing. The hanging version
   also stays inside the section padding at every width, so nothing overflows:
   0.51em of offset against a 1.5rem floor on --section-pad-inline. */
.sector-list li:not(:first-child)::before {
  content: "";
  position: absolute;
  inset-inline-end: calc(100% + 0.31em);
  /* Centred on the letterform, not the box. Caps have no descender, so the
     text box's optical centre sits about 0.07em above its geometric one —
     expressed as an offset from 50% rather than a fixed top value, so it
     holds at every size the clamp produces. */
  inset-block-start: 50%;
  transform: translateY(calc(-50% - 0.07em));
  inline-size: 0.2em;
  aspect-ratio: 1;
  background: var(--color-accent);
}

/* And nothing hangs in front of a row. Two turns of this design were spent
   trying to keep separators off the ends of wrapped lines with selectors
   alone, which cannot be done: CSS can see the first item in a list, never the
   first item on a line. Now that the script reads the rows back off the layout
   it can say so, and the answer is one line.

   Without the script the marks fall back to sitting between every pair, which
   puts one in front of each row start — the state this shipped in before, and
   a perfectly readable one. */
.sector-list li[data-row-first]::before { content: none; }

/* The closing item is a caveat on the list, not another sector — it is always
   last by definition, so position is the honest selector for it. Muted at
   display scale reads as an aside without needing a different size. */
.sector-list li:last-child { color: var(--color-muted); }

/* --------------------------------------------------------------------------
   12a. The index in motion

   The three rows arrive from alternating sides — right, left, right — and all
   three land flush on the same left edge as everything else in the section.

   Which names sit on which row is a wrapping outcome, not something authored,
   so it is read back off the layout: the script writes a data-row attribute
   after asking the browser where it actually put each name, and the stylesheet
   keys direction and delay off that. Same approach as the headline's line
   masks, minus the DOM surgery, because a <ul> may only parent <li> and
   wrapping rows in spans would cost the list its semantics for nothing — an
   identical transform on every item in a row is indistinguishable from a
   transform on the row.

   Gated to the width where three rows exist at all. Below it the names stack
   into six or seven and alternating slides would read as damage, so they take
   the page's ordinary reveal instead.

   The comp also parked the rows at three different horizontal offsets, with
   the middle one running out to the viewport edge. That was built and then
   taken back out: the motion was identical either way, but it finished
   somewhere unresolved, and a composition that ends misaligned asks the reader
   to decide whether it is deliberate. Landing on the common left edge answers
   the question. It also retired the apparatus those offsets needed — a
   viewport-aware cap to stop the top row's last name being pushed off the
   right edge at any width narrower than the 1668px frame the comp was drawn
   on. Roughly twenty lines of guard existed to protect a resting state that
   turned out not to be worth protecting. */

@media (min-width: 62em) {
  /* ---- Entry -------------------------------------------------------------
     Each row arrives from the side it is weighted toward: the top row travels
     right to left, the middle row left to right, the bottom row right to left
     again. The alternation is what makes the block read as one gesture rather
     than three separate slides.

     THE ROWS NOW LAND FLUSH LEFT. An earlier pass carried the comp's resting
     offsets — the rows parked at +5.1em, -5.67em and +1.15em, with the middle
     one reaching the viewport edge — and the motion was the same, but it
     finished somewhere unresolved. Flush left gives the movement a destination
     it obviously arrived at: the same edge the kicker, the heading and the
     closing note all sit on. The stagger stays interesting; the resting state
     stops being. It also retires the whole --row-room apparatus, which existed
     only to stop those resting offsets pushing a name off the right edge at
     widths narrower than the 1668px frame the comp was drawn on.

     A second easing curve, and the only one besides the scrubbed timelines in
     §16 that is not the house cubic-bezier(0.25, 1, 0.5, 1). It is earned:
     the house curve is tuned for the 2-16px moves a hover makes, and over the
     200px these rows travel it still has visible speed left when it arrives.
     An expo-out spends that speed early and lands, which is what a piece of
     type this size has to do — anything still moving at 46px reads as
     unfinished. */

  .js .sector-list li[data-reveal] {
    --row-travel: 4.5em;

    opacity: 0;
    transform: translateX(var(--row-enter, 4.5em));
    transition:
      transform var(--duration-glide) var(--ease-glide),
      opacity var(--duration-base) linear;
    transition-delay: var(--row-delay, 0ms);
  }

  .js .sector-list li[data-reveal][data-revealed] {
    opacity: 1;
    transform: translateX(0);
  }

  .sector-list li[data-row="1"] { --row-enter: var(--row-travel); --row-delay: 0ms; }
  .sector-list li[data-row="2"] { --row-enter: calc(-1 * var(--row-travel)); --row-delay: 130ms; }
  .sector-list li[data-row="3"] { --row-enter: var(--row-travel); --row-delay: 260ms; }
  .sector-list li[data-row="4"] { --row-enter: calc(-1 * var(--row-travel)); --row-delay: 390ms; }
}

/* The note that closes the section. It had a 24px top margin until a rewrite
   of the block above swallowed the rule, and it has been sitting flush against
   the index ever since — 17px body copy with zero clearance under 46px display
   type, which reads as a caption stuck to the last row rather than as the line
   that ends the section. Restored, and given more than it had: the section
   lost its support column, so the note now needs to hold that side of the
   composition on its own. */
.band--sectors > p { margin-block-start: clamp(2rem, 4vw, 3.5rem); }

/* --------------------------------------------------------------------------
   13. Contact and footer
   -------------------------------------------------------------------------- */

.contact__grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-xl);
  align-items: end;
}

/* 1.7fr against 1fr with a wide gap, measured off the comp: a 639px headline
   column, 142px of gap and a 378px action column out of 1144. The ratio is
   what makes "Let's build something well." break across three lines instead of
   two — at 100px, three lines is a closing statement and two is a sentence. */
@media (min-width: 62em) {
  .contact__grid {
    grid-template-columns: minmax(0, 1.7fr) minmax(0, 1fr);
    gap: clamp(2rem, 6vw, 6rem);
  }
}

.contact__action p {
  font-size: var(--text-md);
  line-height: 1.5;
  margin-block-end: var(--space-md);
}

/* --------------------------------------------------------------------------
   13a. The closing wordmark

   The logo set to the full width of the viewport, in white, at the foot of the
   red. In the comp it measures 1665 × 288 against a 1668px frame — the file's
   own 286.2 : 49.5 ratio at exactly 100% of the frame — so it is not "large",
   it is the width of the page, and the size is a consequence of that.

   It breaks back out of the band's own bleed padding by the same maths the
   band used to come in, which is why the margin is the band's padding
   negated rather than a number.

   alt="" and no link: the wordmark is already announced once in the header,
   where it is the home link. Announcing it again at the foot of the page adds
   a second "Wellborn Studio home" to the tab order and tells a screen reader
   nothing new. Here it is a piece of the surface, not a control. */
.contact__mark {
  display: block;
  margin-block-start: clamp(3rem, 7vw, 7.5rem);
  margin-inline: calc(50% - 50cqw);
  flex: none;
}

.contact__mark img {
  display: block;
  inline-size: 100%;
  block-size: auto;
  /* Reserve the box from the width before the file lands: at full-bleed the
     logo is ~288px tall, and letting that arrive late would jump the whole
     closing panel. */
  aspect-ratio: 286.2 / 49.5;
}

/* The footer is the foot of the closing red panel, not a separate grey strip.
   It stays a sibling of <main> rather than moving inside the contact section:
   per ARIA in HTML a <footer> maps to contentinfo only when it is not a
   descendant of article, aside, main, nav or section, so nesting it would cost
   the page its contentinfo landmark — and a site-wide copyright does not
   belong inside a section about contacting the studio anyway.

   Its flat colour is the value the panel's gradient has already reached at its
   own bottom edge, so the two meet with no seam. Same full-bleed maths as the
   band above it; its containing block is .page, which is the same width as
   main, so the percentages resolve identically. */
.site-footer {
  --focus-ring: #ffffff;
  --focus-halo: #5e1206;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm) var(--space-md);
  background: var(--color-accent-strong);
  color: var(--color-on-accent);
  margin-inline: calc(50% - 50cqw);
  padding-inline: calc(50cqw - 50% + var(--section-pad-inline));
  padding-block: var(--space-md);
  min-block-size: var(--footer-h);
}

/* 12px white on #bf250d is 6.0:1 — the fine print stays comfortably readable,
   with a full step more headroom than it had on the old red. */
.site-footer p {
  font-size: 0.75rem;
  color: var(--color-on-accent);
}

/* --------------------------------------------------------------------------
   14. Motion — scroll reveal
   Content is visible by default. The pre-animation state is gated behind the
   `.js` class set in <head>, so a failed, blocked, late, or disabled script
   leaves every element in its final readable state.

   Both hooks are attributes rather than classes, so the script never writes a
   class onto a heading or paragraph either.
   -------------------------------------------------------------------------- */

[data-reveal] {
  opacity: 1;
  transform: none;
}

.js [data-reveal] {
  opacity: 0;
  transform: translate3d(0, var(--reveal-distance), 0);
  transition:
    opacity var(--duration-reveal) var(--ease-out),
    transform var(--duration-reveal) var(--ease-out);
  transition-delay: var(--reveal-delay, 0ms);
}

.js [data-reveal][data-revealed] {
  opacity: 1;
  transform: translate3d(0, 0, 0);
}

/* --------------------------------------------------------------------------
   14a. Sections ease up

   Each band lifts into place as one piece when it is scrolled to. What stays
   animating inside a band is only the choreography that carries meaning — the
   headline's line masks, the four cards arriving one at a time, the sector
   rows coming in from alternating sides. The generic fade-and-lift that used
   to sit on section heads, the hero lede and the About intro is gone: once the
   section itself moves, an element lifting inside a lifting section is two
   moves that describe the same event, and they blur each other.

   TRANSFORM ONLY, NO OPACITY, and that is not a style preference. Above
   1200x704 every .band already has its opacity driven by the scroll-linked
   cross-fade in §16, and a running animation beats a transition on the same
   property — a fade declared here would simply be discarded at desktop and
   would work everywhere else, which is worse than not having it. Lifting is
   the one channel that is free.

   The resting state is `none` rather than a zeroed translate, because any
   transform other than none makes the band a containing block, and the About
   column's sticky positioning has to live inside one of these. It interpolates
   from `none` the same way it would from a zero, so nothing is lost.

   The closing panel is deliberately absent: it has the takeover in §13b, and
   two entrances would fight. */

.js .band[data-reveal] {
  opacity: 1;
  transform: translate3d(0, var(--band-rise), 0);
  transition: transform var(--duration-band) var(--ease-out);
}

.js .band[data-reveal][data-revealed] { transform: none; }

/* Stagger. Items inside a group enter in sequence so the set reads as related,
   capped at five steps — the last card in a long group should never wait on
   the ones ahead of it. A group can retune the interval by redeclaring
   --reveal-stagger on itself. */
[data-reveal-group] > *:nth-child(2) { --reveal-delay: calc(1 * var(--reveal-stagger)); }
[data-reveal-group] > *:nth-child(3) { --reveal-delay: calc(2 * var(--reveal-stagger)); }
[data-reveal-group] > *:nth-child(4) { --reveal-delay: calc(3 * var(--reveal-stagger)); }
[data-reveal-group] > *:nth-child(5) { --reveal-delay: calc(4 * var(--reveal-stagger)); }
[data-reveal-group] > *:nth-child(n + 6) { --reveal-delay: calc(5 * var(--reveal-stagger)); }

/* --------------------------------------------------------------------------
   15. Coarse pointers — comfortable targets without changing pointer layouts
   -------------------------------------------------------------------------- */

@media (pointer: coarse) {
  .site-nav__link { padding-block: 0.875rem; }

  .site-header__brand { padding-block: 0.625rem; }
}

/* --------------------------------------------------------------------------
   16. Sticky header and scroll behaviour

   Sections fill the screen and compose to the frame. This is a layout
   decision, not motion, so it turns on with available space alone. There is
   no scroll snapping; see the note after this section for why it came out.

   Why the gate is 1200 × 704 and not something rounder: below 1200 the service
   cards drop to two-up and that section measures 869px, taller than many
   laptop viewports. Measured section heights, tallest first:

       1440×900   hero 752   services 635   about 558   sectors 471
       1280×720   hero 712   services 546   about 548   sectors 458
       1024×640   hero 637   services 869  ← exceeds the viewport
        390×844   services 1310, about 1077, sectors 1027  ← all exceed

   Everything that overflows sits outside the gate. And because the gate is
   written in CSS pixels, browser zoom shrinks the viewport and drops the
   framing on its own — which is most of what WCAG 1.4.4 needs here, with no
   separate handling.
   -------------------------------------------------------------------------- */

@media (min-width: 75em) and (min-height: 44rem) {
  :root {
    /* The header's tallest child is the 2.75rem CTA plus 0.75rem padding top
       and bottom. Declaring the height as well as the token keeps the two from
       drifting apart, which would misplace every anchor landing, and gives the
       sticky About column something to pin under. */
    --header-h: 4.25rem;
  }

  .site-header {
    position: sticky;
    inset-block-start: 0;
    z-index: 5;
    block-size: var(--header-h);
    transition: transform var(--duration-base) var(--ease-out);
  }

  /* Hidden only where motion is welcome. Chrome that appears and disappears
     unbidden is exactly what a reduced-motion preference is asking to avoid,
     so there it simply stays pinned — the script still reports direction, the
     stylesheet declines to act on it. */
  @media (prefers-reduced-motion: no-preference) {
    .site-header[data-hidden] { transform: translateY(-100%); }
  }

  /* Last, and deliberately: equal specificity to the rule above, so source
     order decides. Without it a keyboard user tabbing from the skip link lands
     on nav links parked off the top of the screen. */
  .site-header:focus-within { transform: none; }

  /* Anchor jumps land below the pinned header. */
  main,
  .band { scroll-margin-block-start: var(--header-h); }

  .band--accent { scroll-margin-block-start: 0; }
}

/* THE SLIDE FRAMING IS GONE, and so, earlier, was the scroll snapping that
   preceded it. Both are worth recording, because between them they were the
   page's whole structural idea for three rounds.

   Snap went first: the full-screen framing was what made the sections read as
   slides — min-height, not magnetism — so the page looked identical at rest
   without it, while snap cost the sticky About column most of its travel, cost
   the header the ability to read scroll direction at all, and made the footer
   unreachable without a snap point of its own.

   The framing itself is what the comp retires. Measured off it, the sections
   are 582, 614, 828, 440 and 985 pixels of ink — every one a different height,
   which is not what a page of 100svh slides can produce. Sizing each section
   to its content and separating them with real space says the same thing the
   framing was reaching for, without the machinery it needed: no component
   growth ceilings, no one-element-per-section slack absorber, no 60rem cap to
   stop a tall display pooling the leftover into the seams. Four rules replaced
   about forty.

   What stays from that work: the sticky header and its direction sensing, the
   sticky About column, the cross-fade below, and smooth anchor scrolling. */

@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}

/* Section cross-fade — the outgoing section dims away as the incoming one
   arrives. A scroll-driven CSS animation, so it runs on the compositor with no
   script and no scroll listener: `view()` gives each section its own timeline
   running from the moment it enters the viewport to the moment it has fully
   left.

   0.85 is not an arbitrary floor. Dimming a section lightens what sits behind
   its text, so each element has a measured opacity below which it stops
   clearing 4.5:1 against its own background:

       body copy    #444141 on white ......... 0.72
       card body    #444141 on #f3f2f2 ....... 0.76
       accent text  #ae1800 on white ......... 0.75
       muted gray   #605d5d on white ......... 0.85   ← binding here
       white text   on the accent band ....... 0.96   ← cannot fade at all

   The muted gray sets the floor for the white sections. The accent band is
   excluded outright: fading it lightens the red while the white text stays
   white, so its contrast collapses before the effect is even visible. It
   arrives without the fade rather than arriving illegible.

   The 0.85 knees sit at 4% and 96% of the transit, not nearer the middle,
   because a section that is 25% visible is only a few percent into its
   timeline and the ramp must have finished by then. Where "a few percent"
   falls is a function of section height against viewport height:

       p = 0.25 x min(H, V) / (V + H)

   and the binding case is the shortest section on the tallest screen. Under
   the old full-screen framing every section was at least 100svh, so the worst
   case was H≈V and the knee could sit at 8%. Retiring the framing made the
   sectors band 440px tall, and on a 1400px viewport that same 25% now arrives
   at 6% — which is how a knee that had been correct for two rounds started
   failing at 0.735 and 3.52:1 the moment the sections were free to be short.

   4% covers it with margin. The deep fade to zero is kept but now applies only
   where a section is a sliver at the very edge of the screen.

   @supports keeps this to browsers with scroll-driven animations. Everywhere
   else the sections simply do not fade, which costs nothing. */

@keyframes band-transit {
  0% { opacity: 0; }
  4% { opacity: 0.85; }
  44% { opacity: 1; }
  56% { opacity: 1; }
  96% { opacity: 0.85; }
  100% { opacity: 0; }
}

@supports (animation-timeline: view()) {
  @media (min-width: 75em) and (min-height: 44rem) and (prefers-reduced-motion: no-preference) {
    .band {
      animation: band-transit linear both;
      animation-timeline: view();
    }

    /* The accent band is excluded from the cross-fade: white text over a
       lightening red has no headroom to fade (see the 0.96 floor above). It
       runs its own entry instead, and not on this timeline — see §13b. */
    .band--accent { animation: none; }
  }
}

/* --------------------------------------------------------------------------
   13a2. The overlap

   The closing panel does not push the page down to make room for itself. The
   section above it stops, and the red climbs over the top of it.

   It is two rules. The sectors band is made sticky, so once its top reaches
   the viewport top it stays there; the accent band is given a stacking order
   above it, so as the reader keeps scrolling it rides up and covers it. The
   distance the red travels across a stationary section is that section's own
   height — measured at 469 to 520px across every viewport tested, which is a
   long, deliberate climb rather than a flourish.

   Why this is safe to pin: a sticky element taller than the viewport pins with
   its own bottom permanently cut off. The sectors band is 319–520px against
   viewports of 640–1080, at every size tested, so it always fits. That is the
   condition this depends on, and it is worth re-checking if the section ever
   grows.

   Sticky releases at the bottom of its containing block, which is <main> — and
   <main> ends at the bottom of the accent band, so the pin holds for exactly
   as long as the red is climbing and lets go once it has arrived.

   Gated behind a motion preference. Nothing here animates, but two layers
   moving at different speeds is parallax by another name, and parallax is
   precisely what a vestibular preference is asking not to be given. Without it
   the sections simply follow one another, which is what they did before.

   Gated at 62em as well: below that the sticky/dynamic-toolbar interaction on
   mobile Safari is not worth the risk for an effect that reads best on a large
   screen anyway. The takeover still fires there.
   -------------------------------------------------------------------------- */

@media (min-width: 62em) and (prefers-reduced-motion: no-preference) {
  .band--sectors {
    position: sticky;
    inset-block-start: 0;
    z-index: 0;
  }

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

  /* The footer is a sibling of <main>, so it is outside the sticky containing
     block and paints after it — but the pinned band is positioned and it is
     not, which would put a static footer underneath. It never actually
     overlaps, because the pin has released by then; this is belt and braces
     against a stacking bug that would only appear at the very bottom of the
     page. */
  .site-footer { position: relative; z-index: 1; }
}

/* --------------------------------------------------------------------------
   13b. The closing takeover

   The red claims the screen: it arrives at the width of every other section,
   sitting a little low, then snaps out to both edges and up into place.

   WHY THIS IS NO LONGER SCROLL-DRIVEN, which is the whole of the change. It
   used to run on `animation-timeline: view()` over `entry 25% -> entry 100%`,
   and that version could not be made to snap at any setting, because a scrubbed
   animation has no speed of its own — its speed is the reader's scroll speed,
   and every frame is a position rather than a moment. Give it an eased curve
   and it lies about where the reader is (measured once at 72% open while the
   band was still 560px off screen); give it the linear one it needs to tell the
   truth, and it can only ever feel as flat as the scroll driving it. Flat was
   not a tuning problem. It was the mechanism.

   So it is time-based now, fired once by the same IntersectionObserver that
   reveals everything else. That buys back the two things a scrubbed timeline
   cannot have: a duration and a curve.

   clip-path, not margin or width: those are layout properties and animating
   them would reflow the heading every frame. Clipping leaves the box where it
   is and changes only what is painted, so the panel takes over around type that
   is already in position.

   The starting inset is the distance from the viewport edge to the page's
   content edge, so frame one lines up precisely with the white bands above it:
   104px at 1440, 24px once the viewport is narrower than the page. The rise at
   the top is what turns a widening into an arrival. */

.band--accent {
  --bleed-distance: calc(
    (100cqw - min(100cqw, var(--page-max)) + 2 * var(--page-gutter)) / 2
  );
  /* No vertical rise any more. It existed to turn a widening into an arrival,
     and the overlap in §13a2 now does that far better: the panel arrives by
     climbing over the section above it. Clipping its top edge during that
     climb would fight the climb — the leading edge would lag behind its own
     box and then snap, which reads as a rendering fault rather than a move.
     What is left is the horizontal open, which the climb does not provide. */
  --takeover-rise: 0px;
  --duration-takeover: 460ms;
  /* How long the content waits. Just short of the panel's own duration, so it
     begins as the red is settling rather than after it has stopped — two
     beats that touch, not two that are separated by a gap. */
  --takeover-hold: 420ms;

  /* The closing row — the headline and the block beside it — rises much
     further than anything else on the page: 6rem against the 1.5rem an
     ordinary reveal uses. It can afford to, and it needs to. The panel is a
     full screen of flat colour with nothing else happening in it, so a small
     lift has no competing motion to read against, and this is the last thing
     the page does. The wordmark below is deliberately not part of it. */
  --closing-rise: 6rem;
  --duration-closing: 820ms;
}

/* Gated behind .js like every other reveal here: with no script the panel is
   simply itself, full bleed and unclipped.

   data-takeover rather than data-reveal, and not only for the observer's sake:
   the generic reveal would fade and lift the whole panel, and cancelling that
   means overriding the value *and* the transition — the omission that once had
   the headline fading and rising at the same time. A separate hook never
   matches the generic rule, so there is nothing to cancel and nothing to
   forget to cancel. */
.js .band--accent[data-takeover] {
  clip-path: inset(
    var(--takeover-rise) var(--bleed-distance) 0 var(--bleed-distance)
  );
}

.js .band--accent[data-takeover][data-revealed] {
  animation: takeover var(--duration-takeover) var(--ease-glide) both;
}

@keyframes takeover {
  from {
    clip-path: inset(
      var(--takeover-rise) var(--bleed-distance) 0 var(--bleed-distance)
    );
  }
  to { clip-path: inset(0 0 0 0); }
}

/* THE CONTENT WAITS FOR THE PANEL, and the important part is what it waits on.

   These elements carry data-reveal like everything else on the page, so the
   ordinary observer was marking them the moment a tenth of the section showed
   — while the takeover does not fire until 28%. A delay was already in place
   here, but it was measured from the wrong starting gun: the content's own
   trigger, which happens first. The headline was therefore easing up through a
   panel that had not begun to open, and no amount of delay would have fixed
   it, because the two were being timed from different events.

   Keying the children off the *panel's* revealed state instead makes the
   sequence real. The descendant selector outranks the generic reveal rules
   (0,5,0 against 0,3,0), so the ordinary observer can mark these elements as
   early as it likes and nothing moves until the red has taken the screen.

   The panel snaps on an expo-out; the content eases up on the house curve.
   Two different gestures, deliberately — the surface arrives, then the words
   settle onto it. */
.js .band--accent[data-takeover] [data-reveal] {
  opacity: 0;
  transform: translate3d(0, var(--rise, var(--reveal-distance)), 0);
}

.js .band--accent[data-takeover][data-revealed] [data-reveal] {
  opacity: 1;
  transform: translate3d(0, 0, 0);
  transition:
    opacity var(--duration-reveal) var(--ease-out),
    transform var(--rise-duration, var(--duration-reveal)) var(--rise-ease, var(--ease-out));
  transition-delay: calc(var(--takeover-hold) + var(--reveal-delay, 0ms));
}

/* The closing row gets the exaggerated version. Set on the row rather than on
   each child, so the two travel together — and because custom properties
   inherit, which keeps the wordmark below (a sibling, not a descendant) out of
   it entirely.

   --ease-glide, the same expo-out the sector rows use, and for the same stated
   reason: it is the curve for travel long enough that the house curve would
   still have visible speed left on arrival. 6rem qualifies; 1.5rem did not,
   which is why this sat on --ease-out until now. */
.js .band--accent[data-takeover] .contact__grid {
  --rise: var(--closing-rise);
  --rise-duration: var(--duration-closing);
  --rise-ease: var(--ease-glide);
}

/* The wordmark still closes the sequence, and still only eases. It is 288px
   tall and full-bleed: at that size a 96px lift would read as the page
   shifting rather than as an element arriving, and it is the one thing here
   that should look placed rather than thrown. */
.js .band--accent[data-takeover] .contact__mark { --reveal-delay: 220ms; }

/* A scroll-driven animation resolves against a scroll position. Anything that
   renders the whole document without scrolling through it — printing, PDF
   export, full-page screenshot tools — leaves every off-screen section frozen
   at opacity 0 and prints blank pages. Found exactly that way. */
@media print {
  .band {
    animation: none !important;
    opacity: 1 !important;
    min-block-size: 0 !important;
  }

  .site-header {
    position: static !important;
    -webkit-backdrop-filter: none !important;
    backdrop-filter: none !important;
  }

  /* Same trap as the bands: a headline parked below its mask prints as an
     empty box the height of the hero. */
  .hero__line-inner { transform: none !important; }
}

/* --------------------------------------------------------------------------
   17. Reduced motion
   Everything above that moves is decoration. None of it carries meaning that
   is lost when it stops, which is the test for whether a blanket override is
   honest — here it is.
   -------------------------------------------------------------------------- */

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

  .js [data-reveal],
  /* The closing panel's contents are held by a rule specific enough to outrank
     the line above, so they have to be named here or a reduced-motion reader
     could be left looking at an empty red screen. */
  .js .band--accent[data-takeover] [data-reveal] {
    opacity: 1;
    transform: none;
  }

  /* The script does not split the headline under this preference, so these
     elements normally do not exist. The rule is here for the one path that
     can still produce them: the preference being switched on mid-session,
     after a split has already happened. */
  .js [data-lines] .hero__line-inner { transform: none; }
}
