/* ==========================================================================
   Steel Point Engineering — tokens, base, components
   Loaded AFTER tailwind.css so these rules win where they overlap.
   ========================================================================== */

/* Everything below sits in Tailwind's `components` layer, so utility classes
   (hidden, md:inline-flex, text-white …) still win where the two overlap.
   Unlayered CSS beats every layer regardless of source order — which would
   otherwise break every responsive show/hide on the page. */
@layer components {
  :root {
    /* Base — LIGHT-FIRST. The page is a run of near-white bands, bookended by
       dark hero / trust / final-CTA / footer, with one solid-blue CTA band.
       Component CSS consumes these names, so the whole site re-themes by
       swapping values here — no component rewrite. `.theme-dark` (further
       down) restores the dark palette for the bookend sections. */
    --spe-black: #f2f5f8; /* band A — page + default section background */
    --spe-carbon: #ffffff; /* band B — alternating section background */
    --spe-graphite: #ffffff; /* cards, inputs (on band A) */
    --spe-graphite-2: #f4f7fa; /* card hover */

    /* Ink — was "brushed steel", now the type ramp on light */
    --spe-steel-light: #0d1319; /* headings — 17.6:1 on band A */
    --spe-steel: #3c4551; /* body copy — 9.1:1 */
    --spe-steel-dark: #5a6573; /* muted text, labels — 5.1:1 */

    /* Industrial blue — the logo accent, unchanged brand hue */
    --spe-blue: #1566c8; /* button fills, solid CTA band (5.6:1 vs white) */
    --spe-blue-bright: #1257a8; /* links, eyebrows, focus — 7.4:1 on light */
    --spe-blue-deep: #0c3f82; /* gradient ends, pressed */

    /* Dark surfaces (the bookends reference these directly) */
    --spe-white: #f7f9fb;
    --spe-line: rgba(13, 19, 25, 0.13); /* hairline borders */
    --spe-line-strong: rgba(13, 19, 25, 0.24); /* visible borders, dashed edges */
    --spe-fill-subtle: rgba(13, 19, 25, 0.09); /* rails, tracks, inactive dots */
    --spe-inset-hi: rgba(13, 19, 25, 0.05); /* milled top edge on cards */

    /* Form control borders are UI, not decoration: WCAG 1.4.11 wants 3:1, and
       the 0.13 hairline manages 1.2:1 on white. #8A929C is 3.15:1. */
    --spe-input-border: #8a929c;

    /* Validation. Amber is illegible on white, and the previous #B26A12 was
       still short of AA for 14px body text: 4.23:1 on the white card surface,
       3.87:1 on band A. #8F5310 measures 6.15:1 on white, 5.62:1 on band A and
       4.82:1 over the most heavily scrimmed photo band (#quote, --veil 0.93).
       Must stay in step with --color-spe-amber in src/tailwind.css. */
    --spe-amber: #8f5310;

    /* Focus indicator colour. Painted as an INSET outline (see the
       :focus-visible block below) because clip-path discards anything a
       chamfered element paints outside its cut. It tracks --spe-blue-bright, so
       it re-maps with the band the control sits in and needs no per-band rule. */
    --spe-focus-ring: var(--spe-blue-bright);

    /* Signature gradients. Brushed steel reads as mud on white, so the
       display gradient is graphite here and steel again inside .theme-dark. */
    --grad-steel: linear-gradient(
      160deg,
      #1b2530 0%,
      #263140 26%,
      #4a5666 55%,
      #1d2733 78%,
      #3b4655 100%
    );
    /* Lightest stop is #1A6ED0 (5.0:1 with white) so the button label holds AA
       across the whole sweep — #2B85E8 only managed 3.6:1. */
    --grad-blue: linear-gradient(135deg, #1a6ed0 0%, #1566c8 55%, #0c3f82 100%);
    /* `--grad-edge` was declared here and re-mapped in `.band-dark`. Its only
       consumer was `.section-edge::before`, removed with §5.4 item 16, so both
       halves went with it rather than leaving a token nothing reads. */

    /* Geometry — hard, never soft. Steel, not SaaS. */
    --chamfer: 12px;
    --radius-card: 4px;
    --radius-btn: 2px;

    /* Motion */
    --ease-reveal: cubic-bezier(0.16, 1, 0.3, 1);
    --ease-hover: cubic-bezier(0.4, 0, 0.2, 1);
    --dur-reveal: 600ms;
    --dur-hover: 200ms;

    /* Rhythm */
    --header-h: 88px;
    --header-h-scrolled: 64px;
    --section-pad: 64px;
    --gutter: 24px;
  }

  /* Band rhythm. The reference runs py-16 / sm:py-20 / lg:py-28, but its
     sections are denser than ours — our headings already carry a 48px mt-12
     beneath them, so 112px read as a void. Capped at 96px. */
  @media (min-width: 640px) {
    :root {
      --section-pad: 76px;
      --gutter: 32px;
    }
  }

  @media (min-width: 1024px) {
    :root {
      --section-pad: 96px;
      --gutter: 40px;
    }
  }

  /* --------------------------------------------------------------------------
     Base
     -------------------------------------------------------------------------- */

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

  /* `overflow-x` on body alone did not stop the page scrolling sideways: the
     viewport's scroll container is <html>, so body only clipped the paint.
     Measured at 375px, documentElement.scrollWidth was 379 while a slide-left /
     slide-right reveal held its 28px pre-reveal offset — a real 4px sideways
     scroll on a phone, on every page that uses those two reveals.
     `clip` rather than `hidden` deliberately: `overflow: hidden` on <html>
     would make the sticky header stop sticking. */
  html {
    scroll-behavior: smooth;
    -webkit-text-size-adjust: 100%;
    overflow-x: clip;
  }

  body {
    margin: 0;
    background: var(--spe-black);
    color: var(--spe-steel);
    font-family: "Inter", ui-sans-serif, system-ui, sans-serif;
    font-size: 16px;
    line-height: 1.65;
    font-weight: 400;
    -webkit-font-smoothing: antialiased;
    overflow-x: hidden;
  }

  @media (min-width: 768px) {
    body {
      font-size: 17px;
    }
  }

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

  h1,
  h2,
  h3,
  h4 {
    font-family: "Saira", ui-sans-serif, system-ui, sans-serif;
    color: var(--spe-steel-light);
    letter-spacing: -0.01em;
    line-height: 1.05;
    margin: 0;
    text-wrap: balance;
  }

  h1,
  h2 {
    font-weight: 700;
    text-transform: uppercase;
  }

  h1 {
    font-size: clamp(32px, 7.6vw, 68px);
  }
  h2 {
    font-size: clamp(28px, 4.6vw, 46px);
  }
  h3 {
    font-weight: 600;
    font-size: clamp(18px, 2vw, 21px);
    line-height: 1.25;
    letter-spacing: 0;
  }

  p {
    margin: 0;
  }

  a {
    color: var(--spe-blue-bright);
    text-decoration: none;
  }

  /* Focus — never remove without a replacement (WCAG 2.4.7) */
  :focus-visible {
    outline: 2px solid var(--spe-blue-bright);
    outline-offset: 2px;
  }

  /* B1 — chamfered controls used to clip their own focus ring away. clip-path
     discards EVERYTHING the element paints outside the cut, outline included,
     and `outline-offset: 2px` puts the ring wholly outside the border box — so
     the indicator was painted and then thrown away on the CTA, the filter
     chips, the gallery tiles, all 14 radio cards, the dropzone and the file
     chips. WCAG 2.4.7 across the whole conversion path.

     A NEGATIVE outline-offset moves the ring inside the border box, where the
     clip-path keeps it: 3px wide at -3px offset sits flush against the edge and
     only the two chamfered corners nibble it, which reads as the ring following
     the chamfer. `outline` is used nowhere else on these components, so unlike
     an inset box-shadow it never loses a cascade fight with a band override or
     a checked/error state (both of which set box-shadow on the same elements).

     .icon-btn, .dot-hit, .nav-link and the form fields carry no clip-path and
     keep the offset outline above.

     `.file-chip:focus-visible` and `.step-node:focus-visible` were in this list
     and have been removed (§5.4 item 16): neither element can ever match it.
     `.file-chip` is a <div> built by form.js — its only control is the nested
     `.icon-btn` remove button, which takes the unclipped ring above — and
     `.step-node` is a <span aria-hidden="true">. Nothing focusable is losing a
     ring here; two selectors that could never fire are. */
  .btn:focus-visible,
  .chip:focus-visible,
  .tile:focus-visible,
  .dropzone:focus-visible,
  /* The labour-hire role chips are <label class="chip"> wrapping a visually
     hidden checkbox. A <label> is not focusable and the input is 1x1 at
     opacity 0, so `.chip:focus-visible` above can never match them — they need
     the same :has() treatment as .radio-card or six controls have no ring. */
  .chip:has(input:focus-visible),
  .radio-card:has(input:focus-visible) {
    outline: 3px solid var(--spe-focus-ring);
    outline-offset: -3px;
  }

  /* The gradient fill would swallow a blue ring, so the primary button flips to
     white — 5.0:1 against the lightest stop in --grad-blue, 10.3:1 at the deep
     end. On the accent stripe .btn-primary is a white button, so it flips back. */
  .btn-primary:focus-visible {
    --spe-focus-ring: #ffffff;
  }
  .band-accent .btn-primary:focus-visible {
    --spe-focus-ring: var(--spe-blue-deep);
  }

  /* The same problem one state further on, and it was the last of the focus
     work left open (WORK-LOG §5.4 item 15). A CHECKED chip or radio card
     already carries `inset 0 0 0 2px var(--spe-blue-bright)`. Focusing it drew
     `outline: 3px solid var(--spe-blue-bright)` at -3px offset — the same
     colour, one pixel wider, painted directly over the ring it is meant to be
     distinguishable from. Across 6 filter chips, 6 labour-hire role chips and
     14 radio cards, every one of them on the conversion path, a keyboard user
     could not tell which selected option they were on.

     Flipping the ring to the band's heading ink fixes it in both palettes
     without a second literal: #0d1319 on the light bands (≈17:1 on the pale
     checked fill, and unmistakable against the blue ring it covers), #ffffff
     inside .theme-dark / .band-dark. The outline paints above the box-shadow,
     so the checked ring is fully replaced while focus is on the control rather
     than being fringed by it.

     `input:checked:focus-visible` rather than two chained :has() — the chip and
     the radio card each wrap exactly one input, so it is the same element. */
  .chip[aria-pressed="true"]:focus-visible,
  .chip.is-active:focus-visible,
  .chip:has(input:checked:focus-visible),
  .radio-card:has(input:checked:focus-visible) {
    --spe-focus-ring: var(--spe-steel-light);
  }

  /* Tiles sit on arbitrary photography, where the ring alone can land on a
     pixel of its own luminance. A white band just inside it (the outline paints
     over the shadow) guarantees the pair reads whatever the photo is doing. */
  .tile:focus-visible {
    box-shadow: inset 0 0 0 6px #fff;
  }

  /* Service cards focus the heading button inside them, which sits well clear
     of the card's chamfer and keeps the outline above; the card-level ring goes
     on .gcard-inner, because .gcard is a 1px border wrapper whose opaque child
     would paint over anything drawn on it. */
  .gcard:has(:focus-visible) > .gcard-inner {
    outline: 3px solid var(--spe-focus-ring);
    outline-offset: -3px;
  }

  ::selection {
    background: var(--spe-blue);
    color: #fff;
  }

  /* Found while closing item 14, not previously recorded. The accent stripe
     paints itself `var(--spe-blue)` and does not re-map that token, so the rule
     above gave selected text on that band a #1566C8 highlight on a #1566C8
     field with the white ink it already had — selecting the final CTA's copy
     did nothing visible at all. Blue-deep is 3.0:1 against the band, so the
     highlight has a visible edge, and white on it is 11.1:1. */
  .band-accent ::selection {
    background: var(--spe-blue-deep);
    color: #fff;
  }

  /* Anchor targets must clear the sticky header. Scoped to the sections that
     anchors actually point at: on every element with an id it also skewed the
     quote form's scrollIntoView({ block: "center" }) on the first invalid
     field. By the time an anchor lands the header has shrunk to
     --header-h-scrolled, so sizing this off --header-h left a 32px gap. */
  main > section[id] {
    scroll-margin-top: calc(var(--header-h-scrolled) + 8px);
  }

  .skip-link {
    position: absolute;
    left: 16px;
    top: -100px;
    z-index: 200;
    background: var(--spe-blue);
    color: #fff;
    padding: 12px 20px;
    font-weight: 600;
    transition: top 150ms ease;
  }
  .skip-link:focus {
    top: 16px;
  }

  /* Brushed-metal / fine-noise overlay. Must never read as a pattern.
     The noise is dark, so at the old 0.05 it visibly greys out the white bands
     that now make up most of the page. Dropped to 0.02, which still breaks up
     the flat fills without dirtying them. The dark bookends carry their own
     photographic texture, so they lose nothing by it.

     p6 — z-index 60 is deliberate, not a mistake: it sits above the content but
     below the header (100) and the sticky mobile CTA (90), so the two chrome
     elements stay crisp while the page body carries the texture. */
  .grain {
    position: fixed;
    inset: 0;
    z-index: 60;
    pointer-events: none;
    opacity: 0.02;
    background-image: url("../assets/images/noise.svg");
    background-size: 180px 180px;
  }

  /* --------------------------------------------------------------------------
     Layout
     -------------------------------------------------------------------------- */

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

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

  /* --------------------------------------------------------------------------
     Bands
     The page is a run of alternating near-white bands (.band / .band-alt),
     bookended by dark sections (.band-dark) and broken once by a solid-blue
     conversion band (.band-accent). `.band-dark` and `.band-accent` re-map the
     brand tokens rather than restyling components, so cards, chips, inputs and
     icons adapt on their own inside them.

     `.bg-black-sec` / `.bg-carbon-sec` were kept here as aliases for the
     previous band names. Nothing in any template or built page used either of
     them, in this file or anywhere else, so they went with §5.4 item 16 — the
     band classes are `.band` and `.band-alt`. `replit.md` said the aliases
     survived; it has been corrected.
     -------------------------------------------------------------------------- */

  .band {
    background: var(--spe-black);
  }

  /* Band B is pure white, so a white card surface would be invisible on it.
     Cards, inputs and open FAQ rows pick up a faint tint here instead — the
     inverse of how they sit on band A. */
  .band-alt {
    background: var(--spe-carbon);
    --spe-graphite: #f4f7fa;
    --spe-graphite-2: #edf2f7;
    --color-spe-graphite: #f4f7fa;
    --color-spe-graphite-2: #edf2f7;
  }

  /* Two more unused components removed with §5.4 item 16, recorded so nobody
     hunts for them: `.section-edge::before`, a one-pixel blue hairline meant to
     sit at the top of a section, and `.band-seam`, a hairline for where `.band`
     meets `.band`. Neither class is on any element on any of the 28 pages, and
     the band rhythm reads without them because no two same-coloured bands are
     ever adjacent. `--grad-edge` existed only to fill `.section-edge::before`,
     so it went too — both the `:root` value and the `.band-dark` re-map. */

  /* ---- The solid conversion band. The reference's single full-bleed accent
     stripe, in Steel Point blue instead of red. White on #1566C8 is 5.6:1. --- */
  .band-accent {
    --spe-black: #1566c8;
    --spe-carbon: #1566c8;
    --spe-graphite: rgba(255, 255, 255, 0.12);
    --spe-graphite-2: rgba(255, 255, 255, 0.18);
    --spe-steel-light: #ffffff;
    --spe-steel: rgba(255, 255, 255, 0.92);
    --spe-steel-dark: rgba(255, 255, 255, 0.78);
    --spe-blue-bright: #ffffff;
    --spe-line: rgba(255, 255, 255, 0.28);
    --spe-line-strong: rgba(255, 255, 255, 0.55);
    --spe-input-border: rgba(255, 255, 255, 0.6);
    --spe-fill-subtle: rgba(255, 255, 255, 0.22);
    --spe-inset-hi: rgba(255, 255, 255, 0.12);
    --grad-steel: linear-gradient(160deg, #ffffff 0%, #dbe8f8 60%, #ffffff 100%);

    --color-spe-steel-light: #ffffff;
    --color-spe-steel: rgba(255, 255, 255, 0.92);
    --color-spe-steel-dark: rgba(255, 255, 255, 0.78);
    --color-spe-blue-bright: #ffffff;
    --color-spe-line: rgba(255, 255, 255, 0.28);
    /* p5 — every --spe-* this band re-maps needs its --color-spe-* twin, or the
       first utility that reads one renders the light palette on solid blue.
       These four had no twin: bg-spe-black / bg-spe-carbon would have painted a
       near-white block, bg-spe-graphite an opaque white card. */
    --color-spe-black: #1566c8;
    --color-spe-carbon: #1566c8;
    --color-spe-graphite: rgba(255, 255, 255, 0.12);
    --color-spe-graphite-2: rgba(255, 255, 255, 0.18);
    /* The dark amber is 1.10:1 on solid blue. Nothing on this band uses it
       today, but a warning or a PLACEHOLDER marker landing here would be
       invisible; #FFE8C4 is 4.67:1 and still reads as amber. */
    --spe-amber: #ffe8c4;
    --color-spe-amber: #ffe8c4;

    /* WORK-LOG §5.4 item 14. These are the last three of the 13 @theme names
       this block did not mention. There was never any drift — neither token set
       re-mapped them — but "consistent because both halves are absent" is not
       the same guarantee as `.band-dark`, which lists all 13 explicitly, and the
       difference is invisible to anyone auditing the two blocks side by side.
       Values are :root's, restated:

       --spe-blue is the band's own background (`background: var(--spe-blue)`
       below), so it deliberately does NOT flip to white the way blue-bright
       does — a nested `.band-accent` inside `.band-accent` has to keep painting
       the same blue. --spe-blue-deep must stay dark: it is the focus ring on
       this band's white .btn-primary, twenty lines up. */
    --color-spe-blue: #1566c8;
    --color-spe-blue-deep: #0c3f82;
    --color-spe-white: #f7f9fb;

    background: var(--spe-blue);
    color: var(--spe-steel);
  }

  /* On blue, the gradient-fill primary button disappears. Invert it: white
     fill, blue label. */
  .band-accent .btn-primary {
    background: #ffffff;
    background-image: none;
    color: var(--spe-blue);
  }
  .band-accent .btn-primary:hover {
    background: #eef4fc;
    box-shadow: 0 8px 30px -10px rgba(0, 0, 0, 0.4);
  }
  .band-accent .btn-secondary {
    color: #ffffff;
    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.55);
  }
  .band-accent .btn-secondary:hover {
    /* Stated explicitly: `.btn-secondary:hover` sits later in the file at the
       same specificity as `.band-accent .btn-secondary`, so it would otherwise
       win on `color` alone and put B2's blue-deep ink on solid blue. */
    color: #ffffff;
    background: rgba(255, 255, 255, 0.12);
    box-shadow: inset 0 0 0 1px #ffffff;
  }
  .band-accent .ico {
    stroke: rgba(255, 255, 255, 0.92);
  }

  .eyebrow {
    font-family: "Saira", ui-sans-serif, system-ui, sans-serif;
    font-weight: 700;
    font-size: 12px;
    line-height: 1.4;
    text-transform: uppercase;
    letter-spacing: 0.22em;
    color: var(--spe-blue-bright);
    margin: 0 0 16px;
  }

  .lead {
    font-size: clamp(16px, 1.6vw, 19px);
    color: var(--spe-steel);
    max-width: 62ch;
  }

  .muted {
    color: var(--spe-steel-dark);
  }

  .hairline {
    height: 1px;
    background: var(--spe-line);
    border: 0;
    margin: 0;
  }

  /* M1 — `background-clip: text` paints the gradient only inside the element's
     own padding box, and `color: transparent` left nothing underneath, so any
     glyph area outside that box was ERASED rather than overflowing: the stat
     numbers were amputated mid-glyph. Two fixes. The ink is now real and the
     gradient is layered over it only where background-clip is supported, so
     text can never go missing; and inline-block + line-height 1.15 + a hair of
     inline padding give the paint box room for brackets, commas and descenders.
     --spe-steel-light is 17.1:1 on band A and 16.9:1 inside .band-dark (where
     the stat bar lives), so the fallback is legible in both palettes. */
  .steel-text {
    display: inline-block;
    line-height: 1.15;
    padding-inline: 0.04em;
    color: var(--spe-steel-light);
  }

  @supports (background-clip: text) or (-webkit-background-clip: text) {
    .steel-text {
      background: var(--grad-steel);
      -webkit-background-clip: text;
      background-clip: text;
      -webkit-text-fill-color: transparent;
      color: transparent;
    }
  }

  /* --------------------------------------------------------------------------
     Chamfers — top-right + bottom-left cut, echoing the angular "E" in the logo
     -------------------------------------------------------------------------- */

  .chamfer {
    clip-path: polygon(
      0 0,
      calc(100% - var(--chamfer)) 0,
      100% var(--chamfer),
      100% 100%,
      var(--chamfer) 100%,
      0 calc(100% - var(--chamfer))
    );
  }

  .chamfer-sm {
    --chamfer: 8px;
    clip-path: polygon(
      0 0,
      calc(100% - var(--chamfer)) 0,
      100% var(--chamfer),
      100% 100%,
      var(--chamfer) 100%,
      0 calc(100% - var(--chamfer))
    );
  }

  /* --------------------------------------------------------------------------
     Buttons — radius 2px, chamfered, no pills anywhere
     -------------------------------------------------------------------------- */

  .btn {
    --chamfer: 12px;
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 10px;
    min-height: 52px;
    padding: 14px 26px;
    font-family: "Saira", ui-sans-serif, system-ui, sans-serif;
    font-weight: 600;
    font-size: 15px;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    border: 0;
    border-radius: var(--radius-btn);
    cursor: pointer;
    text-align: center;
    transition:
      transform var(--dur-hover) var(--ease-hover),
      background-color var(--dur-hover) var(--ease-hover),
      color var(--dur-hover) var(--ease-hover),
      box-shadow var(--dur-hover) var(--ease-hover);
    clip-path: polygon(
      0 0,
      calc(100% - var(--chamfer)) 0,
      100% var(--chamfer),
      100% 100%,
      var(--chamfer) 100%,
      0 calc(100% - var(--chamfer))
    );
  }

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

  .btn-primary {
    background: var(--grad-blue);
    background-size: 160% 160%;
    background-position: 0% 50%;
    color: #fff;
    transition:
      background-position 400ms var(--ease-hover),
      transform var(--dur-hover) var(--ease-hover),
      box-shadow var(--dur-hover) var(--ease-hover);
  }

  .btn-primary:hover {
    background-position: 100% 50%;
    box-shadow: 0 8px 30px -10px rgba(43, 133, 232, 0.65);
  }

  /* Outline / ghost — steel border, blue only on hover */
  .btn-secondary {
    background: transparent;
    color: var(--spe-steel-light);
    box-shadow: inset 0 0 0 1px var(--spe-line-strong);
  }

  /* B2 — this kept the dark theme's white label through the light-first
     refactor, so hovering the form's "Back" button put #FFF on a #EAF3FD tint:
     1.12:1, i.e. the label vanished. Blue-deep on the same tint is 9.15:1. The
     .theme-dark / .band-dark variants further down still take the white ink. */
  .btn-secondary:hover {
    color: var(--spe-blue-deep);
    background: rgba(21, 102, 200, 0.08);
    box-shadow: inset 0 0 0 1px var(--spe-blue-bright);
  }

  .btn-block {
    width: 100%;
  }

  /* Stretched heading button. A <button> may not contain an <h3>, so the
     heading wraps the button and the button's ::after covers the whole card —
     the entire card stays clickable and the heading stays valid. */
  .stretched {
    background: none;
    border: 0;
    padding: 0;
    font: inherit;
    color: inherit;
    text-align: left;
    cursor: pointer;
  }

  .stretched::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 1;
  }

  /* `aria-disabled="true"` was paired with `:disabled` here. It appears on no
     element on any of the 28 pages and in neither script — M5 stopped using it
     — so it went with the rest of §5.4 item 16. If a disabled <a class="btn">
     is ever needed (an anchor cannot match `:disabled`), bring the selector
     back with the markup that needs it, not before. */
  .btn:disabled {
    opacity: 0.45;
    cursor: not-allowed;
    box-shadow: none;
  }

  /* --------------------------------------------------------------------------
     Cards — max radius 4px
     -------------------------------------------------------------------------- */

  /* `.card` and `.card-inner` lived here: a rounded, non-chamfered card pair
     from before the gradient-border `.gcard` below replaced them. Neither class
     appears in any template or built page. Removed with §5.4 item 16. */

  /* Gradient-border card: outer layer is the border, inner is the surface. */
  .gcard {
    --chamfer: 12px;
    position: relative;
    padding: 1px;
    background: var(--spe-line);
    background-image: linear-gradient(
      115deg,
      var(--spe-line) 0%,
      var(--spe-line) 40%,
      var(--spe-blue-bright) 50%,
      var(--spe-line) 60%,
      var(--spe-line) 100%
    );
    background-size: 300% 100%;
    background-position: 100% 0;
    transition:
      transform var(--dur-hover) var(--ease-hover),
      box-shadow var(--dur-hover) var(--ease-hover),
      background-position 600ms var(--ease-hover);
    clip-path: polygon(
      0 0,
      calc(100% - var(--chamfer)) 0,
      100% var(--chamfer),
      100% 100%,
      var(--chamfer) 100%,
      0 calc(100% - var(--chamfer))
    );
  }

  .gcard > .gcard-inner {
    --chamfer: 11px;
    background: var(--spe-graphite);
    height: 100%;
    clip-path: polygon(
      0 0,
      calc(100% - var(--chamfer)) 0,
      100% var(--chamfer),
      100% 100%,
      var(--chamfer) 100%,
      0 calc(100% - var(--chamfer))
    );
  }

  /* Both `transform` and `box-shadow` below are currently inert on the 15 .gcards
     that are also revealables: the unlayered `[data-reveal].is-revealed` in
     animations.css outranks the transform, and .gcard's own clip-path discards
     the outer shadow. Left as-is rather than worked around — see WORK-LOG.md
     §5.4. The .gcard-inner background change and the icon colour shift below do
     still fire, so hover is not entirely without feedback. */
  @media (hover: hover) and (pointer: fine) {
    .gcard:hover {
      transform: translateY(-6px);
      background-position: 0 0;
      box-shadow: 0 18px 40px -24px rgba(43, 133, 232, 0.75);
    }
    .gcard:hover .gcard-inner {
      background: var(--spe-graphite-2);
    }
  }

  .gcard:focus-within {
    transform: translateY(-6px);
    background-position: 0 0;
  }

  /* Icons — 1.5px thin stroke, steel → blue on hover */
  .ico {
    width: 34px;
    height: 34px;
    stroke: var(--spe-steel);
    stroke-width: 1.5;
    fill: none;
    stroke-linecap: round;
    stroke-linejoin: round;
    transition: stroke var(--dur-hover) var(--ease-hover);
  }

  .gcard:hover .ico,
  .gcard:focus-within .ico {
    stroke: var(--spe-blue-bright);
  }

  /* --------------------------------------------------------------------------
     Chips
     -------------------------------------------------------------------------- */

  .chip {
    --chamfer: 7px;
    display: inline-flex;
    align-items: center;
    min-height: 44px;
    padding: 10px 16px;
    font-size: 14px;
    font-weight: 500;
    color: var(--spe-steel);
    background: var(--spe-graphite);
    box-shadow: inset 0 0 0 1px var(--spe-line);
    transition:
      color var(--dur-hover) var(--ease-hover),
      background-color var(--dur-hover) var(--ease-hover),
      box-shadow var(--dur-hover) var(--ease-hover);
    clip-path: polygon(
      0 0,
      calc(100% - var(--chamfer)) 0,
      100% var(--chamfer),
      100% 100%,
      var(--chamfer) 100%,
      0 calc(100% - var(--chamfer))
    );
  }

  button.chip,
  label.chip {
    cursor: pointer;
  }

  .chip:hover {
    color: var(--spe-steel-light);
    box-shadow: inset 0 0 0 1px var(--spe-line-strong);
  }

  /* m14 — .chip does two jobs. Eleven of them are genuinely pressable (5 gallery
     filter buttons, 6 role checkboxes); 21 are inert labels — the labour-hire
     roles and the suburb list — and they rendered identically, so two thirds of
     the chips on the page looked clickable and were not. The static variant
     keeps the geometry and drops every affordance: the surface goes flat so it
     reads as an outlined tag rather than a raised tile, and there is no hover
     state and no transition to invite a click. They are <span>s, so they take no
     cursor from the button/label rule above and no focus ring from B1. */
  .chip.chip-static {
    background: transparent;
    box-shadow: inset 0 0 0 1px var(--spe-line);
    transition: none;
  }

  .chip.chip-static:hover {
    color: var(--spe-steel);
    box-shadow: inset 0 0 0 1px var(--spe-line);
  }

  /* B2 + M4 — same white-ink carry-over as .btn-secondary:hover, and the one
     visible in the gallery screenshot: #FFF on the composited #D5E3F5 fill was
     1.30:1, so the active filter was unreadable. Blue-deep on the lifted
     #E3EDF8 fill is 8.64:1. The weight shift and the 2px ring carry the
     selected state through greyscale as well, so it is no longer colour-only
     (WCAG 1.4.1). The .theme-dark / .band-dark variants keep the white ink. */
  .chip[aria-pressed="true"],
  .chip.is-active,
  .chip:has(input:checked) {
    color: var(--spe-blue-deep);
    font-weight: 600;
    background: rgba(21, 102, 200, 0.12);
    box-shadow: inset 0 0 0 2px var(--spe-blue-bright);
  }

  .chip input {
    position: absolute;
    opacity: 0;
    width: 1px;
    height: 1px;
  }

  /* --------------------------------------------------------------------------
     Header
     -------------------------------------------------------------------------- */

  /* The header overlays the dark hero photo at rest, then scrolls onto white
     bands — so it carries the dark palette at the top and swaps to the light
     one once it gains a background. Both states re-map tokens rather than
     restyling the nav links, phone link and icon button individually. */
  .site-header {
    position: fixed;
    inset-inline: 0;
    top: 0;
    z-index: 100;
    height: var(--header-h);
    display: flex;
    align-items: center;
    background: transparent;
    transition:
      height 300ms var(--ease-hover),
      background-color 300ms var(--ease-hover),
      backdrop-filter 300ms var(--ease-hover),
      box-shadow 300ms var(--ease-hover);

    /* Both token sets, kept in step. Component CSS reads --spe-*, Tailwind
       utilities read --color-spe-*, so remapping only the first is the drift
       replit.md warns about: the first `text-spe-steel-light` utility added to
       the header would render at the :root light-band value and vanish against
       the dark hero. Latent today — no spe-* utility is used in here yet. */
    --spe-steel-light: #e9edf1;
    --spe-steel: #c8ced6;
    --spe-steel-dark: #8b95a1;
    --spe-blue-bright: #6bb0f5;
    --color-spe-steel-light: #e9edf1;
    --color-spe-steel: #c8ced6;
    --color-spe-steel-dark: #8b95a1;
    --color-spe-blue-bright: #6bb0f5;
  }

  .site-header.is-scrolled {
    height: var(--header-h-scrolled);
    background: rgba(255, 255, 255, 0.9);
    backdrop-filter: blur(14px);
    -webkit-backdrop-filter: blur(14px);
    box-shadow: 0 1px 0 0 var(--spe-line);

    /* Both sets again, and this half is not optional: custom properties inherit
       and this is the same element, so omitting the --color-spe-* twins here
       would leave the dark values from .site-header in force on the white
       scrolled header — near-white #E9EDF1 on rgba(255,255,255,0.9). */
    --spe-steel-light: #0d1319;
    --spe-steel: #3c4551;
    --spe-steel-dark: #5a6573;
    --spe-blue-bright: #1257a8;
    --color-spe-steel-light: #0d1319;
    --color-spe-steel: #3c4551;
    --color-spe-steel-dark: #5a6573;
    --color-spe-blue-bright: #1257a8;
  }

  .header-inner {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 24px;
    width: 100%;
  }

  .brand {
    display: inline-flex;
    align-items: center;
    transform-origin: left center;
    transition: transform 300ms var(--ease-hover);
  }

  /* `.logo-mark` lived here to tint the old #logo sprite symbol via
     currentColor. The logo is the client's raster artwork now — one
     horizontal lockup from _includes/macros/brand-logo.njk, whose metal
     gradient carries its own light and dark values and needs no tinting in
     either header state. The rule matched nothing once the sprite went, so it
     is gone rather than left as a hook. */

  .site-header.is-scrolled .brand {
    transform: scale(0.86);
  }

  .nav-link {
    position: relative;
    display: inline-block;
    padding: 8px 2px;
    font-family: "Saira", ui-sans-serif, system-ui, sans-serif;
    font-weight: 600;
    font-size: 14px;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--spe-steel);
    /* M7 — the desktop nav turns on at 1024px, where the row is tight. Without
       this a link wraps mid-label inside the 88px header instead of the row
       simply running out of gap. */
    white-space: nowrap;
    transition: color var(--dur-hover) var(--ease-hover);
  }

  .nav-link::after {
    content: "";
    position: absolute;
    left: 0;
    bottom: 2px;
    width: 100%;
    height: 2px;
    background: var(--spe-blue-bright);
    transform: scaleX(0);
    transform-origin: left center;
    transition: transform 260ms var(--ease-hover);
  }

  .nav-link:hover,
  .nav-link:focus-visible {
    color: var(--spe-steel-light);
  }

  .nav-link:hover::after,
  .nav-link:focus-visible::after,
  .nav-link.is-current::after {
    transform: scaleX(1);
  }

  .header-phone {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    min-height: 44px;
    font-weight: 600;
    font-size: 15px;
    color: var(--spe-steel-light);
    white-space: nowrap;
  }

  .header-phone:hover {
    color: var(--spe-blue-bright);
  }

  /* `flex: none` is the point of this rule, not the 44px. As a flex item with
     the default `flex-shrink: 1`, `width: 44px` is only a basis — the carousel
     control row at 375px overflowed and flex duly shrank both arrows to 19px.
     A 44px control that silently becomes 19px is worse than one declared small,
     because nothing in the stylesheet says so. */
  .icon-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    width: 44px;
    height: 44px;
    background: transparent;
    border: 0;
    color: var(--spe-steel-light);
    cursor: pointer;
  }

  .icon-btn:hover {
    color: var(--spe-blue-bright);
  }

  /* Mobile menu overlay */
  .mobile-menu {
    position: fixed;
    inset: 0;
    z-index: 150;
    display: flex;
    flex-direction: column;
    background: rgba(8, 9, 11, 0.98);
    backdrop-filter: blur(8px);
    -webkit-backdrop-filter: blur(8px);
    /* m19 — body { overflow: hidden } does not reliably hold on iOS Safari, so
       scrolling the overlay chained through to the page behind it. */
    overscroll-behavior: contain;
    opacity: 0;
    visibility: hidden;
    transition:
      opacity 260ms var(--ease-hover),
      visibility 0s linear 260ms;
  }

  .mobile-menu.is-open {
    opacity: 1;
    visibility: visible;
    transition:
      opacity 260ms var(--ease-hover),
      visibility 0s;
  }

  .mobile-menu a.m-link {
    display: block;
    padding: 14px 0;
    font-family: "Saira", ui-sans-serif, system-ui, sans-serif;
    font-weight: 700;
    font-size: 26px;
    text-transform: uppercase;
    color: var(--spe-steel-light);
    border-bottom: 1px solid var(--spe-line);
    opacity: 0;
    transform: translateY(14px);
  }

  .mobile-menu.is-open a.m-link {
    animation: menu-in 420ms var(--ease-reveal) forwards;
  }

  body.is-locked {
    overflow: hidden;
  }

  /* --------------------------------------------------------------------------
     Hero
     -------------------------------------------------------------------------- */

  .hero {
    position: relative;
    display: flex;
    align-items: center;
    /* m21 — a browser without svh support drops the whole declaration, which
       collapsed the hero to content height. The vh line is the fallback. */
    min-height: 100vh;
    min-height: 100svh;
    padding-top: calc(var(--header-h) + 24px);
    padding-bottom: 88px;
    overflow: hidden;
    isolation: isolate;
  }

  .hero-media {
    position: absolute;
    inset: -6% 0 -6% 0;
    z-index: -2;
    will-change: transform;
  }

  .hero-media img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    animation: ken-burns 12s var(--ease-hover) forwards;
  }

  /* The stop at 60% used to be 0.55, which put the whole text column over a
     half-veiled photograph. Measured on the rendered page at 1280, the eyebrow
     sat at 3.22:1, the lead's worst pixel at 2.62:1 and the reassurance line's
     at 1.73:1 — all against a 4.5:1 requirement, because the sky behind the
     text is the brightest part of the photo. The dark is now held across the
     text column (0.88 to 46%) and only released over the right-hand third,
     where the worker is and where no copy sits. Do not lift the 46% stop
     without re-measuring: the hero carries the site's only above-the-fold copy. */
  .hero-overlay {
    position: absolute;
    inset: 0;
    z-index: -1;
    background: linear-gradient(
      90deg,
      rgba(8, 9, 11, 0.94) 0%,
      rgba(8, 9, 11, 0.88) 46%,
      rgba(8, 9, 11, 0.58) 78%,
      rgba(8, 9, 11, 0.72) 100%
    );
  }

  @media (max-width: 767px) {
    .hero-overlay {
      background: linear-gradient(
        180deg,
        rgba(8, 9, 11, 0.82) 0%,
        rgba(8, 9, 11, 0.78) 45%,
        rgba(8, 9, 11, 0.94) 100%
      );
    }
  }

  .hero-content {
    max-width: 760px;
    text-align: center;
  }

  @media (min-width: 768px) {
    .hero-content {
      text-align: left;
    }
  }

  .hero h1 .word {
    display: inline-block;
    opacity: 0;
    transform: translateY(22px);
    animation: word-up 620ms var(--ease-reveal) forwards;
  }

  .rule-draw {
    height: 2px;
    width: 84px;
    background: var(--spe-blue-bright);
    transform: scaleX(0);
    transform-origin: left center;
    animation: rule-draw 600ms var(--ease-reveal) 200ms forwards;
    margin-inline: auto;
  }

  @media (min-width: 768px) {
    .rule-draw {
      margin-inline: 0;
    }
  }

  .scroll-cue {
    position: absolute;
    left: 50%;
    bottom: 22px;
    translate: -50% 0;
    color: var(--spe-steel-dark);
    animation: bob 2s ease-in-out infinite;
  }

  .hero-cta-pulse {
    animation:
      fade-in 500ms var(--ease-reveal) 900ms both,
      blue-pulse 1800ms var(--ease-hover) 1500ms 1;
  }

  /* --------------------------------------------------------------------------
     Marquee
     -------------------------------------------------------------------------- */

  /* Holds the strip and its pause button. The button cannot live inside
     `.marquee` — that element is `overflow: hidden` and masks its own children
     to transparent across the outer 12%, which is precisely where the button
     goes. See the note in partials/marquee.njk. */
  .marquee-wrap {
    position: relative;
  }

  .marquee {
    position: relative;
    overflow: hidden;
    padding-block: 22px;
    border-block: 1px solid var(--spe-line);
    background: var(--spe-carbon);
    -webkit-mask-image: linear-gradient(
      90deg,
      transparent,
      #000 12%,
      #000 88%,
      transparent
    );
    mask-image: linear-gradient(
      90deg,
      transparent,
      #000 12%,
      #000 88%,
      transparent
    );
  }

  .marquee-track {
    display: flex;
    width: max-content;
    animation: marquee 40s linear infinite;
  }

  /* WCAG 2.2.2 (Level A) wants moving content stoppable by every input method.
     The real mechanism is now `.is-paused`, driven by #marqueePause (main.js).
     The three implicit selectors stay as a convenience for pointer users — they
     were never a 2.2.2 pass on their own, because none is discoverable and a
     touch device has neither hover nor anything focusable inside the strip.
     prefers-reduced-motion stops the animation outright.
     `.is-paused` sits on the WRAPPER, so the button's own :hover and
     :focus-within cannot fight it: the button is inside .marquee-wrap but
     outside .marquee, so pressing it never trips the hover selectors above. */
  .marquee:hover .marquee-track,
  .marquee:focus-within .marquee-track,
  .marquee:active .marquee-track,
  .marquee-wrap.is-paused .marquee-track {
    animation-play-state: paused;
  }

  /* 44px square, sitting in the strip's right-hand fade zone so it never covers
     legible text: the mask is fully opaque only to 88% of the width, and 12% of
     the narrowest supported viewport (375px) is 45px. Vertically the strip is
     14px of line between 22px of padding, so a 44px control fits inside it.
     The translucent carbon plate is there because the labels scroll underneath.

     The ink is a LITERAL, not `--spe-steel-light`, and that is deliberate. The
     button sits in .marquee-wrap, which is outside .marquee and therefore
     outside `.theme-dark` — so the token resolves to the LIGHT palette's
     #0d1319, near-black, on a near-black plate. This is exactly the drift
     replit.md warns about. #e9edf1 is the value `.theme-dark` would have given
     it, ~16:1 on the plate. Do not "tidy" it back into a token without also
     moving the element inside a dark-themed ancestor. */
  .marquee-pause {
    position: absolute;
    top: 50%;
    right: 4px;
    transform: translateY(-50%);
    z-index: 1;
    color: #e9edf1;
    background: rgba(8, 9, 11, 0.72);
  }

  .marquee-pause .ico {
    stroke: currentColor;
  }

  /* Same reason as the base colour: `.icon-btn:hover` resolves
     --spe-blue-bright from the light palette here, and it is the same value in
     both, but stating white keeps the pressed/hover state legible on the plate
     without depending on which palette is in scope. */
  .marquee-pause:hover {
    color: #ffffff;
  }

  /* With `prefers-reduced-motion` the track does not move, so a pause control
     has nothing to act on and only invites a pointless press. What stops the
     animation in that mode is the blanket rule in animations.css, which does
     not depend on this button existing. */
  @media (prefers-reduced-motion: reduce) {
    .marquee-pause {
      display: none;
    }
  }

  .marquee-group {
    display: flex;
    align-items: center;
    gap: 28px;
    padding-right: 28px;
    font-family: "Saira", ui-sans-serif, system-ui, sans-serif;
    font-weight: 600;
    font-size: 14px;
    letter-spacing: 0.14em;
    text-transform: uppercase;
    color: var(--spe-steel-dark);
    white-space: nowrap;
  }

  /* `.dot` is double-booked: the carousel's pagination buttons and these 20 inert
     "·" separators share the class. Without this reset the separators inherit a
     10x10 box, a subtle background fill and `cursor: pointer` — decorative text
     that looks pressable, the same defect m14 fixed for the static chips. */
  .marquee-group span.dot {
    color: var(--spe-blue);
    width: auto;
    height: auto;
    background: none;
    cursor: inherit;
    transition: none;
  }

  /* --------------------------------------------------------------------------
     Process timeline
     -------------------------------------------------------------------------- */

  .timeline {
    position: relative;
  }

  .timeline-rail {
    position: absolute;
    left: 27px;
    top: 8px;
    bottom: 8px;
    width: 2px;
    background: var(--spe-line);
  }

  .timeline-rail::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(
      180deg,
      var(--spe-blue-bright),
      var(--spe-blue-deep)
    );
    transform: scaleY(var(--rail, 0));
    transform-origin: top center;
  }

  .step-node {
    position: relative;
    z-index: 1;
    display: grid;
    place-items: center;
    width: 56px;
    height: 56px;
    flex: 0 0 56px;
    background: var(--spe-graphite);
    box-shadow: inset 0 0 0 1px var(--spe-line);
    font-family: "Saira", ui-sans-serif, system-ui, sans-serif;
    font-weight: 700;
    font-size: 20px;
  }

  .step-node.is-lit {
    box-shadow: inset 0 0 0 1px var(--spe-blue-bright);
  }

  /* REMOVED, not fixed — WORK-LOG §5.4 item 12. There used to be a
     `.step-node.is-lit::after` here: a 2px blue ring at `inset: -6px`, animated
     by `ring-pulse` from scale(0.9) to scale(1.35).

     It never painted a single pixel, in any motion mode. Every `.step-node`
     carries `.chamfer-sm` in partials/process.njk, and a `clip-path` clips
     everything the element renders — pseudo-elements, borders, outlines and
     box-shadows alike — to the clipped shape. A ring 6px OUTSIDE the border box,
     growing to 1.35×, is entirely outside that shape. The reduced-motion reset
     that used to sit in animations.css, and the `ring-pulse` keyframe itself,
     both existed only to serve it and have gone with it.

     `.is-lit`'s inset ring above is the state cue and always was the only one
     anybody has ever seen, so deleting this changes nothing on screen — which is
     exactly why it is safe to do without a browser. Restoring the flourish is
     not a CSS-only change: the ring needs a wrapper element that does not own a
     clip-path, and that needs eyes on it. */

  /* --------------------------------------------------------------------------
     Sector + gallery media cards
     -------------------------------------------------------------------------- */

  .media-card {
    position: relative;
    display: block;
    overflow: hidden;
    background: var(--spe-graphite);
    border-radius: var(--radius-card);
  }

  .media-card img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    transition: transform 500ms var(--ease-hover);
  }

  /* 0.1 at 30% put the h3 — which starts around 42% of the card — on a scrim of
     roughly 0.23 alpha. Measured on the rendered page that was 2.70:1 mean for
     white 18px text at 375 and a 1.25:1 worst pixel; the descriptor and the
     "See … work" link failed too. The old ramp only worked in the hover state,
     which strengthened it — and on touch, where the descriptor is force-opened
     by the (hover: none) block below, there is no hover to strengthen anything.
     So every phone and tablet got the weak scrim with the MOST text on it.
     Stops chosen against the alpha each colour needs over a white photo:
     white h3 ~0.55, #e9edf1 descriptor ~0.60, #6bb0f5 link ~0.77. */
  .media-card .veil {
    position: absolute;
    inset: 0;
    background: linear-gradient(
      180deg,
      rgba(8, 9, 11, 0) 0%,
      rgba(8, 9, 11, 0.28) 24%,
      rgba(8, 9, 11, 0.74) 46%,
      rgba(8, 9, 11, 0.95) 100%
    );
    transition: opacity 300ms var(--ease-hover);
  }

  .media-card .descriptor {
    max-height: 0;
    opacity: 0;
    overflow: hidden;
    transition:
      max-height 320ms var(--ease-hover),
      opacity 320ms var(--ease-hover);
  }

  /* M2 — the reveal used to live only inside the hover media query, so three
     paragraphs of the page's most persuasive sector copy rendered at height 0
     on every phone and tablet. There is no hover there to reveal them with, so
     they are simply always open. */
  @media (hover: none), (pointer: coarse) {
    .media-card .descriptor {
      max-height: none;
      opacity: 1;
    }

    /* The scrim has to be paid for wherever the descriptor is force-opened.
       With it open and the card at phone width the descriptor wraps to four
       lines, so the text block fills roughly the bottom three-quarters of the
       card and the h3 starts around 24% down — where the resting ramp above is
       still only 0.28. Measured at 375: white 18px h3 at 4.41:1 mean, 2.00:1
       worst. This ramp reaches 0.62 by 18% instead.
       Yes, it veils most of the photograph on a phone. That is the correct
       trade: the copy is the reason the card exists and the photo is context.
       The desktop resting state, where the descriptor is collapsed, keeps far
       more of the image visible. */
    .media-card .veil {
      background: linear-gradient(
        180deg,
        rgba(8, 9, 11, 0.1) 0%,
        rgba(8, 9, 11, 0.62) 18%,
        rgba(8, 9, 11, 0.88) 40%,
        rgba(8, 9, 11, 0.96) 100%
      );
    }
  }

  @media (hover: hover) and (pointer: fine) {
    .media-card:hover img,
    .media-card:focus-visible img {
      transform: scale(1.06);
    }
    /* Hover opens the descriptor, so the text block grows upward and needs the
       scrim to grow with it. Must stay at least as strong as the resting ramp
       above at every stop, or hovering would REDUCE legibility. */
    .media-card:hover .veil {
      opacity: 1;
      background: linear-gradient(
        180deg,
        rgba(8, 9, 11, 0.08) 0%,
        rgba(8, 9, 11, 0.46) 20%,
        rgba(8, 9, 11, 0.84) 42%,
        rgba(8, 9, 11, 0.96) 100%
      );
    }
    /* .media-card is an <article> and cannot take focus, so :focus-visible on it
       never matched — tabbing to the inner "See … work" link revealed nothing.
       :focus-within matches on the link instead. 90px also clipped the
       Residential descriptor at four lines (14.5px × 1.65 ≈ 96px); 14em gives
       the longest of the three room to breathe. */
    .media-card:hover .descriptor,
    .media-card:focus-within .descriptor {
      max-height: 14em;
      opacity: 1;
    }
  }

  /* Gallery corner brackets */
  .tile {
    position: relative;
    width: 100%;
    overflow: hidden;
    background: var(--spe-graphite);
    border: 0;
    padding: 0;
    cursor: zoom-in;
    border-radius: var(--radius-card);
  }

  .tile img {
    transition: transform 500ms var(--ease-hover);
  }

  .tile .bracket {
    position: absolute;
    width: 22px;
    height: 22px;
    border: 2px solid var(--spe-blue-bright);
    opacity: 0;
    transition:
      opacity 250ms var(--ease-hover),
      transform 250ms var(--ease-hover);
  }
  .tile .b-tl {
    top: 12px;
    left: 12px;
    border-right: 0;
    border-bottom: 0;
    transform: translate(-6px, -6px);
  }
  .tile .b-tr {
    top: 12px;
    right: 12px;
    border-left: 0;
    border-bottom: 0;
    transform: translate(6px, -6px);
  }
  .tile .b-bl {
    bottom: 12px;
    left: 12px;
    border-right: 0;
    border-top: 0;
    transform: translate(-6px, 6px);
  }
  .tile .b-br {
    bottom: 12px;
    right: 12px;
    border-left: 0;
    border-top: 0;
    transform: translate(6px, 6px);
  }

  @media (hover: hover) and (pointer: fine) {
    .tile:hover img {
      transform: scale(1.05);
    }
    .tile:hover .bracket {
      opacity: 1;
      transform: translate(0, 0);
    }
  }
  .tile:focus-visible .bracket {
    opacity: 1;
    transform: translate(0, 0);
  }

  /* The scrim used to be `transparent → 0.92`, i.e. a linear ramp across the
     whole caption box. The text starts about a third of the way down that box,
     so it landed where the ramp was still near-transparent. Measured on the
     rendered page, the caption's worst pixel hit 1.00:1 over the bright
     galvanised and sky regions of the real photos, and .tile-cat — which is the
     FIRST child of the flex column and therefore sits highest, in the weakest
     part of the ramp — averaged 1.60:1.
     The fix is geometric, not just darker: 34px of top padding moves the fade
     ABOVE the text, and the ramp now reaches 0.88 by 34% so the text sits on a
     field dark enough for both #e9edf1 (needs ~0.60 alpha over white) and the
     10px #6bb0f5 category label (needs ~0.77). Keep the 0% stop transparent —
     the soft top edge is what stops this reading as a solid black bar. */
  .tile-caption {
    position: absolute;
    inset-inline: 0;
    bottom: 0;
    padding: 34px 16px 14px;
    text-align: left;
    font-size: 13px;
    color: var(--spe-steel-light);
    background: linear-gradient(
      180deg,
      rgba(8, 9, 11, 0) 0%,
      rgba(8, 9, 11, 0.55) 18%,
      rgba(8, 9, 11, 0.88) 34%,
      rgba(8, 9, 11, 0.96) 100%
    );
  }

  /* Masonry — fixed row height + row spans keeps FLIP filtering predictable
     and guarantees no horizontal overflow at any width. */
  .masonry {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
    grid-auto-rows: 150px;
    grid-auto-flow: dense;
    gap: 14px;
  }

  .masonry .tile {
    grid-row: span 2;
  }

  .masonry .tile.tall {
    grid-row: span 3;
  }

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

  .masonry .tile.is-hidden {
    display: none;
  }

  @media (min-width: 768px) {
    .masonry {
      grid-auto-rows: 170px;
    }
  }

  /* Lightbox */
  .lightbox {
    position: fixed;
    inset: 0;
    z-index: 180;
    display: grid;
    place-items: center;
    padding: 24px;
    background: rgba(8, 9, 11, 0.94);
    /* m19 — same iOS scroll-chaining problem as .mobile-menu */
    overscroll-behavior: contain;
    opacity: 0;
    visibility: hidden;
    transition:
      opacity 220ms var(--ease-hover),
      visibility 0s linear 220ms;
  }

  .lightbox.is-open {
    opacity: 1;
    visibility: visible;
    transition:
      opacity 220ms var(--ease-hover),
      visibility 0s;
  }

  .lightbox-figure {
    max-width: min(1100px, 100%);
    transform: scale(0.94);
    transition: transform 220ms var(--ease-reveal);
  }

  .lightbox.is-open .lightbox-figure {
    transform: scale(1);
  }

  /* --------------------------------------------------------------------------
     Labour hire panel
     -------------------------------------------------------------------------- */

  .inset-panel {
    --chamfer: 18px;
    position: relative;
    padding: 1px;
    background: linear-gradient(
      115deg,
      var(--spe-line) 0%,
      var(--spe-line) 42%,
      rgba(43, 133, 232, 0.9) 50%,
      var(--spe-line) 58%,
      var(--spe-line) 100%
    );
    background-size: 300% 100%;
    animation: sheen 6s linear infinite;
    clip-path: polygon(
      0 0,
      calc(100% - var(--chamfer)) 0,
      100% var(--chamfer),
      100% 100%,
      var(--chamfer) 100%,
      0 calc(100% - var(--chamfer))
    );
  }

  .inset-panel > div {
    --chamfer: 17px;
    background:
      radial-gradient(
        120% 100% at 0% 0%,
        rgba(21, 102, 200, 0.12) 0%,
        transparent 55%
      ),
      var(--spe-carbon);
    clip-path: polygon(
      0 0,
      calc(100% - var(--chamfer)) 0,
      100% var(--chamfer),
      100% 100%,
      var(--chamfer) 100%,
      0 calc(100% - var(--chamfer))
    );
  }

  /* --------------------------------------------------------------------------
     Testimonials
     -------------------------------------------------------------------------- */

  .carousel-viewport {
    overflow: hidden;
  }

  .carousel-track {
    display: flex;
    transition: transform 450ms var(--ease-reveal);
  }

  .carousel-slide {
    flex: 0 0 100%;
    padding-inline: 6px;
  }

  .dot {
    width: 10px;
    height: 10px;
    border: 0;
    padding: 0;
    background: var(--spe-fill-subtle);
    cursor: pointer;
    transition: background-color 200ms var(--ease-hover);
  }

  /* `.is-on`, not [aria-selected]: the dots are plain buttons in a role="group",
     not tabs, so main.js marks the active one with aria-current on the button and
     drives appearance off this class on the inner span. */
  .dot.is-on {
    background: var(--spe-blue-bright);
  }

  .dot-hit {
    display: grid;
    place-items: center;
    width: 44px;
    height: 44px;
    border: 0;
    background: transparent;
    cursor: pointer;
  }

  /* --------------------------------------------------------------------------
     Form
     -------------------------------------------------------------------------- */

  .field {
    position: relative;
  }

  .field input,
  .field select,
  .field textarea {
    width: 100%;
    background: var(--spe-graphite);
    color: var(--spe-steel-light);
    border: 1px solid var(--spe-input-border);
    border-radius: var(--radius-card);
    padding: 26px 14px 10px;
    font-family: inherit;
    font-size: 16px; /* 16px stops iOS zoom-on-focus */
    line-height: 1.5;
    min-height: 60px;
    transition:
      border-color var(--dur-hover) var(--ease-hover),
      box-shadow var(--dur-hover) var(--ease-hover);
    appearance: none;
  }

  .field textarea {
    min-height: 132px;
    padding-top: 28px;
    resize: vertical;
  }

  /* m4 — the chevron is a control indicator, so WCAG 1.4.11 wants 3:1. The old
     #AEB6BF managed 2.05:1 on the white field; #5A6573 is 5.93:1. The colour is
     baked into a data URI and so cannot follow the band tokens — the dark-band
     override further down restores the light chevron. */
  .field select {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%235A6573' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 14px center;
    padding-right: 40px;
    cursor: pointer;
  }

  .field label {
    position: absolute;
    left: 15px;
    top: 19px;
    font-size: 16px;
    color: var(--spe-steel-dark);
    pointer-events: none;
    transform-origin: left top;
    transition: transform 160ms var(--ease-hover), color 160ms var(--ease-hover);
  }

  /* m5 — `placeholder=" "` does nothing on input[type="date"] and
     :placeholder-shown never matches it, so the label sat on top of the
     browser's own dd/mm/yyyy. Date fields raise their label unconditionally,
     the same as selects.

     `.field.is-filled label` used to be the last selector in this list. Nothing
     sets `.is-filled` on a `.field` — form.js drives the float entirely off
     `:not(:placeholder-shown)` and the selectors above — so it never matched and
     it was removed. THE OPENING BRACE WENT WITH IT. That left a selector list
     ending in a comma and no `{`, which is not a parse error you can see: the
     tokenizer simply kept consuming until the NEXT `{` in the file, swallowing
     these declarations and the whole `.field input:focus` rule below into one
     garbage prelude, and dropped both rules on the floor.

     The visible symptom was the reported one — text overlapping at the top of
     the quote form. Labels are `position: absolute; top: 19px`, so with the
     float dead, every label sat directly on top of its own field's value: on
     first name and last name as soon as you typed, and on page load for anyone
     whose answers form.js had restored from localStorage. The focus ring was
     collateral damage from the same missing brace.

     Do not re-open this rule with a trailing comma. */
  .field input:focus + label,
  .field textarea:focus + label,
  .field input:not(:placeholder-shown) + label,
  .field textarea:not(:placeholder-shown) + label,
  .field input[type="date"] + label,
  .field select + label {
    transform: translateY(-11px) scale(0.75);
    color: var(--spe-steel-dark);
  }

  .field input:focus,
  .field select:focus,
  .field textarea:focus {
    outline: none;
    border-color: var(--spe-blue-bright);
    box-shadow: 0 0 0 2px rgba(43, 133, 232, 0.28);
  }

  .field.is-valid input,
  .field.is-valid select,
  .field.is-valid textarea {
    border-color: rgba(43, 133, 232, 0.55);
  }

  /* M4 — an error was a 1px colour swap and nothing else, so in greyscale (or
     to a red-green colour-blind user) a valid field and an invalid one looked
     identical. The border also doubles in weight, which survives any colour
     loss. box-sizing is border-box and min-height is fixed, so nothing moves. */
  .field.is-error input,
  .field.is-error select,
  .field.is-error textarea {
    border-width: 2px;
    border-color: var(--spe-amber);
  }

  /* p7 — was 13.5px, below the 14px floor the rest of the ramp holds to.
     M4 — the "!" prefix means the message is not carrying its meaning in amber
     alone. */
  .err {
    display: none;
    margin-top: 6px;
    font-size: 14px;
    color: var(--spe-amber);
    opacity: 0;
    transform: translateY(-8px);
  }

  .err::before {
    content: "! ";
    font-weight: 700;
  }

  .field.is-error .err,
  .err.is-shown {
    display: block;
    animation: err-in 220ms var(--ease-hover) forwards;
  }

  fieldset {
    border: 0;
    margin: 0;
    padding: 0;
    min-width: 0;
  }

  legend,
  .field-legend {
    display: block;
    font-size: 14px;
    font-weight: 500;
    color: var(--spe-steel);
    margin-bottom: 10px;
    padding: 0;
  }

  /* Radio cards */
  .radio-card {
    --chamfer: 10px;
    position: relative;
    display: flex;
    align-items: center;
    gap: 10px;
    min-height: 56px;
    padding: 14px 16px;
    background: var(--spe-graphite);
    box-shadow: inset 0 0 0 1px var(--spe-input-border);
    color: var(--spe-steel);
    font-size: 15px;
    cursor: pointer;
    transition:
      color var(--dur-hover) var(--ease-hover),
      background-color var(--dur-hover) var(--ease-hover),
      box-shadow var(--dur-hover) var(--ease-hover);
    clip-path: polygon(
      0 0,
      calc(100% - var(--chamfer)) 0,
      100% var(--chamfer),
      100% 100%,
      var(--chamfer) 100%,
      0 calc(100% - var(--chamfer))
    );
  }

  .radio-card input {
    position: absolute;
    opacity: 0;
    width: 1px;
    height: 1px;
  }

  .radio-card:hover {
    color: var(--spe-steel-light);
  }

  /* M4 — a tick gives the selection a cue that survives greyscale (WCAG 1.4.1).
     Drawn from two borders rather than set as a "✓" glyph: `content: ""` puts
     nothing in the accessibility tree, so it cannot be announced on all 14 cards
     — opacity 0 does not remove generated text from it. It is always in the flow
     at its full size, so checking an option never re-flows the label, and the
     borders take currentColor, so it follows the checked ink on every band. */
  .radio-card::after {
    content: "";
    flex: 0 0 auto;
    margin-left: auto;
    width: 13px;
    height: 7px;
    border-left: 2px solid;
    border-bottom: 2px solid;
    transform: rotate(-45deg) translate(1px, -3px);
    opacity: 0;
    transition: opacity var(--dur-hover) var(--ease-hover);
  }

  /* B2 — white ink on the composited #DAE7F6 fill was 1.25:1, so in the quote
     form the user could not see which project type, drawings, stage or contact
     method they had picked — four required questions on the only conversion
     path on the site. Blue-deep on the lifted #E8F0FA fill is 8.89:1, and the
     ring goes to 2px so the change is not carried by colour alone. The
     .theme-dark / .band-dark palette is untouched. */
  .radio-card:has(input:checked) {
    color: var(--spe-blue-deep);
    background: rgba(21, 102, 200, 0.1);
    box-shadow: inset 0 0 0 2px var(--spe-blue-bright);
  }

  .radio-card:has(input:checked)::after {
    opacity: 1;
  }

  fieldset.is-error .radio-card {
    box-shadow: inset 0 0 0 2px var(--spe-amber);
  }

  /* Progress bar */
  .progress-rail {
    height: 3px;
    background: var(--spe-fill-subtle);
    overflow: hidden;
  }

  .progress-fill {
    height: 100%;
    background: var(--grad-blue);
    transform: scaleX(0.333);
    transform-origin: left center;
    transition: transform 380ms var(--ease-reveal);
  }

  /* Steps */
  .step {
    display: none;
  }
  .step.is-active {
    display: block;
    animation: step-in 220ms var(--ease-hover) forwards;
  }
  .step.is-active.is-back {
    animation-name: step-in-back;
  }

  /* Dropzone */
  .dropzone {
    --chamfer: 10px;
    display: grid;
    place-items: center;
    gap: 8px;
    padding: 28px 20px;
    text-align: center;
    background: var(--spe-graphite);
    border: 1px dashed var(--spe-input-border);
    color: var(--spe-steel-dark);
    cursor: pointer;
    transition:
      border-color var(--dur-hover) var(--ease-hover),
      background-color var(--dur-hover) var(--ease-hover);
  }

  .dropzone.is-dragover {
    border-color: var(--spe-blue-bright);
    background: rgba(21, 102, 200, 0.12);
    animation: dash-pulse 900ms var(--ease-hover) infinite;
  }

  .dropzone.pulse-once {
    animation: blue-pulse 1200ms var(--ease-hover) 1;
  }

  .file-chip {
    --chamfer: 8px;
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 12px;
    background: var(--spe-graphite);
    box-shadow: inset 0 0 0 1px var(--spe-line);
    font-size: 13.5px;
    animation: scale-in 260ms var(--ease-reveal) forwards;
    clip-path: polygon(
      0 0,
      calc(100% - var(--chamfer)) 0,
      100% var(--chamfer),
      100% 100%,
      var(--chamfer) 100%,
      0 calc(100% - var(--chamfer))
    );
  }

  .file-chip .name {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--spe-steel-light);
  }

  .file-bar {
    height: 2px;
    background: var(--spe-fill-subtle);
    margin-top: 6px;
  }
  .file-bar > i {
    display: block;
    height: 100%;
    width: 0%;
    background: var(--spe-blue-bright);
    transition: width 200ms linear;
  }

  .char-count {
    font-size: 12.5px;
    color: var(--spe-steel-dark);
  }

  /* Submit button states */
  .btn-submit .spinner {
    display: none;
    width: 20px;
    height: 20px;
    border: 2px solid rgba(255, 255, 255, 0.35);
    border-top-color: #fff;
    border-radius: 50%;
    animation: spin 700ms linear infinite;
  }

  .btn-submit.is-busy .label,
  .btn-submit.is-done .label {
    display: none;
  }
  .btn-submit.is-busy .spinner {
    display: block;
  }
  .btn-submit.is-done .tick {
    display: block;
  }
  .btn-submit .tick {
    display: none;
  }
  .btn-submit .tick path {
    stroke-dasharray: 30;
    stroke-dashoffset: 30;
    animation: draw 420ms var(--ease-reveal) forwards;
  }

  /* --------------------------------------------------------------------------
     FAQ
     -------------------------------------------------------------------------- */

  .faq-item {
    border-bottom: 1px solid var(--spe-line);
    border-left: 2px solid transparent;
    transition: border-color 260ms var(--ease-hover);
  }

  .faq-item[open] {
    border-left-color: var(--spe-blue-bright);
    background: color-mix(in srgb, var(--spe-graphite) 60%, transparent);
  }

  .faq-item summary {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
    padding: 22px 18px;
    cursor: pointer;
    list-style: none;
    font-family: "Saira", ui-sans-serif, system-ui, sans-serif;
    font-weight: 600;
    font-size: clamp(16px, 2vw, 19px);
    color: var(--spe-steel-light);
  }

  .faq-item summary::-webkit-details-marker {
    display: none;
  }

  .faq-item summary .chev {
    flex: 0 0 auto;
    transition: transform 300ms var(--ease-hover);
  }

  .faq-item[open] summary .chev {
    transform: rotate(180deg);
  }

  .faq-body {
    overflow: hidden;
    max-height: 0;
    opacity: 0;
    transition:
      max-height 340ms var(--ease-hover),
      opacity 260ms var(--ease-hover);
  }

  /* B5 — `max-height: 0` with no [open] rule meant the seven answers were
     invisible whenever main.js had not run: opening a row rotated the chevron
     and showed nothing, and those answers are also the FAQPage JSON-LD content.
     The script's inline max-height animates the open, then clears itself on
     transitionend so this value takes over — the animation is the enhancement,
     not the source of truth. */
  .faq-item[open] .faq-body {
    max-height: none;
    opacity: 1;
  }

  /* p8 — 78ch was well over the comfortable 45–75ch measure at 17px and
     disagreed with .lead's 62ch. 68ch reconciles the two. */
  .faq-body > div {
    padding: 0 18px 24px;
    max-width: 68ch;
  }

  /* Arrow links ("See residential work", "Read the case study", the 18 related
     -service links on the project pages) measured 23px tall — 14px × 1.65 line
     -height — one pixel under the WCAG 2.5.8 (AA) 24px minimum. They are not
     inline text links, so the inline exception does not cover them.
     :has(> .ico) is what makes this a CSS-only fix: it selects exactly the
     text-plus-arrow links and nothing else, instead of editing 22 templates.
     #quickToggle is the same 14px miss with no icon, so it is named. */
  a.inline-flex:has(> .ico),
  #quickToggle {
    min-height: 24px;
  }

  /* --------------------------------------------------------------------------
     Sticky mobile CTA
     -------------------------------------------------------------------------- */

  .mobile-cta {
    position: fixed;
    inset-inline: 0;
    bottom: 0;
    z-index: 90;
    display: grid;
    /* m1 — single CTA site: the call button was removed on request, but the
       two-up grid stayed behind and left "Request a Quote" filling half the bar
       with an empty right-hand cell. */
    grid-template-columns: 1fr;
    gap: 10px;
    padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
    background: color-mix(in srgb, var(--spe-carbon) 94%, transparent);
    backdrop-filter: blur(12px);
    -webkit-backdrop-filter: blur(12px);
    border-top: 1px solid var(--spe-line);
    transform: translateY(110%);
    transition: transform 300ms var(--ease-hover);
  }

  .mobile-cta.is-visible {
    transform: translateY(0);
  }

  @media (min-width: 1024px) {
    .mobile-cta {
      display: none;
    }
  }

  /* --------------------------------------------------------------------------
     Footer
     -------------------------------------------------------------------------- */

  /* m10 — 5px of padding on a 14.5px line gave ~34px targets, stacked with no
     row gap in the footer's ul.grid. 9px takes them to ~43px, in line with the
     44px the rest of the site holds to (.chip, .icon-btn, .dot-hit, .btn). */
  .footer-link {
    display: inline-block;
    padding: 9px 0;
    font-size: 14.5px;
    color: var(--spe-steel);
    transition: color var(--dur-hover) var(--ease-hover);
  }

  .footer-link:hover {
    color: var(--spe-blue-bright);
  }

  .footer-head {
    font-family: "Saira", ui-sans-serif, system-ui, sans-serif;
    font-weight: 700;
    font-size: 12px;
    letter-spacing: 0.18em;
    text-transform: uppercase;
    color: var(--spe-steel-light);
    margin-bottom: 12px;
  }

  /* Footer social links. The same 44px target as .icon-btn, but it cannot BE an
     .icon-btn for one reason: .icon-btn moves `color` on hover, and `.ico`
     declares `stroke` explicitly, so the inherited colour never reaches the
     glyph and the icon would sit dead under the pointer. The hover moves the
     stroke instead — the same move .gcard:hover .ico already makes.

     The negative inline-start margin is optical, not a fudge: the 44px box is
     wider than the 20px glyph, so without it the icon hangs ~12px right of the
     paragraph edge above it and the column loses its left rule. */
  .social-link {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 44px;
    margin-inline-start: -12px;
  }

  .social-link:hover .ico,
  .social-link:focus-visible .ico {
    stroke: var(--spe-blue-bright);
  }


  /* --------------------------------------------------------------------------
     Photographic section backgrounds
     Decorative only (alt="", aria-hidden). Always under a heavy scrim so body
     copy keeps its 4.5:1 contrast — the photo adds depth, never competes.
     -------------------------------------------------------------------------- */

  /* `.section-bg-host` was an alias for `.has-bg` here and sits on no element on
     any of the 28 pages. Removed with §5.4 item 16 — `.has-bg` is the class the
     sections actually carry. */
  .has-bg {
    position: relative;
    isolation: isolate;
  }

  .section-bg {
    position: absolute;
    inset: 0;
    z-index: -1;
    overflow: hidden;
    pointer-events: none;
  }

  /* Default is the light-band treatment: lifted and desaturated so the photo
     survives under a white scrim as texture rather than mud. `.band-dark`
     restores the darkened version further down. */
  .section-bg img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    filter: grayscale(0.5) contrast(1.02) brightness(1.06);
  }

  .section-bg::after {
    content: "";
    position: absolute;
    inset: 0;
    background: linear-gradient(
      180deg,
      rgb(var(--scrim)) 0%,
      rgba(var(--scrim), var(--veil, 0.9)) 26%,
      rgba(var(--scrim), var(--veil, 0.9)) 74%,
      rgb(var(--scrim)) 100%
    );
  }

  .scrim-black {
    --scrim: 8, 9, 11;
  }
  .scrim-carbon {
    --scrim: 16, 18, 22;
  }

  /* Gallery category tag — sits in the caption bar, always legible, so the
     filter chips and the photos visibly agree. */
  .tile-caption {
    display: flex;
    flex-direction: column;
    gap: 4px;
    align-items: flex-start;
  }

  /* The optional one-line description under a tile caption (`captionDetail` in
     _data/gallery.json — the billet ledge is the only one so far). Same family
     and colour as the caption it sits under, one step down in size and slightly
     held back, so it reads as a continuation rather than a second caption. The
     4px column gap above already spaces it. */
  .tile-detail {
    font-size: 12px;
    line-height: 1.45;
    opacity: 0.82;
  }

  .tile-cat {
    font-family: "Saira", ui-sans-serif, system-ui, sans-serif;
    font-weight: 700;
    font-size: 10px;
    letter-spacing: 0.16em;
    text-transform: uppercase;
    color: var(--spe-blue-bright);
  }

  /* Polish: a hairline top highlight gives cards a milled edge rather than a
     flat fill, and pretty text-wrap stops orphaned words in body copy. */
  .gcard-inner {
    box-shadow: inset 0 1px 0 var(--spe-inset-hi);
  }

  p,
  li {
    text-wrap: pretty;
  }


  /* --------------------------------------------------------------------------
     DARK BANDS
     Light is now the default (see :root). The dark palette survives here, for
     the sections that bookend the page — hero, trust strip, final CTA, footer —
     and for anything sitting on a dark photograph.

     Same mechanism as the old `.theme-light`, inverted: a dark band re-maps the
     brand tokens its children already consume, so cards, chips, inputs, icons
     and dividers all adapt without a single component override.

     Tailwind utilities (text-spe-steel-light, bg-spe-line …) resolve
     --color-spe-*, NOT --spe-*. Both have to be re-mapped, or every utility
     keeps the light palette and renders near-black text on a black band.
     -------------------------------------------------------------------------- */

  .theme-dark,
  .band-dark {
    --spe-black: #08090b;
    --spe-carbon: #101216;
    --spe-graphite: #1a1d23;
    --spe-graphite-2: #22262e;

    --spe-steel-light: #e9edf1;
    --spe-steel: #aeb6bf;
    --spe-steel-dark: #8b95a1; /* was #6B7280 — only 3.6:1 on #08090B */

    --spe-blue-bright: #2b85e8; /* 6.1:1 on carbon, so it can be text again */
    --spe-line: rgba(174, 182, 191, 0.14);
    --spe-line-strong: rgba(174, 182, 191, 0.34);
    --spe-input-border: rgba(174, 182, 191, 0.45);
    --spe-fill-subtle: rgba(174, 182, 191, 0.18);
    --spe-inset-hi: rgba(233, 237, 241, 0.045);
    --spe-amber: #f0a13c; /* the bright amber is legible again on black */

    /* Brushed steel works again on black */
    --grad-steel: linear-gradient(
      160deg,
      #ffffff 0%,
      #d5dbe2 28%,
      #8e97a1 55%,
      #edf1f5 78%,
      #9aa3ad 100%
    );
    --color-spe-black: #08090b;
    --color-spe-carbon: #101216;
    --color-spe-graphite: #1a1d23;
    --color-spe-graphite-2: #22262e;
    --color-spe-steel-light: #e9edf1;
    --color-spe-steel: #aeb6bf;
    --color-spe-steel-dark: #8b95a1;
    --color-spe-blue-bright: #2b85e8;
    --color-spe-line: rgba(174, 182, 191, 0.14);
    /* p5 — the four @theme names this block used to leave behind. --spe-amber is
       re-mapped above but its twin was not, which is the token drift
       replit.md warns about; the other three are stated at their :root values so
       the two sets are provably complete rather than accidentally in step. */
    --color-spe-amber: #f0a13c;
    --color-spe-blue: #1566c8;
    --color-spe-blue-deep: #0c3f82;
    --color-spe-white: #f7f9fb;

    color: var(--spe-steel);
    background: var(--spe-black);
  }

  /* Two-class selectors so these beat the plain `.theme-dark` background above,
     which is later in the file than `.band-alt` / `.marquee`. */
  /* `.theme-dark.bg-carbon-sec` left this list with the `.bg-carbon-sec` alias
     itself — §5.4 item 16. */
  .band-dark.band-alt,
  .marquee.theme-dark {
    background: var(--spe-carbon);
  }

  /* Outline button on dark: steel border, blue on hover */
  .theme-dark .btn-secondary,
  .band-dark .btn-secondary {
    color: var(--spe-steel-light);
    box-shadow: inset 0 0 0 1px rgba(174, 182, 191, 0.34);
  }
  .theme-dark .btn-secondary:hover,
  .band-dark .btn-secondary:hover {
    color: #fff;
    background: rgba(43, 133, 232, 0.1);
    box-shadow: inset 0 0 0 1px var(--spe-blue-bright);
  }

  /* Card edge gradient goes back to the steel hairline */
  .theme-dark .gcard,
  .band-dark .gcard {
    background-image: linear-gradient(
      115deg,
      rgba(174, 182, 191, 0.16) 0%,
      rgba(174, 182, 191, 0.16) 40%,
      var(--spe-blue-bright) 50%,
      rgba(174, 182, 191, 0.16) 60%,
      rgba(174, 182, 191, 0.16) 100%
    );
    background-size: 300% 100%;
    background-position: 100% 0;
  }
  .theme-dark .gcard-inner,
  .band-dark .gcard-inner {
    box-shadow: inset 0 1px 0 rgba(233, 237, 241, 0.045);
  }
  @media (hover: hover) and (pointer: fine) {
    .theme-dark .gcard:hover,
    .band-dark .gcard:hover {
      box-shadow: 0 18px 40px -24px rgba(43, 133, 232, 0.75);
    }
  }

  .theme-dark .ico,
  .band-dark .ico {
    stroke: var(--spe-steel);
  }

  .theme-dark .chip,
  .band-dark .chip {
    background: var(--spe-graphite);
  }
  /* …but the inert ones stay flat. The labour-hire role labels sit inside a
     .theme-dark panel, so without this the rule above would re-fill them with
     the card surface and undo m14 on exactly those six. */
  .theme-dark .chip-static,
  .band-dark .chip-static {
    background: transparent;
  }
  .theme-dark .chip[aria-pressed="true"],
  .theme-dark .chip.is-active,
  .theme-dark .chip:has(input:checked),
  .band-dark .chip[aria-pressed="true"],
  .band-dark .chip.is-active,
  .band-dark .chip:has(input:checked) {
    color: #fff;
    background: rgba(21, 102, 200, 0.28);
    /* 2px to match the light-band rule — M4's non-colour cue applies here too */
    box-shadow: inset 0 0 0 2px var(--spe-blue-bright);
  }

  /* B2 re-authored the base checked radio card for light bands, where its ink is
     now --spe-blue-deep. That would be dark-on-dark here, so the dark palette
     keeps the white ink — the same pattern as the chip rule above. #FFF on the
     composited #193151 fill is 13.1:1. No radio card sits on a dark band today;
     this exists so moving the quote form onto one cannot silently break it. */
  .theme-dark .radio-card:has(input:checked),
  .band-dark .radio-card:has(input:checked) {
    color: #fff;
    background: rgba(21, 102, 200, 0.28);
    box-shadow: inset 0 0 0 2px var(--spe-blue-bright);
  }

  .theme-dark .step-node,
  .band-dark .step-node {
    background: var(--spe-graphite);
  }

  .theme-dark .dropzone,
  .band-dark .dropzone {
    background: rgba(26, 29, 35, 0.7);
    border-color: rgba(174, 182, 191, 0.34);
  }

  .theme-dark .timeline-rail,
  .band-dark .timeline-rail {
    background: rgba(174, 182, 191, 0.16);
  }

  .theme-dark .dot,
  .band-dark .dot {
    background: rgba(233, 237, 241, 0.28);
  }
  .theme-dark .dot.is-on,
  .band-dark .dot.is-on {
    background: var(--spe-blue-bright);
  }

  /* --------------------------------------------------------------------------
     Scrims for photographic section backgrounds
     -------------------------------------------------------------------------- */

  .scrim-light {
    --scrim: 242, 245, 248;
  }
  .scrim-white {
    --scrim: 255, 255, 255;
  }

  .theme-dark .section-bg img,
  .band-dark .section-bg img {
    filter: grayscale(0.35) contrast(1.05) brightness(0.82);
  }

  /* m8 — the shared scrim above is fully opaque at both the 0% and 100% stops
     and only reaches --veil between 26% and 74%. On the stat bar, which is a
     ~215px band, that left the 163KB photo visible at 10% opacity across about
     55px of page: real client photography paying for itself almost nowhere.

     The dark bands can afford a thinner scrim than the white ones, because the
     ink on them is near-white rather than mid-grey. Plateau 8%–92% at 0.84,
     ends at 0.96 so the seam with the neighbouring bands still reads as solid.
     Declared here rather than as a `--veil` value because the section sets
     --veil inline and an author-level rule cannot outrank that.

     Worst case measured against the brightest pixel this band's own filter can
     emit (brightness(0.82) caps every channel at 209, i.e. #D1D1D1): the 13px
     stat labels at #8B95A1 are 4.79:1 (were 5.55:1) and the darkest stop of the
     dark --grad-steel, #8E97A1, is 4.92:1 — both clear of AA, and the numbers
     are 36px+ bold so they only owe 3:1.

     `.theme-dark` added — this was WORK-LOG §5.4 item 13 (II-2). The sibling
     `img` filter rule twenty lines up already covers BOTH classes, so the
     labour-hire panel's photo was being darkened by the filter and then
     over-veiled by the full-strength scrim underneath: it carries the same
     near-white `.theme-dark` ink as the dark bands, so it can afford the same
     thinner scrim, and the two selectors now match exactly the same elements
     instead of disagreeing. That panel is the only `.theme-dark` element on the
     site with a `.section-bg`, so nothing else changes.
     Note its inline `style="--veil: 0.9"` is now inert, exactly as the inline
     values on the `.band-dark` sections already are — the stops below are
     literals for the reason given above. */
  .theme-dark .section-bg::after,
  .band-dark .section-bg::after {
    background: linear-gradient(
      180deg,
      rgba(var(--scrim), 0.96) 0%,
      rgba(var(--scrim), 0.84) 8%,
      rgba(var(--scrim), 0.84) 92%,
      rgba(var(--scrim), 0.96) 100%
    );
  }

  /* m4 — the chevron's colour is baked into a data URI, so it cannot follow the
     tokens. #5A6573 would be 1.6:1 on the dark field; the original light
     chevron is 8.23:1 on it. */
  .theme-dark .field select,
  .band-dark .field select {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23AEB6BF' stroke-width='2'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E");
  }

  /* Gallery tiles and sector cards always sit on a dark photo gradient, even
     inside a white band, so their captions keep the light palette explicitly —
     --spe-blue-bright is a dark navy out here and would vanish. */
  .tile-caption {
    color: #e9edf1;
  }
  .tile-cat {
    color: #6bb0f5;
  }
  .media-card .descriptor,
  .media-card h3 {
    color: #e9edf1;
  }
  .media-card a {
    color: #6bb0f5;
  }

  /* Same reasoning, one class further out: the hero is .theme-dark, so its
     eyebrow inherited --spe-blue-bright = #2b85e8. That value is documented as
     "6.1:1 on carbon" and it is — but the hero's field is a photograph, not
     carbon, and measured on the rendered page the eyebrow came back at 3.22:1
     against a 4.5:1 requirement for 12px text. #2b85e8 cannot reach 4.5:1 on
     anything lighter than about rgb(29,29,29), so this is a colour fix, not a
     scrim fix; the overlay was strengthened as well, and both were needed.
     Scoped to .hero so solid-carbon bands keep the darker blue. */
  .hero .eyebrow {
    color: #6bb0f5;
  }

  /* Visible placeholder marker — every one of these must be gone before launch */
  .ph {
    color: var(--spe-amber);
    font-weight: 600;
  }

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

}

/* Unlayered on purpose: the hidden attribute must beat every utility and
   component rule, including display:grid and display:inline-flex. */
[hidden] {
  display: none !important;
}
