/* =====================================================================
   ZEF Design Build — stylesheet
   Design direction: "The Drawing Set" (paper / graphite / blueline steel).
   Palette v1 built by Fahad in Coolors 2026-08-14; approved on decision
   board v8, 2026-08-15. This file is that board's design system, rebuilt
   as three token layers so the whole site re-skins from the middle layer.

   LAYER 1  primitive  — the raw palette and scales. Named for what they ARE.
   LAYER 2  semantic   — role aliases. Named for what they DO. Theming edits
                         only this layer.
   LAYER 3  component  — per-component vars. Components reference layer 2/3
                         only: no raw hex or px inside a component rule.
   ===================================================================== */

/* =====================================================================
   LAYER 1 — PRIMITIVE
   ===================================================================== */
:root {
  /* --- Palette v1 (Fahad, Coolors, 2026-08-14) ------------------------ */
  --zef-paper:        #E0E1DD;  /* page ground, and light text on dark    */
  --zef-ink:          #000000;  /* headings and rules on paper            */
  --zef-navy:         #151C28;  /* dark sections                          */
  --zef-steel:        #3A5069;  /* the one accent                         */
  --zef-graphite:     #303236;  /* body text on paper                     */

  /* --- Derived tints (brand-core: needed for legibility and depth) ---- */
  --zef-paper-2:      #D4D6D1;  /* sunken panel fill                      */
  --zef-white:        #FFFFFF;  /* card fill, text on steel               */
  --zef-ground:       #F4F4F2;  /* THE GROUND (Fahad 2026-08-20: "the grey
                                   behind customer testimonials"), site-wide.
                                   NOT named --zef-mist: that name is taken
                                   further down for #BFC2C4, on-navy text. */
  --zef-ground-2:     #E9E9E6;  /* recessed fills, one step under the ground */
  --zef-black:        #000000;  /* deepest layer: footer, xl tile ground  */
  --zef-steel-light:  #8AA6C2;  /* text and linework on navy              */
  --zef-steel-hover:  #47617F;  /* primary button hover                   */
  --zef-mist:         #BFC2C4;  /* secondary text on navy                 */
  --zef-slate:        #97A6B6;  /* tertiary text on navy                  */
  --zef-slate-deep:   #8A97A6;  /* footer text on black                   */

  /* --- Alpha primitives (rules, hairlines, scrims) -------------------- */
  --zef-ink-a20:      rgba(0, 0, 0, .20);
  --zef-ink-a05:      rgba(0, 0, 0, .05);
  --zef-paper-a17:    rgba(224, 225, 221, .17);
  --zef-paper-a12:    rgba(224, 225, 221, .12);
  --zef-paper-a20:    rgba(224, 225, 221, .20);
  --zef-white-a10:    rgba(255, 255, 255, .10);
  --zef-shadow-a35:   rgba(0, 0, 0, .35);
  --zef-shadow-a40:   rgba(0, 0, 0, .40);

  /* --- Type -----------------------------------------------------------
     Sizes are taken from the approved v8 render rather than from a clean
     modular ratio: rule 59 makes the approved render's geometry the spec,
     so the scale is tokenised as-built instead of "corrected" to a ratio.
     A rationalisation pass is a proposal for Fahad, not a silent change. */
  /* TYPE PASS 2026-08-17 (Fahad: "cartoonish font ... not professional grade
     font styling"). Two things were wrong, and only one of them was the
     family. Archivo's 800 at 60px+ is what read as cartoonish, so the ceiling
     came down to 700 and display type now sets at 600 with real optical
     tracking, which is how large type is supposed to be set. The family moved
     to Instrument Sans, which holds up better at display sizes and does not
     go chunky. A picker board of fresh families is with Fahad to lock this. */
  /* Instrument Sans is the INTERFACE and SUB-COPY face: nav, logo, the lead
     dock, buttons, form labels, footer, and every paragraph. Anton lives on
     `--font-display` and reaches only the written headings — see the
     CONTENT HEADINGS block at the end of this file. Setting Anton on
     `--font-sans` (2026-08-20, briefly) put it on the logo and the call
     panel too, which was not the instruction. */
  --font-sans: 'Instrument Sans', system-ui, -apple-system, 'Segoe UI', sans-serif;
  /* Condensed display face for the editorial section heads (2026-08-20).
     Anton over Barlow Condensed 900 because it is the closer match to the
     reference: a true condensed grotesk with almost no counter, where Barlow
     is a normal grotesk narrowed. Single weight by design — asking for a
     bold makes the browser synthesise one, which is not the typeface. */
  --font-display: 'Anton', 'Arial Narrow', 'Helvetica Neue Condensed', sans-serif;
  /* THE LIGHT CONDENSED (2026-08-20). Fahad asked for the hero lead in the
     heading face and then asked for it UNBOLD. Anton ships exactly one
     weight and it is black, so "unbold Anton" does not exist as a thing to
     set. Oswald is the closest relative that does: a true condensed grotesk
     on the same skeleton, but with real light weights. At 300 it reads as
     the quiet member of the same family rather than as a second family. */
  --font-lead: 'Oswald', 'Anton', 'Arial Narrow', sans-serif;
  --font-mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, monospace;
  /* Hand lettering for the process board's stage numbers only (2026-08-19,
     the pinned-card reference). Caveat over the reference's Comic Sans:
     architect's hand, not classroom hand. Swapping is one line here plus
     the Google Fonts URL in generate.py's head(). */
  --font-script: 'Caveat', 'Chalkboard SE', 'Comic Sans MS', cursive;

  --text-3xs:   8px;
  --text-2xs:   9.5px;
  --text-xs:    10.5px;
  --text-sm:    11.5px;
  --text-md:    12.5px;
  --text-base:  13.5px;
  --text-lg:    14.5px;
  --text-xl:    16px;
  --text-2xl:   19px;
  --text-3xl:   22px;
  --text-4xl:   26px;

  --lh-tight:   1.03;
  --lh-snug:    1.2;
  --lh-body:    1.65;

  /* RETUNED FOR ANTON, 2026-08-20 (Fahad: "the words are too closely attached
     on the hero page, space them out a little so they look normal and match
     the subcopy", with a reference image of the cramped hero).

     These were NEGATIVE because Instrument Sans sets loose and needed pulling
     in. Anton is the opposite: a true condensed face with almost no
     sidebearing, so negative tracking closes the letters until they touch,
     which is exactly what the reference shows. Positive values now, and the
     display value most of all, because the tighter the face the more a large
     setting exaggerates it. */
  --track-tight: -0.02em;
  --track-snug:  -0.015em;
  --track-wide:   0.02em;
  --track-caps:   0.1em;
  --track-lockup: 0.34em;
  /* Display sizes need more negative tracking than text sizes: the larger
     the setting, the looser it reads at the same value. */
  --track-display: -0.032em;

  --weight-regular: 400;
  --weight-medium:  500;
  --weight-semi:    600;
  --weight-bold:    700;
  --weight-black:   700;  /* was 800: the chunk Fahad called cartoonish */
  --weight-display: 600;  /* every heading above ~30px sets at this */

  /* --- Space (4px base) ----------------------------------------------- */
  --space-1:  4px;
  --space-2:  8px;
  --space-3:  12px;
  --space-4:  16px;
  --space-5:  22px;
  --space-6:  26px;
  --space-7:  34px;
  --space-8:  44px;
  --space-9:  70px;

  /* --- Radii: sharp is the personality. Only the dock is a pill. ------ */
  --radius-none: 0;
  --radius-xs:   2px;
  /* 5px, added 2026-08-24 for the labelled-photo chips ("soft square", Fahad's
     pick from the label builder). The scale jumped 2px -> 14px with nothing in
     between, and a 2px chip reads sharp-cornered at 26px while 14px reads as a
     pill. Anything else wanting a softened square belongs on this token. */
  --radius-sm:   5px;
  --radius-sheet: 14px;
  --radius-pill: 99px;

  /* --- Elevation: three tiers, each tied to a purpose ----------------- */
  --shadow-menu:  0 30px 60px var(--zef-shadow-a35);
  --shadow-dock:  0 8px 30px var(--zef-shadow-a40);

  /* --- Motion (design-system-reference §5) ---------------------------- */
  --dur-micro:  .18s;
  --dur-enter:  .28s;
  --dur-large:  .6s;
  --ease-out:   ease-out;
  --ease-spring: cubic-bezier(.2, 1.25, .3, 1);

  /* --- Layout --------------------------------------------------------- */
  --page-max:    1240px;
  --header-h:    58px;
}

/* =====================================================================
   LAYER 2 — SEMANTIC (the only layer a re-skin touches)
   ===================================================================== */
:root {
  /* WHITE GROUND (Fahad, 2026-08-17): "instead of using so many different
     background colours, we use white for the background". The home page ran
     six grounds (paper, sunken grey, white, navy, black, photo); it now runs
     white panels with two deliberate dark ones. Paper survives as a faint
     tint for sunken areas, not as the page ground. A side effect that fixes
     the previous round: shadows were muddy on #E0E1DD and Fahad could not
     see the depth system at all. On white they read. */
  /* GROUND, 2026-08-20 (Fahad: "change all the background colours to the
     grey thats being used behind customer testimonials"). That grey was
     `--color-surface-sunken` and only the testimonial band wore it; it is
     now the site's ground, so every section reads the way that band did.
     RAISED STAYS WHITE deliberately: cards, forms and the booking panel
     have to sit ABOVE the ground for the depth system to say anything, and
     the testimonial band was always white cards on this grey.
     SUNKEN HAD TO DROP A STEP. It was the same #F4F4F2, so leaving it there
     would have made every recessed affordance — menu hover fills, the
     booking sheet's muted inputs, the date picker's day hover, the gate
     panels — invisible against the new ground. */
  --color-surface:            var(--zef-ground);
  --color-surface-raised:     var(--zef-white);
  --color-surface-sunken:     var(--zef-ground-2);
  /* THE CONTENT BANDS (Fahad, 2026-08-20: "use a more prominent light grey
     for Expertise, Process, Testimonials, it is looking too white"). The
     ground is #F4F4F2, which is barely off-white, and the homogenous-pages
     pass had put every band on it, so the page read as one white sheet.

     This is --zef-paper, the palette's own drawing-set paper, rather than a
     new grey invented for the job: it is the colour the whole direction is
     named after, it is a 1.19:1 step off the ground so it reads as grey
     instead of as a printing artefact, and black on it is 15.98:1. */
  --color-surface-band:       var(--zef-paper);
  /* THE GROUND TILE (2026-08-20). Honed concrete, generated to a written
     brief and dropped in by Fahad, replacing the flat band fill. It is a
     genuinely seamless tile: measured, left edge into right and top into
     bottom, at the same difference as any two adjacent columns.

     SHIPPED AT ITS NATIVE 1254px AND NEVER SCALED. Downscaling clamps at
     the frame edge instead of wrapping and manufactures a seam where there
     was none; so does compressing much below quality 78, because the fine
     grain that masks the join is the first thing a compressor throws away.
     Both were measured and both failed. See build-assets.sh.

     It sits on `background-color`, so a browser with no WebP gets the flat
     band colour, which is exactly what shipped before it. */
  --band-texture: url("../assets/ground.webp");
  --color-surface-inverse:    var(--zef-navy);
  --color-surface-deep:       var(--zef-black);

  --color-text:               var(--zef-ink);
  --color-text-muted:         var(--zef-graphite);
  --color-text-on-inverse:    var(--zef-paper);
  --color-text-muted-inverse: var(--zef-mist);
  --color-text-faint-inverse: var(--zef-slate);
  --color-text-on-deep:       var(--zef-slate-deep);

  --color-primary:            var(--zef-steel);
  --color-primary-hover:      var(--zef-steel-hover);
  /* The accent as a three-step ramp, added 2026-08-20 when the section heads
     and the service list took the accent (Fahad, option B). Lightest to
     darkest: -hover #47617F, -primary #3A5069, -deep #151C28. `-deep` is
     navy used as TEXT; --color-surface-inverse is the same primitive doing a
     surface job, and a component asking for dark accent type should not have
     to reach for a surface token to get it. */
  --color-primary-deep:       var(--zef-navy);
  --color-text-on-primary:    var(--zef-white);
  --color-on-primary:         var(--zef-white);
  --color-accent-inverse:     var(--zef-steel-light);

  --color-border:             var(--zef-ink);
  --color-border-soft:        var(--zef-ink-a20);
  --color-border-inverse:     var(--zef-paper-a20);
  --color-border-inverse-soft: var(--zef-paper-a17);

  /* Focus ring: steel on paper is 6.31:1, paper on navy is 13:1. The ring
     is drawn in currentColor so it stays visible on both grounds. */
  --color-ring:               currentColor;
  --ring-width:               2px;
  --ring-offset:              2px;

  --color-danger:             #8C2F1E;  /* form errors: paired with text+icon,
                                           never colour alone (a11y gate) */
}

/* =====================================================================
   LAYER 3 — COMPONENT vars + rules
   ===================================================================== */

/* --- reset + base ---------------------------------------------------- */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }

html {
  scroll-behavior: smooth;
  /* THE SAME CLIP AS body, AND IT IS NOT REDUNDANT (2026-08-29). Clipping
     `body` stops content spilling, but the INITIAL CONTAINING BLOCK is sized
     from `html`, so a phone still widens its layout viewport to whatever
     overflowed: the home page laid out at 415px on a 375px device and
     kitchens at 385px. That is worse than a scrollbar, because once the
     viewport widens EVERY element measures as fitting and the overflow
     audit reports zero. This pins the ICB at the device width so the defect
     shows up as a clipped element instead of a silently wrong page width.
     `clip`, never `hidden`: hidden makes html a scroll container and breaks
     position: sticky, which this site uses on the home sections. */
  overflow-x: clip;
}

/* Lenis smooth scroll (2026-08-19, the kodeimmersive round): the
   library's own reset. Native CSS smooth-scroll must yield while Lenis
   drives the window, or every anchor jump double-eases. Under reduced
   motion Lenis never starts and these classes never appear. */
html.lenis, html.lenis body { height: auto; }
.lenis.lenis-smooth { scroll-behavior: auto !important; }
.lenis.lenis-smooth [data-lenis-prevent] { overscroll-behavior: contain; }
.lenis.lenis-stopped { overflow: hidden; }

body {
  background: var(--color-surface);
  color: var(--color-text);
  font-family: var(--font-sans);
  font-size: var(--text-lg);
  line-height: var(--lh-body);
  /* clip, NOT hidden: overflow-x:hidden turns body into a scroll container
     and silently breaks every viewport-relative position:sticky on the page.
     clip does the same clipping without creating one. */
  overflow-x: clip;
  -webkit-font-smoothing: antialiased;
}

img { max-width: 100%; display: block; }
button { font-family: inherit; }
.mono { font-family: var(--font-mono); }

/* Visible focus on every focusable thing. The v8 board had none; a
   keyboard user could not see where they were. */
:focus-visible {
  outline: var(--ring-width) solid var(--color-ring);
  outline-offset: var(--ring-offset);
}

/* Skip link: first tab stop on every page. */
.skip {
  position: absolute; left: var(--space-3); top: calc(var(--space-3) * -6);
  z-index: 100;
  background: var(--color-surface-inverse);
  color: var(--color-text-on-inverse);
  padding: var(--space-3) var(--space-4);
  font-size: var(--text-base); font-weight: var(--weight-semi);
  text-decoration: none;
  transition: top var(--dur-micro) var(--ease-out);
}
.skip:focus { top: var(--space-3); }

.vh {
  position: absolute; width: 1px; height: 1px;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%);
  white-space: nowrap;
}

/* =====================================================================
   HEADER + MEGA MENU
   ===================================================================== */
/* ANY SECTION THAT CAN BE JUMPED TO MUST CLEAR THE FIXED BAR (2026-08-29,
   when about.html was retired and every About control became a jump to the
   home page's own About section). `header.site` is position: fixed, so a
   bare `#about` lands the section at viewport top and the bar covers the
   heading -- measured at 375: sectionTop 0 against a header 82px tall.
   `scroll-margin-top` is what fixes that; moving the section is not.
   The bar is 58px at rest on desktop and 82px on a phone, hence two values. */
section[id] { scroll-margin-top: calc(var(--header-h) + 40px); }
@media (max-width: 980px) {
  section[id] { scroll-margin-top: 106px; }
}

header.site {
  /* PAPER AT EVERY SCROLL POSITION (Fahad, 2026-08-20: "the ZEF Design
     Build text should be black in the top left corner"). The bar used to
     open navy with a WHITE wordmark and flip to paper past the hero. Two
     reasons that had to go: black text needs a light bar behind it, and
     the home hero is now a bright white kitchen, which a navy bar sat on
     like a sticker. The bar no longer changes character mid-scroll; only
     the shadow arrives, in `.on-paper` below. */
  --header-bg:        color-mix(in srgb, var(--color-surface) 90%, transparent);
  --header-fg:        var(--color-text);
  --header-border:    var(--color-border);
  /* THE WHOLE LOCKUP IS BLACK (Fahad, 2026-08-20: "ZEF Design Build is
     still not written in black"). "ZEF" already was; "DESIGN BUILD" under
     it was carrying --color-primary, the steel blue, which is what was
     still not black. The accent moved off the wordmark entirely. */
  --header-lockup-sub: var(--color-text);

  position: fixed; top: 0; left: 0; right: 0; z-index: 40;
  background: var(--header-bg);
  backdrop-filter: blur(8px);
  border-bottom: 1px solid var(--header-border);
  color: var(--header-fg);
  transition: background var(--dur-enter);
}
/* `.on-paper` no longer flips any colour: the bar is paper from the first
   pixel. main.js still toggles it and it still carries the raised shadow
   (further down), which is the one thing that should only appear once the
   bar is over content rather than over a hero. */

.hrow {
  display: flex; align-items: center; gap: var(--space-6);
  padding: var(--space-3) var(--space-5);
  position: relative;
}
/* Padded to a 44px tap target: the lockup is the home link on every page. */
.lockup { text-decoration: none; color: inherit; display: block; padding: 6px 0; min-height: 44px; }
.lockup b {
  font-weight: var(--weight-black); font-size: var(--text-xl);
  letter-spacing: var(--track-wide); display: block; line-height: 1;
}
.lockup span {
  font-size: var(--text-3xs); letter-spacing: var(--track-lockup);
  font-weight: var(--weight-semi); color: var(--header-lockup-sub);
  display: block; margin-top: 3px;
}

.menu {
  /* navigation-menu-4 port (Fahad's component, 2026-08-16). His amendment
     the same night: the nav sits in the MIDDLE of the header, keeping the
     component's look and motion. Plain labels, hover pill, rotating
     chevron on dropdown triggers. */
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  display: flex; gap: 2px; align-items: center;
}
.menu > a, .menu > button {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: var(--text-md); font-weight: var(--weight-semi);
  font-family: var(--font-sans);
  text-decoration: none; color: inherit; opacity: .82;
  background: none; border: none; cursor: pointer;
  padding: 8px 12px; border-radius: 6px;
  transition: background var(--dur-micro) var(--ease-out), opacity var(--dur-micro);
}
.menu > a:hover, .menu > button:hover,
.menu > button[aria-expanded="true"] {
  background: color-mix(in srgb, currentColor 10%, transparent);
  opacity: 1;
}
.menu > a[aria-current="page"] { opacity: 1; background: color-mix(in srgb, currentColor 14%, transparent); }
.menu .chev {
  width: 13px; height: 13px;
  transition: transform .3s var(--ease-out);
}
.menu > button[aria-expanded="true"] .chev { transform: rotate(180deg); }

/* The logo mark: the chevron cut from the client's file, transparent, beside
   the text lockup. The in-logo wordmark is illegible at header size, so the
   type lockup carries the name (screenshot-grade source; replace when Zain
   sends the original). */
.lockup { display: flex; align-items: center; gap: 10px; }
.lockup .mark { width: 40px; height: 45px; object-fit: contain; display: block; }
.lockup .ltext { display: block; }

/* ---- dropdown panels: compact anchored cards (the Radix viewport,
   ported). Solid popover surfaces in every header state. */
.dd {
  position: absolute; top: 100%; left: var(--dd-left, 240px); margin-top: 6px;
  background: var(--color-surface-raised); color: var(--color-text);
  border: 1px solid var(--color-border-soft); border-radius: 10px;
  box-shadow: 0 18px 50px rgba(10, 15, 23, .22);
  padding: var(--space-2);
  opacity: 0; transform: translateY(6px) scale(.98); pointer-events: none;
  transition: opacity .18s var(--ease-out), transform .18s var(--ease-out);
  z-index: 50;
}
.dd.open { opacity: 1; transform: none; pointer-events: auto; }
.dd .ddgrid { display: grid; grid-template-columns: repeat(2, 280px); gap: 2px; }
.dd .ddgrid a {
  display: flex; align-items: center; gap: 12px;
  text-decoration: none; color: inherit;
  padding: 8px 12px 8px 8px; border-radius: 8px;
  transition: background var(--dur-micro);
}
.dd .ddgrid a:hover { background: var(--color-surface-sunken); }
.dd .ddgrid .thumb {
  width: 56px; height: 72px; flex: none; overflow: hidden; border-radius: 6px;
  border: 1px solid var(--color-border-soft); background: var(--color-surface-sunken);
}
.dd .ddgrid .thumb img { width: 100%; height: 100%; object-fit: cover; display: block; }
.dd .ddgrid b { display: block; font-size: var(--text-md); font-weight: var(--weight-bold); line-height: 1.2; }
.dd .ddgrid i {
  display: block; font-style: normal; margin-top: 3px;
  font-size: var(--text-sm); line-height: 1.45; color: var(--color-text-muted);
}

/* ---- mobile popover (replaces the full-screen sheet; the component
   anchors a small card under the trigger). */
.mpop {
  position: absolute; top: 100%; left: var(--space-3); margin-top: 6px;
  width: 264px; max-height: min(70vh, 560px); overflow-y: auto;
  background: var(--color-surface-raised); color: var(--color-text);
  border: 1px solid var(--color-border-soft); border-radius: 10px;
  box-shadow: 0 18px 50px rgba(10, 15, 23, .25);
  padding: var(--space-1);
  opacity: 0; transform: translateY(6px) scale(.98); pointer-events: none;
  transition: opacity .18s var(--ease-out), transform .18s var(--ease-out);
  z-index: 50;
}
.mpop.open { opacity: 1; transform: none; pointer-events: auto; }
/* 44px MINIMUM, NOT 38 (2026-08-29). These rows were 249x38 -- six pixels
   under the touch minimum, on the only navigation a phone has. The height
   is set with min-height plus centring rather than more padding, so the
   text keeps its position and only the target grows. */
.mpop a {
  display: flex; align-items: center; min-height: 44px;
  text-decoration: none; color: inherit;
  font-size: var(--text-md); font-weight: var(--weight-medium);
  padding: 9px 10px; border-radius: 7px;
}
.mpop a:hover { background: var(--color-surface-sunken); }
.mpop .grp {
  padding: 7px 10px 3px; font-size: var(--text-xs);
  font-weight: var(--weight-medium); color: var(--color-text-muted);
}
.mpop .sep { height: 1px; background: var(--color-border-soft); margin: 4px -4px; }

.hright { margin-left: auto; display: flex; align-items: center; gap: var(--space-2); }
.ghost-btn {
  color: inherit; opacity: .82; text-decoration: none;
  font-size: var(--text-md); font-weight: var(--weight-semi);
  padding: 8px 12px; border-radius: 6px;
  transition: background var(--dur-micro), opacity var(--dur-micro);
}
.ghost-btn:hover { background: color-mix(in srgb, currentColor 10%, transparent); opacity: 1; }
.quote-btn {
  --btn-bg: var(--color-primary);
  --btn-fg: var(--color-on-primary);
  background: var(--btn-bg); color: var(--btn-fg);
  padding: 10px var(--space-4); border-radius: var(--radius-xs);
  font-weight: var(--weight-bold); font-size: var(--text-md);
  border: none; cursor: pointer; text-decoration: none; display: inline-block;
}
.quote-btn:hover { --btn-bg: var(--color-primary-hover); }

/* ---- burger: the component's three lines morphing to an X, driven by
   aria-expanded (its group-aria-expanded classes, ported). Sits LEFT of the
   logo on mobile, per the component. */
.burger {
  display: none; background: none; border: none;
  color: inherit; cursor: pointer;
  padding: 10px; margin-left: -6px;
  min-width: 44px; min-height: 44px;
}
.burger svg { width: 20px; height: 20px; stroke: currentColor; fill: none; stroke-width: 2; stroke-linecap: round; }
.burger path { transform-origin: center; transition: transform .3s cubic-bezier(.5, .85, .25, 1.1); }
.burger .l1 { transform: translateY(-7px); }
.burger .l2 { transition-timing-function: cubic-bezier(.5, .85, .25, 1.8); }
.burger .l3 { transform: translateY(7px); }
.burger[aria-expanded="true"] .l1 { transform: translateY(0) rotate(315deg); }
.burger[aria-expanded="true"] .l2 { transform: rotate(45deg); }
.burger[aria-expanded="true"] .l3 { transform: translateY(0) rotate(135deg); }

/* =====================================================================
   THE MENU BUTTON (Fahad, 2026-08-20). The bar is removed from the top on
   desktop and only arrives on intent, which meant nothing on screen ever
   said a menu existed. This is that affordance.

   IT SITS UNDER THE SHEETS AND OVER THE DOCK (46: dock is 45, sheets 60),
   and it takes itself off screen once the bar it opens is showing, or it
   would sit on top of the bar's own "Get a quote".
   ===================================================================== */
.navhint {
  position: fixed; top: var(--space-4); right: var(--space-4); z-index: 46;
  display: none; align-items: center; gap: 9px;
  min-height: 44px; padding: 0 var(--space-5);
  background: var(--color-surface); color: var(--color-text);
  border: 1.5px solid var(--color-border); border-radius: var(--radius-pill);
  font-family: inherit; font-size: var(--text-md);
  font-weight: var(--weight-semi); letter-spacing: var(--track-wide);
  text-transform: uppercase; cursor: pointer;
  box-shadow: var(--shadow-dock);
  transition: opacity var(--dur-enter) var(--ease-out),
              transform var(--dur-enter) var(--ease-out);
}
.navhint svg {
  width: 18px; height: 18px; stroke: currentColor; fill: none;
  stroke-width: 2; stroke-linecap: round;
}
.navhint:hover { background: var(--color-surface-sunken); }
/* Desktop only: phones keep the bar and its burger on screen the whole
   time, and only under html.js, because with no JS the bar is never hidden
   and this would toggle nothing. */
@media (min-width: 901px) { html.js .navhint { display: inline-flex; } }
/* Open means the bar has arrived and this has done its job. aria-expanded
   is the state of the menu it controls, which is exactly right. */
.navhint[aria-expanded="true"] {
  opacity: 0; transform: translateY(-10px); pointer-events: none;
}

@media (max-width: 980px) {
  .menu { display: none; }
  .burger { display: grid; place-items: center; }
  .ghost-btn { display: none; }
  /* THE DROPDOWN PANEL MUST GO WITH THE MENU THAT OPENS IT (2026-08-29).
     `.dd` is a SIBLING of `.menu`, not a child, so hiding the menu left the
     panel laid out: 568px wide anchored at x=246, reaching x=814 on a 375px
     phone. Chrome answered by widening the home page's layout viewport to
     415px, so the whole page rendered at the wrong width and every measured
     element read as fitting. `opacity: 0` does not remove a box from layout;
     only `display: none` does. Nothing here is reachable at this width
     anyway -- the burger and the mobile sheet own navigation below 980. */
  .dd { display: none; }
}
@media (max-width: 620px) {
  .lockup .ltext span { display: none; }
}

/* =====================================================================
   LEAD DOCK — persistent on every page (sitemap amendment 3)
   ===================================================================== */
/* CENTRED AGAIN (Fahad, 2026-08-20, reversing the bottom-right move he asked
   for an hour earlier). Back on `left: 50%` and a translate.

   THE LABELS STAY. They came back with the corner move, and the corner move
   is what has been undone, not the text: the bar is the 425px labelled pill
   in the middle of the screen now, which is what it was before the
   icons-only pass. That pass shrank it to 218 on the argument that 425 was
   too much furniture in the centre; both of those calls are his, and this is
   the later one. */
/* BLACK, NOT THE NAVY SURFACE (Fahad, 2026-08-28: "make the background
   panel black with the symbols inside it blue"). --dock-bg is read by the
   Callback mark's knockouts as well as by this background, which is what
   keeps the holes in that symbol the same colour as the panel behind it;
   change this and the mark follows on its own. */
.dock {
  --dock-bg: var(--zef-black);
  position: fixed; z-index: 45; display: flex; gap: var(--space-2);
  left: 50%; transform: translateX(-50%); bottom: var(--space-3);
  background: var(--dock-bg); border: 1px solid var(--color-border-inverse);
  border-radius: var(--radius-pill); padding: var(--space-2);
  box-shadow: var(--shadow-dock);
}
.dock a {
  display: flex; align-items: center; justify-content: center; gap: 9px;
  color: var(--color-text-on-inverse); text-decoration: none;
  font-size: var(--text-md); font-weight: var(--weight-semi);
  padding: 9px var(--space-3); border-radius: var(--radius-pill);
  white-space: nowrap; min-height: 44px; min-width: 44px;
}
.dock a svg { width: 21px; height: 21px; flex: none; }
/* CALL IS THE ONE MARKED ITEM, AND IT INVERTS RATHER THAN COLOURS (Fahad,
   2026-08-28: "remove the blue inside as well, black and white is good").
   It carried --color-primary, the steel accent, which was the last blue
   left in the bar once the symbols went white: a grey-blue lozenge on
   black that read as a smudge rather than a highlight, with the mark on
   top of it down at about 3.2:1. White pill, black contents, and the
   emphasis survives the palette going monochrome.

   `color` is what carries it, not a second icon: every mark in the bar is
   filled `currentColor`, so flipping this one property turns the phone
   glyph AND the word black together. The Callback calendar's knockouts
   stay on --dock-bg, which is correct -- they are holes to the panel, and
   that mark never sits on this pill.

   THE HOVER RULE HAS TO BE RESTATED HERE. `.dock a:hover` matches at the
   same specificity and comes after, so without this Call would take the
   translucent white on hover and put black text on near-black. */
.dock a.call { background: var(--zef-white); color: var(--zef-ink); }
.dock a:hover { background: var(--zef-white-a10); }
.dock a.call:hover { background: var(--zef-paper); }
@media (max-width: 760px) {
  /* A phone gets the full-width bar. Sized rather than pinned to both
     margins, so the centring transform above still does the placing and
     there is one rule deciding where this thing sits. */
  .dock { width: calc(100% - 20px); justify-content: space-between; }
  .dock a { padding: 11px var(--space-2); }
}
/* THE LABELS COME OFF BELOW 520px, AND THIS IS THE ONE PLACE THEY DO. Four of
   them plus their icons need about 350px of bar, and under 520 the bar is not
   that wide: keeping them would wrap the pill into two rows across the bottom
   of the screen.

   HIDDEN FROM THE EYE, NOT FROM THE MACHINE. `display: none` would take the
   text out of the accessibility tree too and leave four links whose only name
   is an aria-hidden SVG, which a screen reader reads as "link, link, link,
   link". This is the site's own `.vh` technique, so each one still announces
   Call, Text, WhatsApp or Callback. */
@media (max-width: 520px) {
  .dock a span {
    position: absolute; width: 1px; height: 1px;
    overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%);
    white-space: nowrap;
  }
  .dock a { gap: 0; padding: 11px var(--space-3); }
}

/* =====================================================================
   HERO
   ===================================================================== */
.hero-stage {
  --hero-scrim: linear-gradient(90deg,
    rgba(244, 244, 242, .97) 0%, rgba(244, 244, 242, .93) 24%,
    rgba(244, 244, 242, .60) 44%, rgba(244, 244, 242, .10) 62%,
    rgba(244, 244, 242, 0) 74%);
  position: relative; min-height: 100vh; overflow: hidden;
  background: var(--color-surface);
}
.hero-stage .gridbg {
  position: absolute; inset: 0; opacity: .28;
  background-image:
    linear-gradient(var(--zef-ink-a05) 1px, transparent 1px),
    linear-gradient(90deg, var(--zef-ink-a05) 1px, transparent 1px);
  background-size: 56px 56px;
}
.hero-photo { position: absolute; inset: 0; pointer-events: none; }
.hero-photo img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 50%; }
.hero-photo::after { content: ""; position: absolute; inset: 0; background: var(--hero-scrim); }

.hero-inner {
  position: relative; min-height: 100vh; display: grid;
  grid-template-columns: minmax(0, 560px); align-items: center;
  max-width: var(--page-max); margin: 0 auto;
  padding: var(--space-9) var(--space-5); gap: var(--space-5);
}
.hero-copy { color: var(--color-text); }
/* THE BASE HERO H1, unchanged, and it serves the EIGHT COMPACT HEROES.
   Restored 2026-08-20 after the home restyle below was written in its place
   rather than on top of it: the inner heroes quietly lost `line-height` and
   fell back to `normal`, which is 82.5px on a 50px Anton head. Family,
   weight and tracking they get from the shared display-type rule further up,
   so line-height and the measure are what this has to keep carrying. */
.hero-copy h1 {
  font-size: clamp(34px, 5vw, 58px); line-height: var(--lh-tight);
  letter-spacing: var(--track-display); font-weight: var(--weight-display);
  max-width: 13ch;
}
/* Still live on about.html and legal-basements.html. The compact heroes
   override it with `--color-accent-inverse`; this is the fallback. */
.hero-copy h1 .l2 { color: var(--color-primary); }

/* SCOPE WARNING, and it cost a round on 2026-08-20. EVERY RULE IN THIS BLOCK
   IS SCOPED `.hero-stage.home`, not `.hero-copy`. `.hero-copy` is the SHARED
   hero class: the home hero is `.hero-stage.home` on a scrimmed photograph,
   but all eight inner pages use `.hero-stage.compact`, which sits on NAVY
   with paper text. Written against `.hero-copy` these rules leaked to nine
   pages and broke eight -- the h1 became uppercase Anton in steel at 2.06:1
   on the navy, `.btn2` fell to 2.67:1, and the black `.btn1` turned into a
   near-invisible block on a dark ground. If you add anything here, scope it
   the same way and check one inner page before believing it.

   THE HOME H1 IS SET AS A SECTION HEAD (Fahad, 2026-08-20, from a screenshot
   of "EXPERTISE."): same family, weight, tracking, leading, case, accent and
   full stop. It was Instrument Sans at 58px, which is why it did not read as
   part of the same site as the heads below it.

   THE SIZE IS NOT THE HEAD'S, AND THE CEILING IS SET BY THE COLUMN, NOT BY
   THIS RULE'S max-width. Section heads run clamp(50px, 12.4vw, 180px) across
   a 1240px column. The hero copy sits in `.hero-inner`'s grid track, which is
   `minmax(0, 560px)` -- so the 13ch measure below (642px at this size) is NOT
   the binding constraint and reading it as one is misleading. Measured at
   1440: "OUR EXECUTION." sets 596px at 100px, over the 560px track, and broke
   to three lines with "OUR" stranded on its own. 92px puts it at 548px and
   holds the intended two.
   RAISING IT COSTS CONTRAST, so do not raise it without re-measuring. The
   scrim behind this column is 97% opaque at the left edge and thins to 60%
   by 44% across and 10% by 62%, so every pixel the line grows to the right
   is crossing more live photograph. At 92px the line ends near 47% of the
   viewport; widening the track to fit 100px would push it to ~50%, where the
   scrim is only ~43% and the kitchen behind it starts showing through the
   counters of the letters.
   MEASURED AT 92px, and it passes: the hero was rendered twice, once with
   this heading `visibility: hidden`, and the pixel difference located all
   46,535 glyph pixels exactly. Against what actually sits behind each one --
   median 7.11:1, worst 5% 5.51:1, worst 1% 4.43:1, and NOT ONE pixel under
   the 3:1 bar for type this size. Do that same two-shot diff if the size,
   the track or the scrim ever changes; sampling the heading's bounding BOX
   instead reads the bare photograph beside the shorter first line and
   reports a failure that is not there. */
.hero-stage.home .hero-copy h1 {
  font-family: var(--font-display);
  font-size: clamp(40px, 6.4vw, 92px);
  font-weight: 400;            /* Anton ships one weight */
  letter-spacing: .01em;
  /* NOT the heads' .84. That value is safe there because those heads set on
     ONE line; this one sets on two, and at .84 the advance (77px at 92px) is
     shorter than line one's own ink (67px of cap plus a 10px comma
     descender), so the comma of "YOUR PLAN," ran into the caps of "OUR
     EXECUTION." below it. Measured: the two lines came back as a single
     contiguous 157px ink run, i.e. touching.
     THE COMMA IS THE BINDING GLYPH, not the caps. Scanned at 92px: at .95 the
     caps of line two clear line one by 12px, but the comma descends into that
     channel and clears them by 2px, which is touching by eye. 1.0 puts the
     1.0 barely helped (3px): the block re-centres in the hero as the leading
     grows, so part of every increase is spent moving both lines together
     rather than separating them. 1.1 is what actually opens the channel.
     Re-scan the INK ROWS if the size changes -- rect geometry will not show
     this, because a text rect is the font's whole em box and those overlap
     long before the ink does, and a bounding-box check reports a collision
     between every pair of lines whether or not one exists. */
  line-height: 1.1;
  text-transform: uppercase;
  color: var(--color-primary);
  max-width: 13ch;
  margin-top: -.07em;          /* pull the capline up, as the heads do */
}
/* Same rule as `.sechead-stop`: the stop inherits, so it can never disagree
   with its own word and read as a chip parked after it. */
.hero-stage.home .hero-stop { color: inherit; }
/* K1, chosen 2026-08-20 from four rendered splits: the heading is TWO-TONE,
   black over the accent. The halves are separately colourable because the
   lockup has always been two-tone and the restyle briefly flattened it.
   `.hl-b` holds the stop, so punctuation follows its own half. */
.hero-stage.home .hero-copy h1 .hl-a { color: var(--color-text); }
.hero-stage.home .hero-copy h1 .hl-b { color: var(--color-primary); }
/* THE HERO'S PRIMARY BUTTON IS INK, and only the hero's. `.btn1` is the
   site's primary button (17 of them across 9 pages, form submits included);
   this is scoped to `.hero-copy` because the reason is compositional, not
   systemic -- it balances the black half of the heading directly above it.
   If the whole site's primary should go black, that is a separate ruling and
   belongs on `.btn1` itself, not here. */
.hero-stage.home .hero-copy .btn1 {
  background: var(--color-text);
  border-color: var(--color-text);
}
.hero-stage.home .hero-copy .btn1:hover {
  background: var(--zef-graphite);
  border-color: var(--zef-graphite);
}
/* THE CALL BUTTON TAKES THE ACCENT, matching "our execution." above it
   (Fahad, 2026-08-20). `.btn2` draws its border from `currentColor`, so
   setting the colour alone turns the label AND the rule blue; there is no
   second declaration to keep in step, and the hover tint (an 8% mix of
   currentColor) follows on its own.
   IT STAYS AN OUTLINE. Filling it would put two solid blocks side by side
   and the pair would read as two primary actions; the two buttons carry the
   heading's own two colours while the fill still says which one is the
   main route. */
.hero-stage.home .hero-copy .btn2 { color: var(--color-primary-hover); }
/* THE HOVER IS PINNED, and that is the whole reason this rule exists (Fahad,
   2026-08-20: the hover colour "is perfect and no changes need to be made").
   `.btn2` takes its border and its hover wash from `currentColor`, so
   lightening the RESTING colour above would have dragged the hover state
   lighter with it -- the one thing that was asked to stay put. Naming both
   ends explicitly freezes the hover at the steel it is today while the rest
   sits a step lighter.
   The direction is the same one the service list runs: lighter at rest,
   deeper on engagement. #47617F measures 5.81:1 on the hero ground, over the
   4.5:1 a button label needs; --zef-steel-light was the other candidate and
   was rejected at 2.29:1. */
.hero-stage.home .hero-copy .btn2:hover,
.hero-stage.home .hero-copy .btn2:focus-visible {
  color: var(--color-primary);
  background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
/* The line that replaced the what-is-ZEF paragraph. Ink rather than muted and
   a step larger, because it is now a hand-off to the two buttons rather than
   body copy someone is expected to read through. */
.hero-stage.home .hero-copy p.herolede {
  font-size: clamp(16px, 1.4vw, 19px);
  color: var(--color-text);
  max-width: 32ch;
}
.hero-copy p {
  /* Condensed to sit with the Anton headline, at 300 so it is genuinely
     light. This is the "unbold" Fahad asked for; the weight is real, not a
     synthesised lighter face, which browsers cannot make anyway. */
  font-family: var(--font-lead); font-weight: 300; letter-spacing: .012em;
  margin-top: var(--space-4); max-width: 44ch;
  /* 1.75, not 1.6: Anton's x-height is tall and its counters are closed, so
     the lines crowd each other at the leading a normal-width face is happy
     with. Measure widened to 48ch for the same reason — a condensed face
     fits more characters per line at the same point size. */
  font-size: 15px; line-height: 1.75; color: var(--color-text-muted);
}
.hero-copy p { max-width: 48ch; }
.hero-copy .ctas { margin-top: var(--space-6); display: flex; gap: var(--space-3); flex-wrap: wrap; }

/* Inner-page hero: same stage, shorter, no photograph. */
.hero-stage.compact, .hero-stage.compact .hero-inner { min-height: 0; }
.hero-stage.compact .hero-inner {
  padding: 128px var(--space-5) var(--space-9);
  grid-template-columns: minmax(0, 720px);
}
.hero-stage.compact { background: var(--color-surface-inverse); }
.hero-stage.compact .hero-copy { color: var(--color-text-on-inverse); }
.hero-stage.compact .hero-copy h1 { max-width: 20ch; font-size: clamp(30px, 4.4vw, 50px); }
/* The flagship H1 is the longest on the site (copy deck B9) and grew again on
   2026-08-19 with "Legal basement apartments". At the shared 20ch it broke to
   three lines and left "code" alone on the last one. Wider measure plus
   `balance` so the engine evens the lines instead of orphaning a word; scoped
   to this one heading so every other compact hero keeps its 20ch. */
.hero-stage.compact .hero-copy h1.h1-long { max-width: 30ch; text-wrap: balance; }
.hero-stage.compact .hero-copy h1 .l2 { color: var(--color-accent-inverse); }
.hero-stage.compact .hero-copy p { color: var(--color-text-muted-inverse); max-width: 56ch; }
.hero-stage.compact .gridbg {
  opacity: 1;
  background-image:
    linear-gradient(var(--zef-paper-a12) 1px, transparent 1px),
    linear-gradient(90deg, var(--zef-paper-a12) 1px, transparent 1px);
}

@media (max-width: 860px) {
  /* Portrait crops to ~26% of the frame: bias right so the navy wall and the
     kitchenette survive, not the blank credenza run. */
  .hero-photo img { object-position: 52% 50%; }
  /* Protect the copy block (top ~52%), then clear fast. A scrim held high all
     the way down erases the photograph entirely. */
  .hero-stage {
    --hero-scrim: linear-gradient(180deg,
      rgba(244, 244, 242, .96) 0%, rgba(244, 244, 242, .94) 46%,
      rgba(244, 244, 242, .88) 60%, rgba(244, 244, 242, .34) 74%,
      rgba(244, 244, 242, .05) 100%);
  }
  /* Top-aligned: with the eyebrow gone, centring drops the copy into the
     released part of the scrim and the furniture cuts through the buttons. */
  .hero-inner {
    grid-template-columns: 1fr; align-content: start; gap: var(--space-2);
    padding: 112px var(--space-5) var(--space-5);
  }
  .hero-stage.compact .hero-inner { padding: 108px var(--space-5) var(--space-8); }
}

/* =====================================================================
   BUTTONS
   ===================================================================== */
.btn1, .btn2 {
  padding: var(--space-3) var(--space-5); border-radius: var(--radius-xs);
  font-weight: var(--weight-semi); font-size: var(--text-lg);
  text-decoration: none; cursor: pointer; display: inline-block;
  min-height: 44px; line-height: 1.4;
}
.btn1 { background: var(--color-primary); color: var(--color-on-primary); border: 1.5px solid var(--color-primary); }
.btn1:hover { background: var(--color-primary-hover); border-color: var(--color-primary-hover); }
.btn2 { border: 1.5px solid currentColor; background: none; color: inherit; }
.btn2:hover { background: color-mix(in srgb, currentColor 8%, transparent); }

/* =====================================================================
   SHARED SECTION SHELL
   ===================================================================== */
section .inner { max-width: var(--page-max); margin: 0 auto; padding: var(--space-9) var(--space-5); }
h1.sec, h2.sec { font-size: clamp(26px, 3.6vw, 42px); font-weight: var(--weight-display); letter-spacing: var(--track-display); }

/* =====================================================================
   THE EDITORIAL SECTION HEAD (Fahad, 2026-08-20, working from a reference
   image): an ordinal and a small label in a narrow left column, a hairline
   across the top, and the section name set oversized in Anton with a full
   stop after it.

   IT REVERSES RULE 53 on purpose. That rule bans eyebrow and kicker labels
   and says headings stand alone; "01 / WHAT WE DO" is exactly a kicker.
   Fahad asked for it with a reference in hand, so the rule now carries a
   named exception instead of being quietly broken.

   WHAT WAS TAKEN FROM THE REFERENCE and what was not. Taken: the scale, the
   left-column ordinal, the hairline between blocks, the full stop, the
   ratio of tiny label to enormous word. NOT taken: the blue. Fahad ruled
   that out explicitly, and this site already has an established heading
   colour — `--color-text`, pure ink — so the heading is ink on the ground.
   The steel accent appears twice and only twice, on the ordinal and on the
   full stop, which is the "used sparingly" instruction taken literally.

   INTER WAS NOT ADDED for the labels either, despite being suggested: rule
   50 flags Inter as the standard-AI tell, and the site already has a clean
   sans in Instrument Sans doing that job everywhere else. Introducing a
   third family to do what the second already does is how a design starts
   looking assembled rather than designed.
   ===================================================================== */
.sechead {
  display: grid;
  grid-template-columns: minmax(76px, 7%) minmax(0, 1fr);
  gap: var(--space-5);
  align-items: start;
  border-top: 1.5px solid var(--color-border-soft);
  padding-top: var(--space-5);
  margin-bottom: var(--space-7);
}
.sechead-meta { display: grid; justify-items: start; gap: var(--space-3); }
.sechead-num {
  font-family: var(--font-display); font-weight: 400;
  font-size: clamp(24px, 2.4vw, 38px); line-height: .9;
  /* INK, NOT STEEL (Fahad, 2026-08-20). The accent moved to the title, and
     an ordinal in the same colour as the word beside it stopped separating
     the two columns. The tick follows the number: they are one unit, and a
     black number with a steel tick reads as an oversight. */
  color: var(--color-text);
}
.sechead-tick { width: 20px; height: 1.5px; background-color: var(--color-text); }
.sechead-label {
  font-size: var(--text-xs); font-weight: var(--weight-bold);
  letter-spacing: .16em; text-transform: uppercase;
  /* Ink, not muted (Fahad, 2026-08-20: "any fine writing like subcopy should
     also be black"). Scoped to this component on purpose -- the ruling was
     for THIS section's small text, not for --color-text-muted itself, which
     still carries body copy on every page. */
  color: var(--color-text); line-height: 1.6;
  /* 11ch, not 9: at 9 the longest label broke to THREE lines
     ("WHAT / THEY / SAY") where the reference sets every one of them on two. */
  max-width: 11ch;
}
/* The statement. Specificity beats both the base `h2.sec` and `.prohead h2`,
   which is why the process head does not need a special case. */
.sechead h1.sec,
.sechead h2.sec {
  font-family: var(--font-display);
  /* Sized so the LONGEST head, "Testimonials.", fills ~93% of its column at
     desktop, which is what the reference does. The shorter two then land at
     70% and 60% and that variation IS the reference: in it, TESTIMONIALS.
     runs nearly edge to edge and PROCESS. plainly does not. Measured, not
     guessed: at the first setting the words filled 44-52% and read timid. */
  /* Dropped from 13.2vw/194px when the tracking went positive for Anton:
     added letter-spacing widens every head, and "Testimonials." went from
     94% of its column to 99%, which is one character away from wrapping.
     Re-measured at 92% with the new tracking, which is the headroom the
     first sizing pass had. */
  font-size: clamp(50px, 12.4vw, 180px);
  font-weight: 400;            /* Anton ships one weight; see --font-display */
  /* Was -.012em. At 194px in Anton a negative value welds the letters
     together; this is the same correction as --track-display, scaled for a
     setting four times larger. */
  letter-spacing: .01em;
  line-height: .84;
  text-transform: uppercase;
  /* THE HEAD IS THE ACCENT (Fahad, 2026-08-20, option B of four rendered).
     Steel, not navy: navy measures 8.90:1 on this ground but carries a
     chroma of 19, so it passes contrast by being a near-black and never
     reads as a colour. Steel is chroma 47 at 4.32:1 and actually reads
     blue, which is the whole point of the change. */
  color: var(--color-primary);
  /* Anton carries deep internal leading, so the cap sits low in its box.
     This pulls the capline up to the ordinal's, which is what makes the two
     columns read as one block instead of two stacked things. */
  margin-top: -.07em;
}
/* THE FULL STOP FOLLOWS ITS OWN WORD, and that is the SAME rule that used
   to hold it to ink, not a reversal of it.

   The original finding: Anton's period is a filled RECTANGLE, not a round
   dot, so at 194px a stop that disagrees with the word beside it reads as a
   stray coloured chip parked after it rather than as punctuation. While the
   word was ink, that meant the stop had to be ink.

   2026-08-20 the word became steel, which inverts the problem exactly: an
   ink stop after a steel word is now the stray chip. It was rendered and
   confirmed before this was changed. `inherit` is the durable form of the
   rule -- it holds whichever colour the head takes next.
   If the round dot of the reference is wanted, the stop is already its own
   span: setting `font-family: var(--font-sans)` on this rule gets it. */
/* See above: only the wrapping page titles get the looser leading. */
.sechead.pagetitle h1.sec { line-height: .92; }
.sechead-stop { color: inherit; }
/* Phones: the meta turns into one short line above the word rather than a
   column beside it, which would otherwise steal a third of a 390px screen
   from the thing that is supposed to be the statement. */
@media (max-width: 700px) {
  .sechead { grid-template-columns: minmax(0, 1fr); gap: var(--space-4); padding-top: var(--space-4); }
  .sechead-meta { display: flex; align-items: center; gap: var(--space-3); }
  .sechead-num { font-size: var(--text-2xl); }
  .sechead-label { max-width: none; }
  .sechead h1.sec, .sechead h2.sec { margin-top: 0; }
}
h3.sub { font-size: var(--text-2xl); font-weight: var(--weight-semi); letter-spacing: var(--track-snug); }
.lead {
  margin-top: var(--space-3); color: var(--color-text-muted);
  font-size: var(--text-lg); line-height: var(--lh-body); max-width: 54ch;
}
/* BODY LINKS (2026-09-09, found when the 404 page was built).
   THE 404 PAGE IS THE FIRST PAGE ON THIS SITE WITH A PLAIN INLINE LINK IN
   BODY COPY. Every other link here is a button, a nav item, a door or a dock
   channel, each with its own class -- so no generic anchor style ever existed
   and the 404's links rendered in browser-default blue with a default
   underline, which is the one colour nothing else on this site uses.
   Scoped to .prose and .svclist deliberately: a bare `a {}` reset would fight
   every component that styles its own anchors. Checked before adding: no
   other built page has an anchor inside either container, so this changes
   exactly one page today and sets the convention for the next one. */
.prose a, .svclist a {
  color: var(--color-primary);
  text-decoration: underline;
  text-underline-offset: 3px;
  text-decoration-thickness: 1px;
  transition: color var(--dur-micro, .15s) var(--ease-out);
}
.prose a:hover, .svclist a:hover {
  color: var(--color-primary-hover);
  text-decoration-thickness: 2px;
}
.prose a:focus-visible, .svclist a:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 3px;
  border-radius: 2px;
}

.prose { max-width: 68ch; }
.prose p { margin-top: var(--space-4); color: var(--color-text-muted); font-size: var(--text-lg); line-height: var(--lh-body); }
.prose p:first-child { margin-top: 0; }

/* Reveal never gates visibility on JS: hidden only once .js is confirmed,
   with a timed failsafe in main.js (prod-rules #31). */
.reveal { opacity: 1; transform: none; }
html.js .reveal {
  opacity: 0; transform: translateY(24px);
  transition: opacity var(--dur-large) var(--ease-out), transform var(--dur-large) var(--ease-out);
}
html.js .reveal.in { opacity: 1; transform: none; }

/* =====================================================================
   SERVICE TILES — full images at native ratio (Fahad, 2026-08-16 night:
   nothing zoomed, nothing cropped). Each thumb box carries its image's
   native aspect ratio via --ar, so cover fits edge to edge. The row wraps
   centered, which is what keeps a five-tile set from opening the empty
   sixth slot. Reveals stagger by --i, and each image settles as it lands.
   ===================================================================== */
/* align-items: flex-start, not the flex default of stretch: the thumbs carry
   their images' native ratios, so a stretched tile grows taller than its own
   photograph and opens a white band under it. */
.tiles { margin-top: var(--space-6); display: flex; flex-wrap: wrap; justify-content: center; align-items: flex-start; gap: var(--space-3); }
.tile {
  flex: 0 1 calc((100% - 2 * var(--space-3)) / 3);
  display: block; text-decoration: none; color: var(--color-text);
  border: 0; background: var(--color-surface-raised);
  overflow: hidden;
  transition: transform var(--dur-enter) var(--ease-out), box-shadow var(--dur-enter) var(--ease-out);
}
.tile:hover { transform: translateY(-6px); box-shadow: 0 22px 46px rgba(0,0,0,.17); }
.tile .tthumb {
  position: relative; display: block; aspect-ratio: var(--ar, 3 / 4);
  background: var(--color-surface-sunken); overflow: hidden;
}
.tile .tthumb img {
  width: 100%; height: 100%; object-fit: cover; display: block;
  transition: transform .5s var(--ease-out);
}
.tile:hover .tthumb img { transform: scale(1.02); }
.tile .tthumb::after {
  content: ""; position: absolute; inset: 0; pointer-events: none;
  /* Strengthened for the 4:3 crop (2026-08-17): the shorter box puts the
     label over the brightest part of several photographs, and the old ramp
     left white text on a white kitchen floor. Contrast is not optional. */
  background: linear-gradient(180deg, rgba(10,15,23,0) 24%, rgba(10,15,23,.42) 52%, rgba(10,15,23,.78) 76%, rgba(10,15,23,.96) 100%);
}
.tile .tcopy {
  position: absolute; left: 0; right: 0; bottom: 0; z-index: 2;
  padding: var(--space-4); color: var(--color-text-on-inverse);
}
.tile .tcopy b { display: block; font-size: var(--text-xl); font-weight: var(--weight-semi); letter-spacing: var(--track-snug); }
.tile .tcopy i { display: block; font-style: normal; margin-top: 4px; color: var(--color-text-muted-inverse); font-size: var(--text-md); line-height: 1.5; }
.tile .tcopy .arrow { display: block; margin-top: var(--space-2); color: var(--color-accent-inverse); }

/* Staggered entrance: each tile arrives 90ms after the one before it, and
   the photograph settles from a slight zoom as the card lands. */
html.js .tile.reveal { transition-delay: calc(var(--i, 0) * 90ms); }
html.js .tile.reveal .tthumb img { transform: scale(1.06); transition: transform .9s var(--ease-out) calc(var(--i, 0) * 90ms); }
html.js .tile.reveal.in .tthumb img { transform: scale(1); }
html.js .tile.reveal.in:hover .tthumb img { transform: scale(1.02); }

/* Uniform tile photography (Fahad, 2026-08-17 second round: "pictures are
   far too big", "it should look uniform"). ONE shared 4:3 box for all six
   tiles, filled with cover. The native-ratio rule that governed these tiles
   is deliberately off here and only here: the sources are 2:3 portraits, and
   at three across that made every tile 570px tall. It still governs
   galleries and project photography, where the subject is the work. */
.tile .tthumb { aspect-ratio: 4 / 3; }
.tiles.six .tile { flex: 0 1 calc((100% - 2 * var(--space-3)) / 3); }

/* The flagship: copy panel left, photograph right, and the two doors are
   real links onto the flagship page's own anchors. A DIV, not an anchor,
   because it contains links. */
.tile.xl {
  flex: 1 1 100%; display: grid; grid-template-columns: 1fr minmax(280px, 460px);
  background: var(--color-surface-inverse); color: var(--color-text-on-inverse);
  border-color: var(--color-surface-inverse); margin-bottom: var(--space-3);
}
.tile.xl:hover { transform: none; border-color: var(--color-surface-inverse); }
.tile.xl .xlcopy {
  display: flex; flex-direction: column; justify-content: center;
  padding: var(--space-8) var(--space-7); gap: var(--space-3);
}
.xlkicker {
  color: var(--color-accent-inverse); font-size: var(--text-xs);
  text-transform: uppercase; letter-spacing: var(--track-caps);
}
.tile.xl .xlcopy > b {
  font-size: clamp(28px, 3.2vw, 44px); font-weight: var(--weight-display);
  letter-spacing: var(--track-display); line-height: var(--lh-snug);
}
.tile.xl .xlcopy > i {
  font-style: normal; color: var(--color-text-muted-inverse);
  font-size: var(--text-base); line-height: var(--lh-body); max-width: 54ch;
}
.tile.xl .tthumb { aspect-ratio: 1 / 1; }
.tile.xl .tthumb::after { background: none; }
.tile.xl .doors2 { display: grid; gap: var(--space-2); margin-top: var(--space-3); width: min(100%, 470px); }
.door2 {
  border: 1px solid var(--color-border-inverse); padding: var(--space-3) var(--space-4);
  display: flex; justify-content: space-between; align-items: center; gap: var(--space-3);
  text-decoration: none; color: var(--color-text-on-inverse);
  transition: background var(--dur-micro), padding-left var(--dur-micro), border-color var(--dur-micro);
}
.door2:hover { background: var(--zef-paper-a12); padding-left: var(--space-5); border-color: var(--color-accent-inverse); }
.door2 .d2t { display: grid; gap: 1px; }
.door2 .d2t b { font-size: var(--text-lg); font-weight: var(--weight-semi); }
.door2 .d2t i { font-style: normal; font-size: var(--text-sm); color: var(--color-text-faint-inverse); }
.door2 .d2a { color: var(--color-accent-inverse); font-family: var(--font-mono); transition: transform var(--dur-micro); }
.door2:hover .d2a { transform: translateX(4px); }

@media (max-width: 900px) {
  .tile.xl { grid-template-columns: 1fr; }
  .tile.xl .tthumb { order: -1; }
  .tile.xl .xlcopy { padding: var(--space-6) var(--space-5); }
  .tile, .tiles.six .tile { flex-basis: calc((100% - var(--space-3)) / 2); }
  /* Two across leaves a 4:3 box barely taller than its own label block, so
     the copy climbs out of the scrim and lands on the bright part of the
     photograph. A square box gives it room and the ramp starts higher. */
  .tile .tthumb { aspect-ratio: 1 / 1; }
  .tile .tthumb::after {
    background: linear-gradient(180deg, rgba(10,15,23,0) 16%, rgba(10,15,23,.48) 44%,
      rgba(10,15,23,.86) 72%, rgba(10,15,23,.97) 100%);
  }
}


/* =====================================================================
   RESTORED FROM GIT (2026-08-16 night). A span deletion took this
   stretch out with the seal styles; these are Build 1's rules,
   verbatim, for components still in the markup.
   ===================================================================== */
.consult .inner {
  padding: var(--space-8) var(--space-5); display: flex; align-items: center;
  gap: var(--space-6); flex-wrap: wrap;
}
.consult h2 {
  font-size: clamp(22px, 3.2vw, 34px); font-weight: var(--weight-black);
  letter-spacing: var(--track-snug); max-width: 22ch;
}
.consult .bookbtn {
  margin-left: auto; background: var(--color-surface-inverse);
  color: var(--color-text-on-inverse); border: none;
  padding: 15px var(--space-6); font-weight: var(--weight-bold);
  font-size: var(--text-lg); cursor: pointer; border-radius: var(--radius-xs);
  min-height: 44px;
}
/* `.door` is a LIGHT-ground component. It carried inverse colours (paper
   text on a paper-alpha border), which put its only instance — the
   "Request a quote" route on the flagship's walkout section — at 1.19:1
   and effectively invisible. PRE-EXISTING: it read 1.25:1 on the old white
   ground and was found 2026-08-20 by the contrast sweep run after the
   ground change, not caused by it. The depth system had already classed it
   as light (it takes --el-raised, while its dark twin .door2 takes
   --el-raised-dark); only the colour and border were left inverted. */
.door {
  border: 1.5px solid var(--color-border); padding: var(--space-4);
  display: flex; justify-content: space-between; align-items: center;
  gap: var(--space-4);
  text-decoration: none; color: var(--color-text);
  transition: border-color var(--dur-micro), transform var(--dur-micro);
}
/* =====================================================================
   NAMED SPACES (commercial, 2026-08-26)

   Four named space types under one heading. Fahad's deck writes all four
   out in full, so all four are rendered and always visible: no JS, no
   tabs, nothing hidden (rule 31).

   NOT A 2x2 OF CARDS, AND THAT IS THE WHOLE POINT. The exterior deck's
   four areas were built as four same-size heading-plus-text boxes on
   2026-08-24 and Fahad rejected them: same-size boxes doing a section's
   structural work is the most recognisable generated-site tell. There the
   answer was to make them controls on a photograph. There is no
   photograph here, so the answer is an EDITORIAL ROW: the name in a left
   track, the sentence in a measured column beside it, a hairline between
   rows. Same vocabulary as `section_head()`'s label track, which the site
   already reads as its own.

   The measure is load-bearing. Unstyled, these paragraphs ran the full
   1440 page width at about 120 characters a line, against the QA
   checklist's 35 to 60. The prose column is capped at the site's own 68ch.
   ===================================================================== */
.spaces { margin-top: var(--space-7); }
.spaceblk {
  display: grid;
  /* name track, sentence, picture. At 1440 the inner runs ~1200: the name
     takes ~130px, the picture 340, and the sentence keeps ~600, which is
     about 62 characters a line. */
  grid-template-columns: minmax(0, 14ch) minmax(0, 1fr) 340px;
  /* CENTRED, NOT TOP-ALIGNED. The picture is 255px tall (340 at 4:3) and
     the sentences run two or three lines, so top-alignment left ~200px of
     dead ground under every paragraph -- the same complaint that moved the
     prose out of the split, reappearing one section down. */
  align-items: center;
  gap: var(--space-6);
  padding: var(--space-5) 0;
  border-top: 1px solid var(--color-border);
}
.spaceblk:last-child { border-bottom: 1px solid var(--color-border); }
.spaceblk h3 {
  font-size: var(--text-lg); font-weight: var(--weight-bold);
  line-height: 1.25; color: var(--color-text);
}
.spaceblk p {
  color: var(--color-text-muted);
  font-size: var(--text-lg); line-height: var(--lh-body);
}
/* One column on a phone: a 15ch track beside a paragraph leaves neither
   enough room, and the name reads as a heading above its sentence anyway. */
.spacepic { align-self: center; }
.spacepic img {
  width: 100%; height: auto; display: block;
  aspect-ratio: 4 / 3; object-fit: cover;
  border-radius: var(--radius-sm);
}
.spacepic .gate { min-height: 255px; display: grid; place-items: center; }

/* Below the split, the picture goes full width under its own text rather
   than beside it: a 340px column next to a paragraph leaves neither room. */
@media (max-width: 980px) {
  .spaceblk { grid-template-columns: minmax(0, 14ch) minmax(0, 1fr); }
  .spaceblk { align-items: start; }
  .spacepic { grid-column: 2; margin-top: var(--space-4); }
}
@media (max-width: 760px) {
  .spaceblk { grid-template-columns: 1fr; gap: var(--space-2); padding: var(--space-4) 0; }
  .spacepic { grid-column: 1; margin-top: var(--space-3); }
  .spacepic .gate { min-height: 200px; }
}

/* The plain variant of the split: no picture slot at all, prose at its own
   measure. Used when a deck carries no label list (commercial). */
/* DOUBLED CLASS ON PURPOSE. `.svcsplit { display: grid }` is declared about
   1400 lines further down at the same specificity, so a single-class
   override loses on source order and the prose stays crushed into the
   318px column the photograph used to occupy: measured at roughly 40
   characters a line against the checklist's 35 to 60, with the whole left
   half of the section empty. */
.svcsplit.svcsplit-plain { display: block; }
.svcsplit.svcsplit-plain .svcsplit-words { max-width: 68ch; }

/* THE HUB'S THIRD ROUTE (2026-08-31). A line under the split screen, not a
   third panel: the hub is 50/50 by ruling and a third half is not a small
   change to it. Centred so it reads as a footnote to the two doors rather
   than as a heading of its own, and held to a measure so it does not run the
   full width of a 1440 screen.

   THE LINK IS STYLED HERE BECAUSE NOTHING ELSE STYLES IT. This is the only
   inline text link in a `.lead` on the site -- every other call to action is
   a button or a whole-card anchor -- so without this rule it renders as the
   browser default, underlined and blue going purple on visit. Caught in the
   rendered shot, not in review. */
.hubthird { text-align: center; max-width: 62ch; margin-inline: auto; }
.hubthird a {
  color: var(--color-primary); font-weight: var(--weight-semi);
  text-decoration-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
  text-underline-offset: 4px;
}
.hubthird a:hover { color: var(--color-primary-hover); text-decoration-color: currentColor; }

/* =====================================================================
   THE BASEMENT HUB SPLIT SCREEN (Fahad, 2026-08-25)

   "Two vertical screens divided 50/50 on the page ... customer chooses which
   one and they go to the correct page", then "No heading", then "only a left
   side heading a right side".

   TWO HALVES, ONE FIXED SEAM. Not a comparison slider: the seam never moves
   and neither half grows on hover. It replaced `.chooser` / `.half`, whose
   head band and ordinals both went with the "no heading" ruling.

   FULL VIEWPORT, INCLUDING UNDER THE HEADER. On desktop --header-h collapses
   to 0 and the bar arrives on intent, so the split designs to the top edge
   like the home hero. Phones keep their bar, so the split starts below it.

   THE VEIL IS WHAT MAKES THE TYPE LEGIBLE, and it has to survive whatever
   photograph lands here later. It is a flat 46% ink, not a gradient: the
   copy sits dead centre, where a top-or-bottom gradient is at its weakest.
   ===================================================================== */
.hubsplit {
  position: relative;
  height: 100vh;
  min-height: 520px;
  overflow: hidden;
  background: var(--color-text);
}
.hubhalves { position: absolute; inset: 0; display: grid; grid-template-columns: 1fr 1fr; }

.hubside {
  position: relative; overflow: hidden; display: block;
  text-decoration: none; color: var(--zef-paper);
}
/* The photograph is the ground, not content: it fills the half and crops.
   `.hubshot` is on the <img>; the <picture> wrapper needs the same box or the
   image has nothing to fill. */
.hubside picture { position: absolute; inset: 0; display: block; }
.hubshot { width: 100%; height: 100%; object-fit: cover; display: block; }
/* THE VEIL IS A CENTRED RADIAL OVER A FLAT FLOOR, and the shape is measured.
   A flat 46% put the LEFT panel's worst-case ground at 4.51:1 behind body
   copy against a 4.5:1 floor -- the bright concrete slab is what pushes it
   there -- while over-darkening both photographs at the edges where nothing
   is written. Strongest where the copy sits, weakest at the frame edges:
   about 60% at the centre and 38% at the corners. It is CENTRED, never a
   top-or-bottom gradient, because the copy is centred and a linear ramp is
   at its weakest exactly there. */
.hubveil {
  position: absolute; inset: 0; z-index: 2;
  background:
    radial-gradient(ellipse 80% 48% at 50% 50%,
      rgba(0, 0, 0, .36) 0%, rgba(0, 0, 0, .22) 55%, rgba(0, 0, 0, 0) 100%),
    rgba(0, 0, 0, .38);
}

.hubcopy {
  position: absolute; left: 0; right: 0; top: 50%;
  transform: translateY(-50%);
  z-index: 3; text-align: center; padding: 0 6%;
}
/* The heading is an h2 in a span so the flow stays inside one <a>: an <a>
   may not contain an <h2> as a direct child in every parser, and the span
   keeps the anchor's inline formatting context intact. */
.hubhead h2 {
  font-family: var(--font-display); font-weight: 400;
  text-transform: uppercase; letter-spacing: .005em;
  font-size: clamp(30px, 4.4vw, 68px); line-height: .94;
  max-width: 14ch; margin: 0 auto;
  color: var(--zef-paper);
  text-shadow: 0 2px 34px rgba(0, 0, 0, .62);
}
.hubline {
  display: block; margin-top: var(--space-4);
  font-size: clamp(14px, 1.15vw, 18px); line-height: 1.55;
  color: #E6E9ED; max-width: 42ch; margin-left: auto; margin-right: auto;
  text-shadow: 0 1px 18px rgba(0, 0, 0, .9);
  /* Both lines run to different lengths. A shared min-height keeps the two
     Explore controls on ONE baseline; without it the longer line pushes its
     button down and the 50/50 stops reading as symmetrical. */
  min-height: 5.2em;
}
.hubgo {
  display: inline-block; margin-top: var(--space-2);
  font-family: var(--font-mono); font-size: var(--text-sm);
  letter-spacing: .11em; text-transform: uppercase;
  color: var(--zef-paper);
  border: 1px solid rgba(255, 255, 255, .62);
  padding: var(--space-3) var(--space-5);
  transition: background var(--dur-hover) var(--ease-out-strong),
              border-color var(--dur-hover) var(--ease-out-strong);
}
/* THE RESPONSE TO A POINTER IS THE VEIL, NOT THE SEAM. The separator is
   fixed by ruling, so the hovered half brightens and its control fills. */
.hubside:hover .hubveil,
.hubside:focus-visible .hubveil {
  background:
    radial-gradient(ellipse 80% 48% at 50% 50%,
      rgba(0, 0, 0, .30) 0%, rgba(0, 0, 0, .18) 55%, rgba(0, 0, 0, 0) 100%),
    rgba(0, 0, 0, .22);
}
.hubside:hover .hubgo,
.hubside:focus-visible .hubgo {
  background: rgba(255, 255, 255, .14); border-color: var(--zef-paper);
}
.hubside:focus-visible { outline: 3px solid var(--zef-paper); outline-offset: -6px; }
@media (prefers-reduced-motion: reduce) {
  .hubgo, .hubveil { transition: none; }
}

/* The seam reads on a bright photograph AND a dark one, so it is paper with
   an ink edge rather than a plain ink rule. A 3px ink line vanished against
   the framing shot in the first render. */
.hubseam {
  position: absolute; top: 0; bottom: 0; left: 50%;
  width: 4px; transform: translateX(-50%);
  background: var(--zef-paper);
  box-shadow: 0 0 0 1.5px rgba(0, 0, 0, .72);
  z-index: 4;
}
.hubdisc {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
  z-index: 5;
  width: clamp(120px, 11.4vw, 164px); height: clamp(120px, 11.4vw, 164px);
  border-radius: 50%;
  background: var(--color-text); color: var(--zef-paper);
  display: grid; place-items: center; text-align: center;
  font-family: var(--font-mono); font-size: clamp(10px, .85vw, 12px);
  letter-spacing: .13em; text-transform: uppercase; line-height: 1.65;
  padding: var(--space-2);
}

/* PHONES STACK, AND THE SEAM SIMPLY CHANGES AXIS. It still does not move.
   The halves share the viewport minus the header, so each is about 365px on
   a 375x812 phone: the 4:5 masters cover-crop there, which is why the spec
   carries an optional square phone file. */
@media (max-width: 760px) {
  .hubsplit { height: calc(100vh - var(--header-h, 0px)); }
  .hubhalves { grid-template-columns: 1fr; grid-template-rows: 1fr 1fr; }
  .hubseam { top: 50%; bottom: auto; left: 0; right: 0;
             width: auto; height: 4px; transform: translateY(-50%); }
  .hubdisc { width: 104px; height: 104px; font-size: 9.5px; letter-spacing: .1em; }
  .hubhead h2 { font-size: clamp(26px, 8vw, 38px); }
  .hubline { font-size: 14px; min-height: 0; max-width: 34ch; }
  .hubcopy { padding: 0 var(--space-5); }
}
/* A short phone in landscape has no room for two stacked halves plus type. */
@media (max-width: 760px) and (max-height: 620px) {
  .hubsplit { height: auto; }
  .hubhalves { grid-template-rows: 60vh 60vh; }
}

/* THE LIGHT DOOR HAD NO INNER TYPE RULES AT ALL, and its dark twin always
   did (`.door2 .d2t`). Its label and its qualifier therefore ran together on
   one line -- "Bringing yours up to codeIt was built without permits".
   PRE-EXISTING, not new: the only previous `.door` was the walkout section's
   "Request a quote", which had the same defect and was never photographed.
   Found 2026-08-20 when the basement page's two doors were built. */
.doors { display: grid; gap: var(--space-3); width: min(100%, 560px); }
.door > span:first-child { display: grid; gap: 2px; }
.door b { font-size: var(--text-lg); font-weight: var(--weight-semi); }
/* Bumped from --text-sm to --text-lg (Fahad, 2026-08-20: "lets increase the
   subcopy for the answers"). The qualifier is what a visitor actually reads to
   decide which door is theirs, so it was the wrong thing to set at the
   smallest size on the scale. */
.door i { font-style: normal; font-size: var(--text-lg); color: var(--color-text-muted); line-height: 1.45; }
.door .mono { flex: none; }
.door:hover { border-color: var(--color-primary); }
.chapter {
  display: grid; grid-template-columns: 64px 1fr minmax(200px, 300px);
  gap: var(--space-6); align-items: center; padding: 36px 0;
  border-bottom: 1px solid var(--color-border-soft);
}
/* A gate that follows a section heading needs the same air `.prose` takes, or
   the dashed panel sits on the heading's baseline. SCOPED TO `.ruled`: unscoped
   this reaches legal-basements.html's "Basement gallery" gate, and the basement
   set is a separate design round. */
main > section.ruled .sec + .gate { margin-top: var(--space-5); }
.gate {
  border: 1.5px dashed var(--color-primary); background: var(--color-surface-sunken);
  min-height: 220px; display: grid; place-items: center; text-align: center;
  padding: var(--space-6) var(--space-5);
}
.gate p {
  font-family: var(--font-mono); font-size: var(--text-xs);
  letter-spacing: var(--track-caps); text-transform: uppercase;
  color: var(--color-primary); max-width: 46ch; line-height: 1.9;
}
/* ON THE CONCRETE, SUNKEN HAS TO BE MIXED FROM THE BAND, NOT FROM THE PAGE
   (2026-08-20 night). `--color-surface-sunken` is #E9E9E6 and the band is
   #E0E1DD: the "recessed" fill is the LIGHTER of the two, so every gate on a
   banded section read as a raised pale box. Mixing ink into the band keeps the
   affordance pointing the right way on either ground. */
main > section.band .gate {
  background-color: color-mix(in srgb, var(--zef-ink) 7%, var(--color-surface-band));
}
.bsmt .gate, .quoteband .gate {
  border-color: var(--color-accent-inverse); background: var(--color-surface-deep);
}
/* The dark gate variant moved the BACKGROUND to black and left the text on
   --color-primary, which is steel on black at 2.53:1. Found 2026-08-20 by
   the contrast sweep run after the ground change; it PREDATES that change
   and was failing on the flagship all along. The text follows the border to
   steel-light, the same swap every other inverse component makes. */
.bsmt .gate p, .quoteband .gate p { color: var(--color-accent-inverse); }
.svcidx {
  background: var(--color-surface-sunken);
  border-top: 1.5px solid var(--color-border); border-bottom: 1.5px solid var(--color-border);
}
.svcidx a {
  font-size: var(--text-lg); font-weight: var(--weight-semi); text-decoration: none;
  color: var(--color-text); padding: 15px 2px;
  border-bottom: 1px solid var(--color-border-soft);
  display: flex; justify-content: space-between; gap: var(--space-3); min-height: 44px; align-items: center;
}

/* THE STEEL SKIN IS RETIRED (2026-08-20 night). This band was the one steel
   surface on About; with the whole page on the concrete it was the only
   section left disagreeing with its neighbours, and the editorial head sets
   its title in --color-primary -- the same steel -- so the heading would have
   vanished into its own ground. Ink on concrete now, like every other section
   on the page. The steel skin is one commit back if it is ever wanted. */
.facts { color: var(--color-text); }
/* ONE COLUMN SINCE THE EDITORIAL HEAD LANDED (2026-08-20 night). This was a
   two-column grid pairing a small `h2.sec` with the paragraph beside it. The
   section head is itself a two-column block (ordinal track + oversized word),
   so nesting it in a 1fr column squeezed "OUR RANGE." onto two lines, ran its
   hairline across a third of the section, and let the descender overflow. The
   head takes the full inner and the sentence sits under it, which is what
   every other section on the page does. */
.facts .inner {
  /* The old `--space-8` and the two-column grid were both tuned for the steel
     band. It is an ordinary section on the concrete now, so it takes the
     standard inner padding like its neighbours. */
  padding: var(--space-9) var(--space-5);
}
.facts p {
  margin-top: var(--space-5);
  font-size: var(--text-2xl); line-height: 1.55; max-width: 58ch;
  font-weight: var(--weight-medium);
}
.facts p .k { font-weight: var(--weight-black); }
.facts p .s { color: var(--color-text-muted); } /* was paper, for the retired steel band */
@media (max-width: 860px) {
  .facts p { font-size: var(--text-xl); }
}

/* =====================================================================
   FAQ - THE QUESTION CARDS (2026-08-20)

   Ported from the veil-faqs-4 component Fahad supplied: a centred intro,
   a two-column grid of question cards, and one closing line offering the
   way to ask something else. THE STRUCTURE IS THE REFERENCE'S. Nothing
   else is, and that was the instruction: the reference ships its own
   paper, ink, serif and radii, and dropped in whole it would have read as
   a second website wearing this one's header.

   So the card here is `.reccard`'s anatomy to the property (white on the
   ground, 1px hairline, 2px radius, the depth ramp's resting shadow), the
   question sets in Anton like every other written heading on the site,
   and the answer stays Instrument Sans at 400. Same object as the review
   card, holding different content.

   WHAT IT REPLACED: a ruled single-column list that ran down the left
   half of a 1440px page with nothing in the right half. The grid is what
   closes that gap; the measure below is what keeps a 90-word answer
   readable inside it.

   NO HOVER LIFT, NO PRESS. These cards are not pressable, so they take
   the resting elevation and nothing else (see the DEPTH & MOTION SYSTEM
   block, rule 4). A card that rises to meet a pointer it cannot answer is
   exactly the template tell that system exists to avoid.
   ===================================================================== */
/* THE HEAD IS THE EDITORIAL ONE (Fahad, 2026-08-20: "use the same heading
   as used in Expertise, process, about us"). `section_head()` at the page's
   own ordinal 01, which until now only home carried. The section therefore
   keeps the STANDARD --page-max inner, or its hairline would run short of
   every other head on the site and of `.certs` directly below it.

   THE GRID STARTS WHERE THE TITLE STARTS, but only where there IS an
   editorial title. `.sechead` puts the ordinal in a `minmax(76px, 7%)`
   track with a `--space-5` gap and the oversized word in the column beside
   it, so `.faqsec` repeats that offset and the cards hang off the title's
   left edge with the ordinal alone out in the margin. Centring the grid
   instead was tried first and read as a misalignment: it shared an edge
   with neither the hairline above it nor the word.

   THE INDENT IS SCOPED TO `.faqsec` AND THE WIDTH IS NOT. The flagship's
   "Questions about basements" is a plain `h2.sec` like its ten sibling
   sections on that page, with no ordinal column to hang off, so inheriting
   the indent pushed its cards 106px right of their own heading. Both pages
   keep the same max-width, expressed as the same subtraction, so a card is
   the same object either way and only its left edge moves.

   The offset is written as `max(76px, 7%) + gap` rather than as a fixed
   number because 7% resolves against `.inner`'s content box, which is the
   same box `.sechead`'s track resolves against. The two cannot drift.

   The width also buys back the measure. Two columns across the FULL inner
   put a line at about 80 characters, past the ceiling; one ordinal track
   narrower they land near 69. */
.faq {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-4);
  margin-top: var(--space-6);
  max-width: calc(100% - (max(76px, 7%) + var(--space-5)));
}
/* CONCRETE ON THE FAQ PAGE (Fahad, 2026-08-20: "Add the same concrete
   background to the page"). Longhands, never the `background` shorthand --
   that resets background-image and the tile would silently vanish, which is
   the trap the banded components already carry a warning about. */
main > section.faqsec {
  background-color: var(--color-surface-band);
  background-image: var(--band-texture);
}
/* THE QUESTIONS TAKE THE ACCENT, matching the "FAQ." head above them
   (Fahad: "Make questions blue, same colour as FAQ, everything else black
   is good"). Answers stay ink, which is what keeps the question the thing
   your eye lands on. */
.faqsec .faq .qa h2 { color: var(--color-primary); }

.faqsec .faq { margin-left: calc(max(76px, 7%) + var(--space-5)); }
/* WHICH CARDS SPAN THE ROW IS COMPUTED, NEVER HAND-MARKED. Grid rows
   stretch to their tallest cell, so a 90-word answer beside a 12-word one
   leaves half a card of white. `faq_grid()` in generate.py measures the
   answers and marks the long ones, plus any last card that would sit
   alone in its own row. Adding a question cannot break the layout. */
.faq .qa.wide { grid-column: 1 / -1; }
/* One column below the point where two stop holding a sane measure. The
   indent is dropped at 700px WITH the section head, which collapses its
   ordinal onto one line above the word at exactly that breakpoint. */
@media (max-width: 820px) { .faq { grid-template-columns: minmax(0, 1fr); } }
/* SCOPED TO `.faqsec`, AND THAT IS THE WHOLE FIX (2026-08-29). The indent
   above is set by `.faqsec .faq` at (0,2,0); this reset was written as a
   bare `.faq` at (0,1,0) and therefore LOST to it at every width. The cards
   kept a ~120px left gutter on a 375px screen, ran to 233px wide, and hung
   off an ordinal track that `.sechead` collapses below 700px. Matching the
   selector's specificity and coming later is what makes it win. */
@media (max-width: 700px) {
  .faqsec .faq, .faqsec .faqmore, .faq, .faqmore { margin-left: 0; max-width: none; }
}

.faq .qa {
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border-soft);
  border-radius: var(--radius-xs);
  padding: var(--space-6) var(--space-5);
}
/* Font-weight and letter-spacing are DELIBERATELY ABSENT: they belong to
   the CONTENT HEADINGS block at the end of this file, which forces Anton
   to its one real weight and its own positive tracking. Setting either
   here would out-specify that block (0,2,1 over 0,2,1, but earlier in
   source loses only on a tie) and hand the browser back the synthesised
   bold that pass removed. */
.faq .qa h2, .faq .qa h3 { font-size: var(--text-2xl); line-height: var(--lh-snug); }
.faq .qa p {
  margin-top: var(--space-3); color: var(--color-text-muted);
  font-size: var(--text-lg); line-height: var(--lh-body);
}
/* The reference's closing line, kept because it is the useful half of the
   component: a visitor whose question is not on the board is told where to
   put it instead of being left at the bottom of a list. */
.faqmore {
  margin-top: var(--space-6); text-align: center;
  max-width: calc(100% - (max(76px, 7%) + var(--space-5)));
  font-size: var(--text-lg); color: var(--color-text-muted);
}
.faqsec .faqmore { margin-left: calc(max(76px, 7%) + var(--space-5)); }
.faqmore a { color: var(--color-primary); font-weight: var(--weight-semi); }

/* =====================================================================
   CONTACT
   ===================================================================== */
.contact-grid { display: grid; grid-template-columns: 1fr 1.1fr; gap: var(--space-9); align-items: start; }
@media (max-width: 860px) { .contact-grid { grid-template-columns: 1fr; gap: var(--space-8); } }
.channel { border-top: 1.5px solid var(--color-border-soft); padding: var(--space-5) 0; }
.channel h2 { font-size: var(--text-2xl); font-weight: var(--weight-black); }
.channel p, .channel a { font-size: var(--text-lg); color: var(--color-text-muted); line-height: var(--lh-body); }
.channel a { color: var(--color-primary); }
.channel .big {
  display: inline-block; font-size: var(--text-4xl); font-weight: var(--weight-black);
  color: var(--color-text); text-decoration: none; letter-spacing: var(--track-snug);
  margin-top: var(--space-2); padding: var(--space-1) 0; min-height: 44px;
}
.channel .big:hover { color: var(--color-primary); }
.channel .hours { font-family: var(--font-mono); font-size: var(--text-md); margin-top: var(--space-3); }
.channel .hours span { display: block; }

/* =====================================================================
   FORMS
   ===================================================================== */
.form { background: var(--color-surface-raised); border: 1.5px solid var(--color-border); padding: var(--space-6); }
.form h2 { font-size: var(--text-2xl); font-weight: var(--weight-black); }
.field { margin-top: var(--space-5); }
.field label { display: block; font-size: var(--text-md); font-weight: var(--weight-semi); margin-bottom: var(--space-2); }
.field .req { color: var(--color-primary); font-weight: var(--weight-bold); }
.field input, .field textarea, .field select {
  width: 100%; border: 1.5px solid var(--color-border); background: var(--color-surface-raised);
  padding: 11px var(--space-3); font-size: var(--text-xl); border-radius: var(--radius-xs);
  font-family: var(--font-sans); color: var(--color-text); min-height: 44px;
}
.field textarea { min-height: 96px; resize: vertical; }
/* A SELECT AND AN INPUT ARE THE SAME BOX, and without this they are not: a
   select carries its own intrinsic line box and comes out 46px against the
   input's 44. Two pixels, invisible in a row and the only thing not lining
   up in a form that stands them side by side (Fahad: "size of boxes should
   be uniform and same").

   STATED AS A HEIGHT, not as a line-height. `line-height` was the first fix
   and it does not hold: Chrome's UA sheet pins `line-height: normal` on a
   select and the author value never lands on it, so the input moved and the
   select did not. A height is a height in every engine.

   The block padding drops to 9px with it, which keeps 23px of content room
   inside the 44 against a ~19px line box: the margin a fallback face with
   taller metrics needs before it clips. `min-height` still wins where a rule
   sets a bigger one (the booking sheet's 50px controls), so this pins the
   default without reaching into the sheet. The textarea keeps its own box. */
.field input, .field select { height: 44px; padding-block: 9px; }
.field input:focus-visible, .field textarea:focus-visible, .field select:focus-visible {
  outline: var(--ring-width) solid var(--color-primary); outline-offset: 1px;
}
/* Errors carry an icon and a message, never colour alone (a11y gate). */
.field .err { display: none; margin-top: 6px; font-size: var(--text-md); color: var(--color-danger); font-weight: var(--weight-semi); }
.field.invalid input, .field.invalid textarea { border-color: var(--color-danger); border-width: 2px; }
.field.invalid .err { display: flex; align-items: center; gap: 6px; }
.field .err::before { content: "!"; display: inline-grid; place-items: center; width: 15px; height: 15px; border: 1.5px solid currentColor; border-radius: var(--radius-pill); font-size: var(--text-xs); flex: none; }

/* THE FAILED SEND (register F11, 2026-08-20). Same icon-plus-text grammar
   as the field errors above, at form level rather than field level.

   IT IS HIDDEN BY :empty, NOT BY [hidden], and that is the whole reason it
   is written this way: role="alert" announces content ARRIVING in a live
   region that is already in the document, so the element has to stay put
   and only its text may change. Toggling the element itself in and out
   announces nothing in several screen readers, which on a failed send is
   the one message that has to land.

   The sheet is on a muted ground and the contact page on paper, so the box
   states its own background rather than inheriting either. */
.formerr {
  display: flex; align-items: flex-start; gap: 8px;
  margin-top: var(--space-4);
  padding: var(--space-4);
  background: var(--color-surface);
  border: 2px solid var(--color-danger);
  border-radius: var(--radius-xs);
  color: var(--color-danger);
  font-size: var(--text-md); font-weight: var(--weight-semi); line-height: 1.5;
}
.formerr:empty { display: none; }
.formerr::before {
  content: "!"; display: inline-grid; place-items: center;
  width: 15px; height: 15px; margin-top: 1px;
  border: 1.5px solid currentColor; border-radius: var(--radius-pill);
  font-size: var(--text-xs); flex: none;
}
.formerr:focus { outline: none; }

/* The pending state. The label swap is done in JS; this carries the look,
   and `pointer-events: none` means a second click during a slow send
   cannot queue a second lead even if the disabled attribute is lost. */
.formbody .send[aria-busy="true"], .formbody .send:disabled {
  opacity: .6; cursor: default; pointer-events: none;
}

/* FormSubmit's honeypot. It must be in the payload and never on the
   screen, and !important because losing this one to a later input rule
   would put an unlabelled text box in the middle of the form. */
.hp { display: none !important; }

.form .send { margin-top: var(--space-6); width: 100%; }
.form .formnote { margin-top: var(--space-3); font-size: var(--text-md); color: var(--color-text-muted); }
.form .sent { display: none; padding: var(--space-6) 0; }
.form .sent h3 { font-size: var(--text-2xl); font-weight: var(--weight-black); }
.form .sent p { margin-top: var(--space-2); color: var(--color-text-muted); font-size: var(--text-lg); }
.form.done .formbody { display: none; }
.form.done .sent { display: block; }
/* The booking sheet's confirmation follows the same contract, but its
   .sent sits in .panel, not .form (main.js adds `done` to the panel).
   Without these two rules the sheet showed "Got it" permanently — found
   2026-08-18 while building the date picker. */
.sheet .panel .sent { display: none; padding: var(--space-6) 0; }
/* THE CONFIRMATION IS TWO LINES and the column is as tall as the checklist
   beside it, so left at the top it reads as a panel that failed to finish
   loading. In the done state only, the headline stays anchored and the
   confirmation takes the space the form vacated. The contact page needs
   none of this: that card is sized by its own content. */
.sheet .panel.duo.done .bkform { display: flex; flex-direction: column; }
.sheet .panel.duo.done .sent { margin-block: auto; }
.sheet .panel.done .formbody { display: none; }
.sheet .panel.done .sent { display: block; }

/* =====================================================================
   QUOTE BAND + FOOTER
   ===================================================================== */
.quoteband { background: var(--color-surface-inverse); color: var(--color-text-on-inverse); }
.quoteband h2 { color: var(--color-text-on-inverse); max-width: 18ch; }
.quoteband .lead { color: var(--color-text-muted-inverse); }
.quoteband .ctas { margin-top: var(--space-6); display: flex; gap: var(--space-3); flex-wrap: wrap; }

/* ---- THE FOOTER REVEAL (6.23 — Fahad, 2026-08-20: "I like how the
   footer animation was and how the page below takes over the top page",
   on the GSAP cinematic-footer reference). Reverses the footer's
   direction of travel: it used to be the last member of the curtain
   chain and RISE OVER the final section. Now it holds still and the
   final section slides up off it.

   MECHANISM, and why clip-path specifically. `.footer-stage` is an
   ordinary in-flow block holding the footer's height in the document,
   so the page is exactly as long as it was. The footer inside it is
   fixed to the VIEWPORT bottom, and the stage's clip-path shows only
   the part of it that falls inside the stage's box — so as the stage
   rises into view the footer is uncovered from its bottom edge upward
   while its content never moves. clip-path is the right property and
   transform/filter are not: those would make the stage the containing
   block for the fixed child, which would re-anchor `bottom: 0` to the
   stage and kill the whole effect. clip-path clips without doing that.

   RULE 31. Everything here is gated on `html.js`. Without JS the stage
   is a plain wrapper and the footer is a plain footer in flow, which is
   also why the height is a CSS fallback first (the stage must never be
   0 tall in the gap before main.js measures) and an exact JS
   measurement second.

   NOT gated on reduced motion, deliberately and consistently with the
   curtain chain: this is position, not animation. Nothing here moves on
   its own, and a visitor who scrolls to the bottom sees the same
   footer either way. ---- */
.footer-stage { position: relative; }
html.curtain .footer-stage {
  /* Fallback height for the frame before main.js measures the footer.
     Replaced with the footer's exact offsetHeight and kept honest by a
     ResizeObserver, the same law the beats are held to. */
  min-height: 60vh;
  clip-path: inset(0);
}
html.curtain .footer-stage > footer.site {
  position: fixed; bottom: 0; left: 0; width: 100%;
}

footer.site {
  background: var(--color-surface-deep); color: var(--color-text-on-deep);
  font-size: var(--text-md); padding: var(--space-9) var(--space-5) 120px;
}
footer.site .inner { max-width: var(--page-max); margin: 0 auto; display: grid; grid-template-columns: 1.4fr 1fr 1fr; gap: var(--space-8); }
footer.site b { color: var(--color-text-on-inverse); font-size: var(--text-xl); font-weight: var(--weight-black); display: block; }
footer.site p { margin-top: var(--space-3); line-height: var(--lh-body); }
footer.site a { color: var(--color-text-on-deep); text-decoration: none; }
footer.site a:hover { color: var(--color-text-on-inverse); }
footer.site h2 { font-size: var(--text-md); font-family: var(--font-mono); letter-spacing: var(--track-caps); text-transform: uppercase; color: var(--color-accent-inverse); font-weight: var(--weight-medium); }
footer.site ul { list-style: none; margin-top: var(--space-3); }
footer.site li { padding: 5px 0; }
/* TAP TARGETS (2026-08-24 launch pass). The li carried the spacing while the
   anchor stayed inline, so the actual hit box was only the 15px text box:
   "Decks" measured 35x15 and "FAQ" 25x15 on a phone, both under WCAG 2.5.8's
   24x24 minimum. The padding moves onto the ANCHOR so the thing you aim at is
   the thing that grows. 25px everywhere clears AA; the phone gets 43px,
   because on this site the footer is where a thumb goes looking for the
   phone number. */
footer.site a { display: inline-block; padding: 5px 0; }
footer.site .legal { max-width: var(--page-max); margin: var(--space-8) auto 0; padding-top: var(--space-5); border-top: 1px solid var(--color-border-inverse-soft); font-size: var(--text-sm); }
@media (max-width: 760px) {
  /* The address block keeps the full width; the two link lists sit side by
     side rather than adding a third of a screen to every page. */
  footer.site .inner { grid-template-columns: 1fr 1fr; gap: var(--space-6) var(--space-5); }
  footer.site .inner > :first-child { grid-column: 1 / -1; }
  footer.site { padding: var(--space-8) var(--space-5) 120px; }
  footer.site li { padding: 0; }
  footer.site a { padding: 14px 0; }
}

/* =====================================================================
   SHEETS (quote / booking)
   ===================================================================== */
.sheet {
  position: fixed; inset: 0; z-index: 60; display: none;
  align-items: flex-end; justify-content: center; background: rgba(0, 0, 0, .6);
}
.sheet.open { display: flex; }
.sheet .panel {
  /* Raised, not ground: a dialog floating over a dimmed page reads as a
     card, and it carries the muted inputs that need something to sink into. */
  background: var(--color-surface-raised); color: var(--color-text);
  width: min(560px, 100%); border-radius: var(--radius-sheet) var(--radius-sheet) 0 0;
  padding: var(--space-6) var(--space-5) var(--space-7); max-height: 86vh; overflow: auto;
}
.sheet h2 { font-size: var(--text-2xl); font-weight: var(--weight-black); }
.sheet .note {
  font-family: var(--font-mono); font-size: var(--text-xs); color: var(--color-primary);
  letter-spacing: .08em; text-transform: uppercase; margin-top: 6px;
}
.sheet .close {
  float: right; background: none; border: none; cursor: pointer; color: var(--color-text);
  min-width: 44px; min-height: 44px; display: grid; place-items: center;
}
.sheet .close svg { width: 20px; height: 20px; stroke: currentColor; fill: none; stroke-width: 2; }
.chips { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.chips button {
  border: 1.5px solid var(--color-border); background: none; padding: 9px var(--space-3);
  font-size: var(--text-base); font-weight: var(--weight-medium); cursor: pointer;
  border-radius: var(--radius-xs); min-height: 44px; color: var(--color-text);
}
.chips button[aria-pressed="true"] {
  background: var(--color-surface-inverse); color: var(--color-text-on-inverse);
  border-color: var(--color-surface-inverse);
}

/* ---- Booking sheet, two-panel (ride-booking-form port, 2026-08-20).
   Form left, decorative photo right; the photo column exists only at
   >=901px and the sheet centers as a dialog there, keeping the mobile
   bottom-sheet behaviour below. The connected property -> project block
   is the reference's pickup/dropoff motif on ZEF tokens: icon nodes
   joined by a dashed spine, borderless inputs inside one sunken box.
   The entrance is the reference's stagger rebuilt as CSS animations
   (backwards fill, all under 600ms); the global reduced-motion block
   kills them wholesale. Interaction transitions stay under 300ms per
   the entrance-rule gotcha. ---- */
@media (min-width: 901px) {
  .sheet.wide { align-items: center; padding: var(--space-5); }
  .sheet.wide .panel.duo { border-radius: var(--radius-sheet); }
}
.sheet .panel.duo {
  /* Component vars: the reference's skin on the HOUSE TYPEFACE. The sheet
     ran on Plus Jakarta Sans from 2026-08-20 (Fahad: "follow the uber
     guidelines more, you customized too much to our site") until
     2026-08-28, when he ruled the other way on this one property: "Use the
     same font as the rest of the website". The layout, radii, muted boxes
     and generous control height are still the reference's; only the face
     came home. --bk-face stays as the knob, and everything inside the
     sheet inherits it, form controls included.

     PLUS JAKARTA SANS IS NO LONGER REQUESTED in generate.py's head(). It
     had no other caller on the site, so the webfont went with this change
     -- if the sheet is ever put back on its own face, the family has to go
     back into that URL or it will silently render in the fallback. Note
     also that Instrument Sans stops at 700: the two hard-coded 800s in
     this block came down to --weight-bold with it, because 800 on a face
     that has no 800 is a synthesised smear, not a heavier weight. */
  --bk-face: var(--font-sans);
  --bk-radius: 10px;
  --bk-muted: var(--color-surface-sunken);
  --bk-ink: var(--zef-ink);
  --bk-control: 50px;   /* input height: the reference's h-12 generosity */
  position: relative; width: min(1080px, 100%);
  padding: 0; overflow: hidden;
  display: grid; grid-template-columns: minmax(0, 1fr);
  font-family: var(--bk-face);
}
.sheet .panel.duo input, .sheet .panel.duo select, .sheet .panel.duo button {
  font-family: inherit;
}
.sheet .panel.duo .close {
  float: none; position: absolute; top: var(--space-3); right: var(--space-3);
  z-index: 2; background: var(--color-surface-raised); border-radius: var(--radius-pill);
  box-shadow: var(--el-rest);
}
.bkform {
  padding: var(--space-8) var(--space-7);
  overflow-y: auto; max-height: 88vh; min-width: 0;
}
/* The headline: the ride reference's scale at the footer reference's
   black weight, with real air under it before the form starts. */
.sheet .panel.duo h2 {
  font-size: clamp(30px, 4.4vw, 42px); font-weight: var(--weight-bold);
  letter-spacing: -.025em; line-height: 1.05;
}
/* The sub-line as the footer reference's tracked micro-label. */
.sheet .panel.duo .note {
  font-family: inherit;
  font-size: var(--text-xs); font-weight: var(--weight-bold);
  text-transform: uppercase; letter-spacing: .22em;
  color: var(--color-text-muted);
  margin-top: var(--space-3);
}
.sheet .panel.duo .formbody { margin-top: var(--space-7); }
/* One vertical rhythm for the whole form (the reference's space-y-4):
   the stack owns the spacing, the items do not. */
.sheet .panel.duo .formbody > * + * { margin-top: var(--space-4); }
.sheet .panel.duo .bkgrid .field, .sheet .panel.duo .bkroute .field { margin-top: 0; }
/* Placeholder-only inputs in muted rounded boxes, per the reference
   (aria-labels carry the labelling). Invalid needs its own border rule:
   the base .field.invalid only recolours a border that no longer exists. */
.sheet .panel.duo .field input, .sheet .panel.duo .field select {
  border: none; background: var(--bk-muted); border-radius: var(--bk-radius);
  padding-left: var(--space-4); min-height: var(--bk-control);
}
.sheet .panel.duo .field.invalid input { border: 2px solid var(--color-danger); }

/* The value column (Ali Imam "Book A Demo" port, 2026-08-20). It replaced
   the decorative photograph, so it must STACK rather than hide: the old
   .bkphoto was display:none below 901px, which is only ever safe for
   decoration. Border-top on phones, border-left on desktop, which is the
   reference's own `border-t ... lg:border-l lg:border-t-0`. */
/* BASE: the list only, no chrome. The wrapper supplies its own frame,
   because the two instances sit in different furniture: the sheet's is a
   panel column, the contact page's is another `.channel` in the stack and
   inherits that block's hairline and padding. Anything added here lands
   twice on the contact page. */
.bkwhy { min-width: 0; }
/* h2 on the contact page (it is a sibling of the other channel headings and
   the outline has to stay flat), h3 in the sheet under its own h2. */
.bkwhy h3 {
  font-size: var(--text-xl); font-weight: var(--weight-bold);
  letter-spacing: -.015em; line-height: var(--lh-snug);
}
.bkwhy :is(h2, h3) + ul { margin-top: var(--space-4); }
.bkwhy ul + :is(h2, h3) { margin-top: var(--space-6); }
/* The contact instance needs no colour rule of its own: `.channel` only
   mutes `p` and `a`, so the check lines inherit the body ink, which is
   what puts them at full strength beside the muted channel copy. */
.bklist { list-style: none; display: grid; gap: var(--space-3); }
.bklist li {
  display: grid; grid-template-columns: 18px minmax(0, 1fr);
  gap: var(--space-3); align-items: start;
  font-size: var(--text-lg); line-height: 1.45;
}
/* The check sits on the first line's optical centre, not its top edge. */
.bkcheck {
  width: 18px; height: 18px; margin-top: 2px;
  stroke: var(--color-primary); fill: none;
  stroke-width: 2.6; stroke-linecap: round; stroke-linejoin: round;
}
.bkwhy-photo { margin-top: var(--space-6); }
.bkwhy-photo picture { display: block; }
.bkwhy-photo img {
  width: 100%; height: auto; display: block; border-radius: var(--bk-radius);
}
/* SHEET INSTANCE ONLY: the panel column's frame. */
.sheet .bkwhy {
  padding: var(--space-7);
  border-top: 1px solid var(--color-border-soft);
}
@media (min-width: 901px) {
  /* The reference's even split. The column scrolls on its own so a long
     list never forces the form column taller than the viewport. */
  .sheet .panel.duo { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
  .sheet .bkwhy {
    border-top: none; border-left: 1px solid var(--color-border-soft);
    padding: var(--space-8) var(--space-7);
    overflow-y: auto; max-height: 88vh;
  }
}

/* The route block: property -> project, transparent inputs inside ONE
   muted box, nodes joined by the dashed spine. Spine offsets assume the
   two 50px control rows with no visible labels. */
.bkroute {
  position: relative;
  background: var(--bk-muted);
  border: none; border-radius: var(--bk-radius);
  padding: var(--space-4) var(--space-5);
}
.bkroute .bkspine {
  position: absolute; left: calc(var(--space-5) + 15px);
  top: 42px; bottom: 42px;
  border-left: 1.5px dashed var(--color-border);
}
.bkstop { display: flex; align-items: center; gap: var(--space-3); }
.bkroute .field { margin-top: 0; }
.bknode {
  position: relative; z-index: 1; flex: none;
  width: 32px; height: 32px;
  display: grid; place-items: center;
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border); border-radius: var(--radius-pill);
}
.bknode svg { width: 15px; height: 15px; stroke: var(--color-text); fill: none; stroke-width: 2; }
.sheet .panel.duo .bkroute input, .sheet .panel.duo .bkroute select {
  flex: 1; min-width: 0;
  background: transparent; border-radius: 0;
  padding-left: 0; padding-right: 0;
}
.bkroute input:focus-visible, .bkroute select:focus-visible { outline-offset: 2px; }
.bksplit {
  /* --color-border is the site's pure-black drawing ink; at 1px inside
     the muted box it reads as a hard rule, so the divider takes the
     soft tone instead (the reference's border-border grey). */
  border: none; border-top: 1px solid var(--color-border-soft);
  margin: var(--space-3) 0 var(--space-3) 44px;
}

/* Paired boxes: name/phone, then the date/time chips. */
.bkgrid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-4); }
/* Even split since 2026-08-20. The old 1.2fr/.8fr was sized for
   "Morning"; two-hour windows are three times that long and the select
   clipped. The date column pays for it by writing "Fri, Aug 21, 2026"
   rather than "Friday, August 21, 2026" (main.js, fmtShort). */
.bkgrid.when { grid-template-columns: 1fr 1fr; }
@media (max-width: 560px) {
  .bkgrid, .bkgrid.when { grid-template-columns: 1fr; }
}
/* Date chip: calendar icon LEADS, like the reference's chips; the clear
   control keeps the right edge. */
.sheet .panel.duo .dpick input { padding-left: 48px; padding-right: 52px; }
.sheet .panel.duo .dpick .dpick-open { left: 6px; right: auto; }
.sheet .panel.duo .dpick .dpick-clear { right: 4px; }
/* Both selectors are needed and neither is redundant: the first dresses the
   popover while it is closed and still inside the sheet, the second while it
   is OPEN and portaled to <body>, where no descendant selector rooted in
   .sheet can reach it (see main.js open()). --bk-radius is declared on
   .panel.duo, so the portaled copy cannot read it either and states the
   10px itself. */
.sheet .panel.duo .dpick-pop { --dpick-radius: 8px; border-radius: var(--bk-radius); }
.dpick-pop.in-sheet { --dpick-radius: 8px; border-radius: 10px; }
/* Time chip: clock icon + borderless select in one muted box. */
.bkchip { display: flex; align-items: center; background: var(--bk-muted); border-radius: var(--bk-radius); }
.bkchip > svg {
  width: 17px; height: 17px; flex: none; margin-left: var(--space-4);
  stroke: var(--color-text-muted); fill: none; stroke-width: 2;
}
.sheet .panel.duo .bkchip select {
  flex: 1; min-width: 0; background: transparent; border-radius: 0;
  padding-left: var(--space-3);
}

/* The CTA row: the footer reference's pill grammar. Solid-ink primary
   pill, and the call link as its quiet secondary pill. */
.sheet .panel.duo .btn1.send {
  background: var(--bk-ink); border-color: var(--bk-ink);
  color: var(--color-text-on-inverse);
  border-radius: var(--radius-pill); min-height: 52px; padding: 0 var(--space-7);
  font-weight: var(--weight-bold);
}
.sheet .panel.duo .btn1.send:hover {
  background: color-mix(in srgb, var(--bk-ink) 82%, var(--color-primary));
  border-color: color-mix(in srgb, var(--bk-ink) 82%, var(--color-primary));
}
.bkactions {
  display: flex; align-items: center; gap: var(--space-4); flex-wrap: wrap;
}
/* The rhythm rule above would set space-4 here; the CTA row gets the
   reference's extra pt-4 of separation instead. */
.sheet .panel.duo .formbody > .bkactions { margin-top: var(--space-6); }
.bkactions .send { flex: none; }
.bkcall {
  display: inline-flex; align-items: center; gap: 8px;
  min-height: 52px; padding: 0 var(--space-5);
  border: 1px solid var(--color-border); border-radius: var(--radius-pill);
  font-size: var(--text-md); font-weight: var(--weight-semi);
  color: var(--color-text-muted); text-decoration: none;
  transition: color var(--dur-hover) var(--ease-out-strong),
              border-color var(--dur-hover) var(--ease-out-strong),
              background var(--dur-hover) var(--ease-out-strong);
}
.bkcall:hover {
  color: var(--color-text); border-color: var(--color-text-muted);
  background: var(--bk-muted);
}
.bkcall svg {
  width: 15px; height: 15px; stroke: currentColor; fill: none; stroke-width: 2;
  transition: transform var(--dur-hover) var(--ease-out-strong);
}
.bkcall:hover svg { transform: translateX(3px); }
/* Roomier canvas on small screens without the desktop's full padding. */
@media (max-width: 560px) {
  .bkform { padding: var(--space-7) var(--space-5); }
}

/* The reference's staggered entrance. Animations, not transitions, so
   hover timings are untouched; backwards fill holds the start state
   through each delay. The <form> itself is excluded: its CHILDREN rise,
   and animating both would double the motion. */
@keyframes bkrise { from { opacity: 0; transform: translateY(12px); } }
.sheet.open .panel.duo .bkform > :not(form):not(.sent),
.sheet.open .panel.duo .formbody > * {
  animation: bkrise .45s var(--ease-out-strong) backwards;
}
.sheet.open .panel.duo .bkform > :nth-child(1) { animation-delay: .04s; }
.sheet.open .panel.duo .bkform > :nth-child(2) { animation-delay: .09s; }
.sheet.open .panel.duo .bkform > :nth-child(3) { animation-delay: .14s; }
.sheet.open .panel.duo .formbody > :nth-child(1) { animation-delay: .19s; }
.sheet.open .panel.duo .formbody > :nth-child(2) { animation-delay: .25s; }
.sheet.open .panel.duo .formbody > :nth-child(3) { animation-delay: .31s; }
.sheet.open .panel.duo .formbody > :nth-child(4) { animation-delay: .37s; }
.sheet.open .panel.duo .bkwhy { animation: bkrise .45s var(--ease-out-strong) backwards .1s; }

/* ---- Date picker (react-day-picker "Calendar" port, 2026-08-19 — the
   Origin UI skin rebuilt on ZEF tokens; replaces the Ark UI header of
   2026-08-18, same vanilla shell). Lives in the booking sheet's "When
   suits you" field. Without JS the buttons ship [hidden] and the field
   is a plain text input (rule 31). The popup renders IN FLOW inside the
   sheet's scrolling panel, never floating: the panel is overflow:auto
   and would clip anything positioned above or below it. Elevation comes
   from the depth system's ramp; every interaction transition here is
   under 300ms per the entrance-rule gotcha.
   --dpick-cell is 36px to the reference's size-9 grid, bumped to 44px
   on coarse pointers so the touch-target rule holds. --dpick-radius
   sits on the site's 2px drawing-set corner; the reference's 8px pill
   cells are that one var if ever wanted. ---- */
.dpick-control { position: relative; }
.dpick input { padding-right: 96px; }
/* The 96px is room for BOTH trailing buttons, and the clear one only exists
   once a date has been picked. Holding its 44px open the whole time cost the
   placeholder its last word in a half-width field, so the padding tracks
   what is actually on screen. The booking sheet's own rule is more specific
   and still wins there, where the open button sits on the left instead. */
.dpick:has(.dpick-clear[hidden]) input { padding-right: 52px; }
.dpick .dpick-clear, .dpick .dpick-open {
  position: absolute; top: 0; height: 100%; min-width: 44px;
  display: grid; place-items: center; background: none; border: none;
  cursor: pointer; color: var(--color-text-muted);
  transition: color var(--dur-hover) var(--ease-out-strong);
}
.dpick .dpick-open { right: 0; }
.dpick .dpick-clear { right: 44px; }
.dpick .dpick-clear:hover, .dpick .dpick-open:hover { color: var(--color-primary); }
.dpick .dpick-clear svg, .dpick .dpick-open svg {
  width: 17px; height: 17px; stroke: currentColor; fill: none; stroke-width: 2;
}
.dpick .dpick-clear:focus-visible, .dpick .dpick-open:focus-visible {
  outline: var(--ring-width) solid var(--color-primary); outline-offset: -2px;
}
/* display:grid above beats the [hidden] attribute's UA display:none, so
   without this the clear control shows on an EMPTY field (and both
   buttons would paint before main.js arms them). Found 2026-08-20. */
.dpick .dpick-clear[hidden], .dpick .dpick-open[hidden] { display: none; }
/* THE CALENDAR FLOATS, AND `position: fixed` IS WHY IT CAN (Fahad,
   2026-08-28: "When you click calendar the form should not expand like it
   currently is"). It used to render IN FLOW, which pushed every field below
   it down by the height of a month grid and grew the sheet as you opened it.
   Fixed, not absolute: the sheet's `.bkform` is `overflow-y: auto`, so an
   absolutely positioned popover is clipped the moment it passes the panel's
   edge -- which is exactly where a calendar opened from a field near the
   bottom of the form lands. Fixed takes it out of that clip entirely.
   The cost is that fixed coordinates are viewport coordinates and nothing
   in CSS knows where the field is, so `place()` in main.js writes top/left
   on open and again on scroll and resize. If this ever renders in the top
   left corner of the screen, that call is what went missing.
   z-index clears the booking sheet (60), because it opens inside it. */
.dpick-pop {
  --dpick-cell: 36px;
  --dpick-radius: var(--radius-xs);
  position: fixed; top: 0; left: 0; z-index: 70;
  width: -moz-fit-content; width: fit-content;
  padding: var(--space-2);
  border: 1.5px solid var(--color-border); border-radius: var(--radius-xs);
  background: var(--color-surface-raised);
  box-shadow: var(--el-floating), var(--edge-light);
}
@media (pointer: coarse) { .dpick-pop { --dpick-cell: 44px; } }
/* Header: centred caption, ghost chevrons pinned to the edges. */
.dpick-head {
  position: relative; height: var(--dpick-cell);
  display: flex; align-items: center; justify-content: center;
}
.dpick-caption {
  font-size: var(--text-base); font-weight: var(--weight-medium);
  color: var(--color-text);
}
.dpick-head .dpick-nav {
  position: absolute; top: 0; width: var(--dpick-cell); height: var(--dpick-cell);
  display: grid; place-items: center; background: none; border: none;
  border-radius: var(--dpick-radius); cursor: pointer;
  color: var(--color-text-muted);
  transition: background var(--dur-hover) var(--ease-out-strong),
              color var(--dur-hover) var(--ease-out-strong);
}
.dpick-head .dpick-nav[data-nav="-1"] { left: 0; }
.dpick-head .dpick-nav[data-nav="1"] { right: 0; }
.dpick-head .dpick-nav:hover:not(:disabled) {
  background: var(--color-surface-sunken); color: var(--color-text);
}
.dpick-head .dpick-nav:disabled { opacity: .35; cursor: default; }
.dpick-head .dpick-nav svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 2; }
.dpick-head .dpick-nav:focus-visible {
  outline: var(--ring-width) solid var(--color-primary); outline-offset: 2px;
}
.dpick-grid {
  display: grid; grid-template-columns: repeat(7, var(--dpick-cell));
  margin-top: var(--space-1);
}
.dpick-grid .dow {
  height: var(--dpick-cell); display: grid; place-items: center;
  font-size: var(--text-xs); font-weight: var(--weight-medium);
  color: var(--color-text-muted);
}
.dpick-day {
  position: relative; width: var(--dpick-cell); height: var(--dpick-cell);
  padding: 0; display: grid; place-items: center;
  background: none; border: none; border-radius: var(--dpick-radius);
  font-family: var(--font-sans); font-size: var(--text-base); color: var(--color-text);
  cursor: pointer;
  transition: background var(--dur-hover) var(--ease-out-strong),
              color var(--dur-hover) var(--ease-out-strong);
}
.dpick-day:hover:not(:disabled):not([aria-pressed="true"]) { background: var(--color-surface-sunken); }
.dpick-day:focus-visible {
  outline: var(--ring-width) solid var(--color-primary); outline-offset: 2px; z-index: 1;
}
.dpick-day.out { opacity: .35; }
.dpick-day:disabled {
  color: var(--color-text-muted); opacity: .35; cursor: default;
  text-decoration: line-through;
}
/* Today carries the reference's under-number dot, not a ring; on the
   chosen day the dot flips to the surface colour so it stays visible. */
.dpick-day[data-today]::after {
  content: ""; position: absolute; bottom: 4px; left: 50%;
  width: 3px; height: 3px; transform: translateX(-50%);
  border-radius: var(--radius-pill); background: var(--color-primary);
  pointer-events: none;
}
.dpick-day[aria-pressed="true"] {
  background: var(--color-primary); color: var(--color-text-on-primary);
}
.dpick-day[aria-pressed="true"][data-today]::after { background: var(--color-surface-raised); }

/* =====================================================================
   MOTION PREFERENCES
   ===================================================================== */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { transition: none !important; animation: none !important; }
  html { scroll-behavior: auto; }
  html.js .reveal { opacity: 1; transform: none; }
}

/* =====================================================================
   CODE REFERENCE — the legal-basement cluster (added 2026-08-16)

   The informative pivot (Fahad, 2026-08-16) gave the site five pages whose
   job is to state a requirement and the number attached to it. `.specs` is
   that pattern: the figure carried in mono at the left, the plain-language
   explanation beside it, and the clause it comes from underneath. `.codenote`
   is the standing line that the city has the final say, and it appears on
   every page in the cluster.
   ===================================================================== */
.specs { margin-top: var(--space-4); border-top: 1.5px solid var(--color-border-soft); }
.specs .spec {
  display: grid; grid-template-columns: 220px 1fr; gap: var(--space-4);
  padding: var(--space-4) 0; border-bottom: 1.5px solid var(--color-border-soft);
}
.specs .spec > b {
  font-family: var(--font-mono); font-size: var(--text-xl);
  color: var(--color-primary); font-weight: var(--weight-bold);
  letter-spacing: var(--track-snug); line-height: 1.2;
}
.specs .spec h3 { font-size: var(--text-lg); font-weight: var(--weight-black); }
.specs .spec p {
  margin-top: var(--space-1); color: var(--color-text-muted);
  font-size: var(--text-base); line-height: 1.65; max-width: 68ch;
}
.specs .spec cite {
  display: block; margin-top: var(--space-2); font-style: normal;
  font-family: var(--font-mono); font-size: var(--text-xs);
  color: var(--color-text-muted); letter-spacing: var(--track-wide);
  text-transform: uppercase;
}
@media (max-width: 760px) {
  .specs .spec { grid-template-columns: 1fr; gap: var(--space-1); }
  .specs .spec > b { font-size: var(--text-lg); }
}

.codenote {
  margin-top: var(--space-6); border-left: 3px solid var(--color-primary);
  padding: var(--space-3) var(--space-4); background: var(--color-surface-sunken);
}
.codenote p {
  font-size: var(--text-base); line-height: 1.65;
  color: var(--color-text-muted); max-width: 76ch;
}

/* Source list. The cluster cites where every number came from, so the page
   can be checked rather than trusted. */
.sources { margin-top: var(--space-4); }
.sources li {
  list-style: none; padding: var(--space-2) 0;
  border-bottom: 1px solid var(--color-border-soft);
  font-size: var(--text-base);
}
.sources a { color: var(--color-primary); }

/* Cluster index: the hub's doors into the five reference pages. */
.cluster { margin-top: var(--space-4); display: grid; gap: var(--space-2); }
.cluster a {
  display: flex; justify-content: space-between; align-items: baseline;
  gap: var(--space-3); text-decoration: none; color: var(--color-text);
  border: 1.5px solid var(--color-border-soft); background: var(--color-surface-raised);
  padding: var(--space-3) var(--space-4);
  transition: border-color var(--dur-micro), transform var(--dur-micro);
}
.cluster a:hover { border-color: var(--color-primary); transform: translateY(-2px); }
.cluster a b { font-size: var(--text-lg); font-weight: var(--weight-black); display: block; }
.cluster a i {
  font-style: normal; font-size: var(--text-base); color: var(--color-text-muted);
  display: block; margin-top: 2px; max-width: 62ch;
}
.cluster a .mono { font-family: var(--font-mono); color: var(--color-primary); }

/* Mega cards without a thumbnail. The Basements panel is set in type because
   the only basement photography on the site is a placeholder, and a borrowed
   frame in a menu labelled "Basements" reads as a job ZEF completed. */
.mcard.plain { padding: var(--space-3); align-items: flex-start; }
.mcard.plain > span { display: block; }
.mcard.plain b { font-size: var(--text-lg); }

/* =====================================================================
   EDITORIAL REBUILD (2026-08-16) — Fahad's ruling: fewer pictures, more
   words, less template. Three new components, all in the drawing-set
   language: the flagship feature block (type on deep navy, no photograph),
   the renovation schedule (a drawing set's sheet index as a services menu),
   and the on-page sheet index for the one-page basement flagship.
   ===================================================================== */

/* The sheet index on the one-page basement flagship: where everything on the
   sheet lives, the way a drawing set numbers its pages. */
.pagenav-wrap .inner { padding-top: var(--space-5); padding-bottom: 0; }
.sheetnav {
  display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--space-6);
  border-top: 1.5px solid var(--color-border);
}
.sheetnav a {
  display: flex; gap: var(--space-3); align-items: baseline;
  padding: var(--space-2) var(--space-1); text-decoration: none;
  color: var(--color-text); border-bottom: 1px solid var(--color-border-soft);
  font-size: var(--text-base);
  transition: background var(--dur-micro), padding-left var(--dur-micro);
}
.sheetnav a:hover { background: var(--color-surface-raised); padding-left: var(--space-2); }
.sheetnav a .mono { color: var(--color-primary); font-size: var(--text-sm); }
.sheetnav a b { font-weight: var(--weight-semi); }
@media (max-width: 620px) { .sheetnav { grid-template-columns: 1fr; } }

/* Spec group headings inside the one-page requirements section. */
.spec-group { margin-top: var(--space-6); font-size: var(--text-2xl); font-weight: var(--weight-black); letter-spacing: var(--track-snug); }
.spec-group:first-of-type { margin-top: var(--space-2); }

/* =====================================================================
   BUILD 4 (2026-08-16 late) — the Ashton reference treatment on the
   decided components. Reference: ashtonrenovations.com (built by
   Marketing Depot; analysed directly). What was taken: photo-filled
   service cards with text overlaid on a scrim, image-backed CTA bands,
   photo-backed interior page heroes. Palette and type stay ZEF's own.
   ===================================================================== */

/* CTA band over one of Zain's own photographs (Job A). Dark scrim keeps
   AA contrast; the photograph is atmosphere, never captioned as a
   specific project (rule 30). */
.quoteband.photo {
  position: relative; background-size: cover; background-position: 50% 45%;
}
.quoteband.photo::before {
  content: ""; position: absolute; inset: 0;
  background: rgba(10, 15, 23, .78);
}
.quoteband.photo .inner { position: relative; }

/* Interior page heroes carry their service photograph full-bleed under the
   standing dark scrim (Ashton pattern; images are the decided board v8 set). */
.hero-stage.compact.photo { position: relative; overflow: hidden; }
.hero-stage.compact.photo .hero-photo {
  position: absolute; inset: 0; z-index: 0;
}
.hero-stage.compact.photo .hero-photo img,
.hero-stage.compact.photo .hero-photo picture { width: 100%; height: 100%; object-fit: cover; display: block; }
/* THE SCRIM LIVES INSIDE .hero-photo, which is where the non-photo heroes
   have always put theirs (`.hero-photo::after`, further up). It used to be
   `section::after` at z-index 1, which forced `.hero-inner` to z-index 2 to
   clear it. Both versions render identically; this one is kept because one
   scrim pattern across all hero types beats two.

   The z-index came off .hero-inner with it and should stay off. It is not
   needed (the element is positioned and follows .hero-photo in the markup,
   so it paints above on document order alone) and a z-index there makes
   .hero-inner a STACKING CONTEXT, which silently traps anything inside it
   below a section-level layer. That cost a full round on 2026-08-20. */
.hero-stage.compact.photo .hero-photo::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(96deg, rgba(10,15,23,.9) 0%, rgba(10,15,23,.78) 34%, rgba(10,15,23,.45) 62%, rgba(10,15,23,.28) 100%);
}
.hero-stage.compact.photo .hero-inner { position: relative; }

/* Badge lockups: five constructions, as five different bodies would draw
   them. Grey carries the mark, steel is the one accent (their green).
   The band itself: quiet and light, per the reference. (The 5.3 navy certs
   band died with the seals; navy keeps the header, hero, flagship panel
   and CTA scrims, so the full-palette ruling still holds.) */
/* The seals band sits ON the ground, not above it (2026-08-20, Fahad: "the
   expertise page is white, change that as well"). It was still
   `--color-surface-raised`, so once the ground went grey it became a
   full-bleed WHITE STRIPE 1440px wide across ten pages — the single most
   visible thing that had not followed the ground change. It is a band, not
   a card: nothing lifts it, and its two hairlines are what define it, which
   is the same contract `.tstm` now runs on. */
.certs {
  border-top: 1.5px solid var(--color-border);
  border-bottom: 1.5px solid var(--color-border);
  background: var(--color-surface);
}
.certs .inner {
  max-width: var(--page-max); margin: 0 auto;
  display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center;
  gap: var(--space-7) var(--space-9); padding: var(--space-8) var(--space-6);
}
.badge { width: var(--bw, 140px); flex: none; transition: transform var(--dur-micro); }
.badge:hover { transform: translateY(-2px); }
@media (max-width: 1100px) { .certs .inner { justify-content: center; } }
.badge svg { width: 100%; height: auto; display: block; }
.badge .bring { font-family: var(--font-sans); font-size: 9.5px; font-weight: 700; letter-spacing: .13em; fill: #FFFFFF; }
.badge .bsolid { font-family: var(--font-sans); font-weight: 800; letter-spacing: .015em; fill: #303236; }
.badge .bword { font-family: var(--font-sans); font-size: 15px; font-weight: 800; letter-spacing: .06em; fill: #303236; }
.badge .bsub  { font-family: var(--font-sans); font-size: 8px; font-weight: 600; letter-spacing: .05em; fill: #6B6E71; }
.badge .bbig  { font-family: var(--font-sans); font-size: 23px; font-weight: 800; letter-spacing: .02em; fill: #303236; }
@media (max-width: 700px) {
  .certs .inner { gap: var(--space-5); }
  .badge { width: calc(var(--bw, 140px) * .82); }
}

/* =====================================================================
   HOME HERO — kitchen frame (Fahad's rulings, 2026-08-17)
   Full frame, no fade: the section takes the image's own aspect so
   nothing crops, and the scrim + drawing grid are off — the photograph
   carries the stage. Sits below the fixed header so the top of the
   frame is not covered. 4K-upscaled source, three srcset widths.
   ===================================================================== */
/* --hero-ratio is the ONE place the stage's shape lives (2026-08-22). The
   phone rule below and the slideshow's dot anchor both read it, so a ruling on
   the shape is a one-line change here and nothing else drifts. 2560/1434 is the
   STAGE IS 16:9 AND THE HERO IS A FULL PAGE. NON-NEGOTIABLE (Fahad,
   2026-08-22: "The hero page is supposed to be a full page, why would you
   shorten it? I want it to be big like the kitchen was"). A 3:1 stage was
   tried for one round to show a 3:1 photograph whole and was rejected on
   sight: at 1440 it made the hero 480px of a 900px screen. THE STAGE NEVER
   BENDS TO AN IMAGE; the image is supplied to fit the stage. Any slide that
   is not 16:9 cover-crops, and that is the correct behaviour, not a bug. */
.hero-stage.home { --hero-ratio: 2560 / 1434; min-height: 0; aspect-ratio: var(--hero-ratio); margin-top: var(--header-h, 72px); }
/* NO WASH ON THE HOME HERO (Fahad, 2026-08-22: "remove the shading from the
   left side"). A 90deg paper gradient sat here from 2026-08-17, because the
   kitchen frame put ink display type on white marble at about 1.6:1.
   THE PHOTOGRAPH CHANGED AND THE REASON WENT WITH IT: the living-room master
   is generated with its left half deliberately bright and empty (sheer
   curtains blown out), so the headline carries its own contrast.

   IT MUST BE `content: none`, NOT A DELETED OVERRIDE. Deleting this rule
   does not remove the wash, it falls back to `.hero-photo::after` further up
   which paints `--hero-scrim`, the SITE default -- and that one is STRONGER
   (97% paper at the left edge against the home rule's 93%). Removing the
   override made the shading heavier, which is the opposite of the ruling.
   Same trap as any "just delete the override" on a component with a base
   rule; read the base before deleting the child.

   MEASURED, wash off, on the pixels the letters actually sit on (per-line
   ink boxes from the DOM, darkest 1% of each, both slides):
     slide 1 living room  "Your plan," black  13.99:1   "our execution." steel 4.40:1
     slide 2 kitchen      "Your plan," black  16.95:1   "our execution." steel 3.46:1
   The floor for 92px Anton is 3:1 (large text), so both clear it, and the
   black line clears the 4.5:1 body bar several times over. Steel on the
   kitchen at 3.46:1 is the tightest number on the page.
   THIS IS A CONTRACT ON EVERY FUTURE SLIDE: a slide whose left half carries
   detail or mid-tone under the copy breaks it, and the numbers have to be
   re-measured before it ships, not assumed. It is why the generator prompt
   at `../01 - Hero/living-room-hero/prompt-16x9.txt` reserves that zone. */
/* THE WHITE WASH IS BACK (Fahad, 2026-08-29: "add the shading element of
   white like you had previously done"). This is the EXACT gradient that stood
   here until 2026-08-22, recovered from commit 4204f24 rather than
   re-invented, so "like you had previously done" means the same numbers and
   not a fresh guess: 93% paper at the left edge falling to nothing at 68%.

   IT RETURNS IN THE SAME ROUND THE KITCHEN BECAME SLIDE ONE, AND THAT IS NOT
   A COINCIDENCE. The wash was removed on 2026-08-22 because the living-room
   master carries its own contrast: its left half is deliberately blown out.
   The kitchen frame does not -- it puts display type over white marble, which
   is the reason this gradient was written in the first place, and with the
   wash off it measured 3.46:1 on "our execution." against a 3:1 floor, the
   tightest number anywhere on the site. Slide one is now the frame that
   needs it.

   TO REMOVE IT AGAIN, SET `content: none` -- DO NOT DELETE THE RULE. Deleting
   the override falls back to `.hero-photo::after` further up, and the SITE
   default is STRONGER (97% at the left edge against this rule's 93%), so
   deleting made the shading heavier last time. Read the base before touching
   the child. The phone block below keeps `content: none` on purpose: there
   the copy sits on paper under the photograph, not on it. */
.hero-stage.home .hero-photo::after {
  content: "";
  background: linear-gradient(90deg,
    rgba(244, 244, 242, .93) 0%, rgba(244, 244, 242, .82) 26%,
    rgba(244, 244, 242, .44) 44%, rgba(244, 244, 242, .09) 58%,
    rgba(244, 244, 242, 0) 68%);
}
.hero-stage.home .hero-inner { min-height: 0; height: 100%; }
.hero-stage.home .hero-copy p { color: var(--color-text); }
/* THE PHONE HERO IS A FULL-BLEED FRAME WITH THE COPY ON IT (Fahad,
   2026-08-29, option B of two rendered at 375: "the hero banner > Your Plan
   our execution is written below the image with a white background").

   WHAT THIS REPLACES AND WHY IT LOOKED WRONG. The block here showed the
   WHOLE 16:9 frame uncropped and set the copy on paper beneath it. On a
   laptop that is a cinematic band; at 375px a 16:9 photograph is 211px
   tall, so it read as a thin strip with a white slab bolted underneath.
   The intent was "never crop the photograph" and the cost was the hero
   stopping looking like a hero.

   3:4, WHICH MEANS THE PHONE CROPS. That is the accepted trade and it is
   consistent with the standing rule that the stage never bends to an image
   (2026-08-22); `object-position: 60% 45%` keeps the range and the marble
   in frame on the kitchen slide and the seating on the living-room slide.

   THE TYPE INVERTS, AND IT USES THE SITE'S OWN INVERSE PAIR rather than
   new values: `--color-text-on-inverse` and `--color-accent-inverse` are
   what `.hero-stage.compact` already sets on navy across eight pages, so
   the phone hero joins an existing pattern instead of inventing one.

   THE SCRIM IS DARK HERE AND PAPER ON DESKTOP, which is not an
   inconsistency. Desktop washes 90deg across the left third because the
   copy sits beside the picture; a phone has no beside, so the copy sits at
   the foot of the frame and the gradient has to run 180deg. Different
   axis, different colour, same job.

   MEASURED ON THE PIXELS THE LETTERS SIT ON, both slides, against the
   brightest 1% of each line's own box (which is the worst case for light
   ink). The floor for display type at this size is 3:1. */
@media (max-width: 860px) {
  .hero-stage.home {
    aspect-ratio: 3 / 4;
    margin-top: var(--header-h, 72px);
  }
  .hero-stage.home .hero-photo {
    position: absolute; inset: 0; aspect-ratio: auto;
  }
  .hero-stage.home .hero-photo img {
    height: 100%; object-fit: cover; object-position: 60% 45%;
  }
  /* `content: ""` and not a deleted override, for the reason written on the
     desktop rule above: deleting falls back to the site default `--hero-scrim`,
     which is PAPER, and would wash this frame pale under white type. */
  .hero-stage.home .hero-photo::after {
    content: "";
    background: linear-gradient(180deg,
      rgba(11, 16, 24, 0) 24%,
      rgba(11, 16, 24, .52) 54%,
      rgba(11, 16, 24, .90) 100%);
  }
  .hero-stage.home .hero-inner {
    position: absolute; inset: 0; height: auto;
    align-content: end;
    padding: 0 var(--space-5) var(--space-6);
  }
  .hero-stage.home .hero-copy h1,
  .hero-stage.home .hero-copy h1 .hl-a { color: var(--color-text-on-inverse); }
  .hero-stage.home .hero-copy h1 .hl-b { color: var(--color-accent-inverse); }
  .hero-stage.home .hero-copy p { color: var(--color-text-muted-inverse); }
  /* The button inverts with the type. Left as ink it became a near-black
     block on a near-black gradient: still legible, but it stopped reading
     as the one thing to press. */
  .hero-stage.home .hero-copy .btn1 {
    background: var(--color-text-on-inverse);
    border-color: var(--color-text-on-inverse);
    color: var(--zef-ink);
  }
  .hero-stage.home .hero-copy .btn1:hover {
    background: #fff; border-color: #fff;
  }
}

/* =====================================================================
   HOME HERO SLIDESHOW (Fahad, 2026-08-22)
   Two photographs so far, in HERO_SLIDES (generate.py). Crossfade, no
   travel, no Ken Burns beyond the existing settle; 6.5s hold + 1.5s fade.

   THE CROSSFADE NEVER DIPS TO THE GROUND. Two absolutely positioned
   slides fading past each other meet at 50% + 50%, which lets the paper
   show through for half a second as a flash. So the OUTGOING slide does
   not fade at all: main.js marks it `.was-on` (opaque, underneath) while
   the incoming `.is-on` fades in on top, and only drops `.was-on` once the
   fade has landed, at which point it is fully hidden anyway.

   NO JS: slide one carries `.is-on` in the markup and the others are at
   opacity 0, so the hero is exactly the old single photograph. The dots
   are `display:none` until html.js, because without the script they would
   be two buttons that do nothing.

   REDUCED MOTION: the global switch above kills the fade, and main.js never
   starts the timer, so the hero is slide one, still; the dots still work
   and swap instantly. WCAG 2.2.2: the dots ARE the stop control (a tap
   picks a photograph and ends the auto-advance for that visit).
   ===================================================================== */
/* THE SLIDE LAYER IS ITS OWN STACKING CONTEXT, AND THAT LINE IS LOAD-BEARING.
   `.hero-photo` has no z-index of its own, so the slides' z-index (2 for the
   live one, 1 for the outgoing one) would otherwise resolve in the SECTION's
   stacking context and paint over `.hero-inner`, whose whole layering rests
   on document order and no z-index (see "DO NOT PUT A Z-INDEX BACK ON
   .hero-inner"). That is exactly what happened on the first build of this:
   the photographs rendered, the headline, the button and the wash did not,
   and elementFromPoint still reported the headline on top because the photo
   layer is pointer-events: none. `isolation: isolate` keeps the slide
   z-order inside the layer; the wash (`::after`) then needs its own z-index
   inside that context to sit above the live slide. */
.hero-photo.hero-slides { isolation: isolate; }
.hero-photo.hero-slides::after { z-index: 3; }
.hero-photo .slide { position: absolute; inset: 0; opacity: 0; transition: opacity 1.5s var(--ease-out); }
.hero-photo .slide.is-on { opacity: 1; z-index: 2; }
.hero-photo .slide img { display: block; }

/* The hero dots were removed 2026-09-09 with the slideshow (single static
   hero). `.hero-photo .slide` and `.slide.is-on` stay: the one remaining
   photograph still uses them, and `zefSettle` hangs off `.is-on`. */

/* =====================================================================
   (2026-08-19, late) THE SCROLL EXPERIENCE IS REMOVED — Fahad's revert
   ruling after three same-day versions (hero recede, the deck+curtain,
   full-screen pages with a scrollytelling process). The process cards
   were gated behind scroll cues and "disappeared" on his machine, and
   the feel was never the one he was after. The complete system —
   markup wrapping, gates, keyframes, nav auto-hide, curtain footer —
   is parked at `../06 - Archive/scroll-experience-v3-2026-08-19/`.
   Do not reintroduce any of it without a new ruling AND a live target
   reference whose feel is confirmed as the goal.
   ===================================================================== */

/* =====================================================================
   THE SCOPE LIST — Fahad's kitchen deck, 2026-08-22, section 2.
   The first real list a service page has carried. Two columns where
   there is room, one where there is not; a drawn tick rather than a
   bullet glyph, because a round bullet is a shape nothing else on this
   site uses and the drawing-set language runs on ticks and rules.
   ===================================================================== */
.svclist {
  list-style: none; margin-top: var(--space-5);
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0 var(--space-6);
  border-top: 1.5px solid var(--color-border-soft);
}
/* The tick is a GRID ITEM, not an absolutely positioned guess. The first
   build set it at `top: 1.35em` with a percentage translate and it landed
   differently on every row that wrapped, because 1.35em of a two-line item
   is not where its first line sits. Two columns and `align-items: start`
   put it on the first line by construction. */
.svclist li {
  display: grid; grid-template-columns: 16px 1fr; align-items: start;
  padding: 13px 0;
  border-bottom: 1px solid var(--color-border-soft);
  color: var(--color-text);
  font-size: var(--text-base);
  line-height: 1.5;
}
.svclist li::before {
  content: ""; margin-top: .46em;
  width: 5px; height: 9px;
  border: solid var(--color-primary);
  border-width: 0 1.5px 1.5px 0;
  transform: rotate(45deg);
}
@media (max-width: 700px) {
  .svclist { grid-template-columns: 1fr; }
}


/* =====================================================================
   THE LABELLED PHOTOGRAPH — "What We Handle", kitchens (Fahad, 2026-08-23,
   layout A off the labelled-photo board: "I like a").

   NO JAVASCRIPT TOUCHES THIS COMPONENT. There is no list beside the picture
   for the labels to link to, so they have nothing to toggle -- they are
   positioned text that is always present. Rule 31 holds by construction, not
   by a `html:not(.js)` fallback, which is the strongest form of it on the
   site.

   THE KEY IS THE ACCESSIBLE SOURCE AND THE PHONE LAYOUT AT THE SAME TIME.
   Nine mono labels cannot fit across a 331px-wide image, so under 900px the
   on-image labels hide and the ordered list under the picture takes over. The
   list is in the DOM at every width; the on-image labels are aria-hidden, so
   nothing is announced twice and nothing is lost at any width.
   ===================================================================== */
.lph { margin: var(--space-5) 0 0; }
.lphshot { position: relative; line-height: 0; }
.lphshot picture, .lphshot img { display: block; width: 100%; height: auto; }

/* THE MARK IS THE DOT AND NOTHING ELSE (2026-08-24). It used to be a flex
   row of dot + label, which meant the label's width pushed the dot off the
   point it marks the moment the label was hidden or shown. The mark is now a
   fixed 28px box pinned at --x/--y, and the label is positioned against it,
   so opening a name never moves the anchor. */
.lphmark {
  position: absolute; left: var(--x); top: var(--y);
  transform: translate(-50%, -50%);
  width: 22px; height: 22px; padding: 0; border: 0; background: none;
  cursor: pointer; z-index: 1;
}
.lphmark[aria-expanded="true"] { z-index: 2; }
/* THE DOT STAYS 22px AND THE TARGET BECOMES 44px (2026-08-29). These mark a
   POINT on a photograph, so the visible chip cannot grow without pointing
   somewhere less precisely. The hit area is expanded underneath it instead:
   `inset: -11px` on a pseudo-element takes 22 to 44 in both axes and moves
   nothing on screen. 44 of these across six pages were 22px targets. */
.lphmark::after { content: ""; position: absolute; inset: -11px; }
.lphdot {
  /* SOFT SQUARE, not a circle (Fahad, 2026-08-24, label builder pick:
     soft square / Anton / mono caps). Anton and the mono name were already
     shipping, so the chip is the whole change. */
  width: 100%; height: 100%; border-radius: var(--radius-sm);
  background: rgba(255, 255, 255, .94);
  border: 1.4px solid var(--color-primary); color: var(--color-primary);
  font: 400 14px/1 var(--font-display); letter-spacing: .02em;
  display: flex; align-items: center; justify-content: center;
  transition: background var(--dur-hover) var(--ease-out-strong),
              color var(--dur-hover) var(--ease-out-strong);
}
/* THE NAME. Hidden with opacity, NEVER `display: none`: it is the button's
   accessible name, and the numbered key that used to carry the names for a
   screen reader is gone. Absolutely positioned so it cannot move the dot. */
.lphlab {
  position: absolute; top: 50%; left: calc(100% + 7px);
  transform: translateY(-50%) scale(.92); transform-origin: left center;
  font: var(--weight-medium) 9.5px/1 var(--font-mono);
  letter-spacing: .06em; text-transform: uppercase; white-space: nowrap;
  color: var(--color-text); background: rgba(255, 255, 255, .94);
  padding: 6px 8px; border-radius: var(--radius-xs);
  box-shadow: 0 2px 10px rgba(0, 0, 0, .16);
  opacity: 0; pointer-events: none;
  transition: opacity var(--dur-hover) var(--ease-out-strong),
              transform var(--dur-hover) var(--ease-out-strong);
}
/* Names on the right half of the frame open to the LEFT of their dot, or
   they run off the picture. `data-side` is set per part in generate.py. */
.lphmark[data-side="l"] .lphlab {
  left: auto; right: calc(100% + 7px);
  transform: translateY(-50%) scale(.92); transform-origin: right center;
}
/* OPEN: clicked (aria-expanded), focused, or hovered on a fine pointer. */
.lphmark[aria-expanded="true"] .lphlab,
.lphmark:focus-visible .lphlab {
  opacity: 1; transform: translateY(-50%) scale(1);
}
.lphmark[aria-expanded="true"] .lphdot,
.lphmark:focus-visible .lphdot { background: var(--color-primary); color: #fff; }
@media (hover: hover) and (pointer: fine) {
  .lphmark:hover .lphlab { opacity: 1; transform: translateY(-50%) scale(1); }
  .lphmark:hover .lphdot { background: var(--color-primary); color: #fff; }
}
@media (prefers-reduced-motion: reduce) {
  .lphlab { transition: opacity var(--dur-hover) linear; transform: translateY(-50%); }
  .lphmark[data-side="l"] .lphlab { transform: translateY(-50%); }
  .lphmark[aria-expanded="true"] .lphlab,
  .lphmark:focus-visible .lphlab,
  .lphmark:hover .lphlab { transform: translateY(-50%); }
}

/* =====================================================================
   THE SERVICE-PAGE SPLIT: words left, picture right (Fahad, 2026-08-24).
   Kitchens is the first page on this template; the other service pages
   follow it. Basements is the exception, by his ruling.

   The picture is roughly half the measure it used to be, which is the point,
   and it is also why the pinned text labels come off inside `.lph-split`:
   nine of them already crowded at full width. The dots stay as anchors and
   the numbered key does the naming, from the left column. That is the same
   trade the phone has always made, so the two treatments now agree.
   ===================================================================== */
/* NO TOP MARGIN: the section heading now lives INSIDE the words column
   (2026-08-24), so there is nothing above this to be pushed away from. */
.svcsplit { display: grid; gap: var(--space-6); }
/* The heading and the words are one block, so the picture aligns against
   BOTH. It carries NO margin of its own: every other section on these pages
   runs its heading straight into its `.prose` at a 0px gap (The Work and the
   process section both measure 0), and a --space-5 here made this one section
   sit 22px looser than its neighbours (Fahad, 2026-08-24: "there is more
   white space between what we handle and the sub copy compared to the other
   sections"). The h2's own line-height is the spacing, exactly as elsewhere. */
.svcsplit-words > .sec { margin-bottom: 0; }
@media (min-width: 901px) {
  .svcsplit {
    /* THE PICTURE IS THE SMALLER COLUMN (Fahad, 2026-08-24: "the picture is
       too big which has resulted in too much white space").

       Even halves put a 576px square beside 247px of words and left 329px of
       nothing under the text, because the row is sized by the taller of the
       two and the columns aligned to the top. The photograph now takes 0.7
       of the pair, and what slack is left is CENTRED rather than all falling
       to the bottom, so the two columns read as a pair instead of one
       running out early. */
    /* THE PICTURE COLUMN IS A FIXED WIDTH, and the width is chosen so the
       SQUARE photograph finishes level with the words beside it (Fahad,
       2026-08-24: "the white space between the sections is different ... make
       them homogenous").

       Every section on this page pads its content 70px top and bottom, and
       that was already true here. What differed is that the deepest thing in
       THIS section was the picture, not the text: the words stopped 64px
       higher, so the gap under them read as 134px against everyone else's 70.
       A square source means height follows width, so setting the column sets
       the finish line. Re-check this number if the copy or the source ratio
       changes. */
    grid-template-columns: minmax(0, 1fr) var(--split-media, 340px);
    /* TOP-ALIGNED, NOT CENTRED (Fahad, 2026-08-24: "it should be homogenous
       with the other sections").

       Centring the words against the taller picture pushed this section's
       heading 102px below its own section top, where every other section on
       the page starts its heading at 70px. Measured, not eyeballed: The
       Work 70, The Process 71, Recent Projects 70, the closing band 70.
       Top-aligning puts the heading and the picture on one line and returns
       this section to that rhythm. The slack left under the shorter column is
       65px, which is a margin, not the hole the even-halves layout left. */
    gap: var(--space-8); align-items: start;
  }
  .lph-split { margin: 0; }
  /* A 30px dot is sized for a 1196px photograph, not a 570px one. */
  /* The dot fills its mark, so this sets the NUMERAL only: sizing the dot
     here fought .lphmark's 26px and left the number a size under what the
     label builder previewed. */
  .lph-split .lphdot { font-size: 13px; }
}

@media (max-width: 900px) {
  /* The phone gets the same treatment as the desktop now: tap a dot, the
     name pops. It used to hide the labels and print a numbered key under the
     photograph, and that key is gone (Fahad, 2026-08-24). */
  .lphdot { font-size: 12px; }
  .lphmark { width: 24px; height: 24px; }
}
@media (prefers-reduced-motion: reduce) {
  .lphdot { transition: none; }
}


/* =====================================================================
   PHOTOGRAPH CAROUSEL — Fahad's kitchen deck, 2026-08-22, section 4:
   "Display an image carousel ... Do not include captions, project
   information, or a featured-project panel."

   IT IS A SCROLL-SNAP TRACK, AND THAT IS RULE 31. An opacity stack
   would hide every frame but one from a visitor with no JS; this is a
   real scrollable element, so all of them are reachable by swipe,
   trackpad or arrow key before a line of JS runs, and .cnav (the arrows
   and dots) is display:none until html.js exactly like .steptablist.

   THE PHOTOGRAPHS ARE CONTAINED, NEVER CROPPED (standing rule). The
   frames are portrait and landscape mixed, so the stage is a fixed box
   with a dark matte and each image sits inside it at its own aspect.
   Cropping them to one shape is the single thing that rule forbids.
   ===================================================================== */
.carousel { margin-top: var(--space-5); }
/* THE STAGE'S HEIGHT IS SET HERE, ON THE TRACK, AND IT HAS TO BE.
   The first build put `aspect-ratio` on the SLIDE instead. A slide is a
   flex item, flex items stretch by default, and a stretched item's height
   comes from the line, not from its own aspect-ratio -- so the tallest
   photograph set the height and a 736x1104 portrait made the stage 1104px
   tall with the landscape frames stranded inside it. The track's width is
   definite (it is the section's `.inner`), so an aspect-ratio here gives a
   definite height, which is also what lets `max-height: 100%` on the image
   resolve to anything at all. `max-height` caps it on a short window. */
.ctrack {
  list-style: none; display: flex; margin: 0; padding: 0;
  overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  border: 1px solid var(--color-border);
  background: var(--zef-ink);
  /* WIDTH IS EXPLICIT BECAUSE max-height WOULD OTHERWISE EAT IT. With both
     axes auto and an aspect-ratio set, clamping the HEIGHT re-derives the
     WIDTH from the ratio: the stage came back 893px wide inside a 1196px
     section, ending 300px short of the heading above it. Pinning the width
     leaves max-height to do only what it is for, capping the stage on a
     short window; the photographs contain either way, so the box not being
     exactly 16:10 there costs nothing. */
  width: 100%;
  aspect-ratio: 16 / 10; max-height: 62vh;
}
.ctrack::-webkit-scrollbar { display: none; }
.ctrack:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 3px; }
.cslide {
  flex: 0 0 100%; scroll-snap-align: center; scroll-snap-stop: always;
  height: 100%; min-width: 0;
  position: relative;
}
.cslide picture { display: contents; }
/* THE IMAGE IS AN ABSOLUTELY POSITIONED BOX WITH object-fit, NOT AN
   auto-SIZED ONE INSIDE max-height: 100%. The first build did the latter
   and the photographs overflowed the stage by 750px: `max-width` capped,
   `max-height` silently did not, because a percentage max-height only
   resolves against a parent whose height the layout engine already treats
   as definite, and a grid area inside a stretched flex item is not that.
   `inset: 0` takes its box from the slide's padding box, which IS definite,
   so the contain is computed rather than hoped for. The padding is the dark
   matting the standing no-crop rule asks for. */
.cslide img {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: contain;
  padding: var(--space-3);
}

/* The controls. Hidden until JS, per the step-tabs contract. */
.cnav { display: none; }
html.js .cnav {
  display: flex; align-items: center; justify-content: center;
  gap: var(--space-4); margin-top: var(--space-4);
}
.carrow {
  width: 44px; height: 44px; flex: 0 0 44px;
  display: grid; place-items: center; cursor: pointer;
  background: var(--color-surface-raised); color: var(--color-text);
  border: 1px solid var(--color-border);
  transition: background var(--dur-micro), color var(--dur-micro);
}
.carrow svg { width: 20px; height: 20px; }
.carrow.prev svg { transform: rotate(180deg); }
.carrow:hover { background: var(--color-primary); color: #fff; border-color: var(--color-primary); }
.carrow[disabled] { opacity: .35; cursor: default; }
.carrow[disabled]:hover { background: var(--color-surface-raised); color: var(--color-text); border-color: var(--color-border); }
.cdots { display: flex; gap: 2px; }
/* 44x44, NOT 32x44. The phone driver measured the first build's dots at
   32px on the narrow axis, under the checklist's 44px floor, which is the
   whole reason that harness exists. The BAR stays short; it is the hit box
   that grew, which is the point -- the mark and the target are different
   things. */
.cdot {
  width: 44px; height: 44px; padding: 0; cursor: pointer;
  background: none; border: 0;
  display: grid; place-items: center;
}
/* INACTIVE IS THE FAINT ONE AND ACTIVE IS INK. The first build had these
   the wrong way round without looking wrong in the source: it used
   `--color-border` for the rest, and on this site that token IS
   `--zef-ink`, pure black, while the active dot's `--color-primary` is the
   steel #3A5069 -- so the selected dot was the palest mark in the row.
   Measured, not eyeballed: rgb(0,0,0) against rgb(58,80,105). */
.cdot::before {
  content: ""; width: 26px; height: 3px;
  background: var(--color-border-soft);
  transition: background var(--dur-micro);
}
.cdot.is-on::before { background: var(--color-text); }
.cdot:hover::before { background: var(--color-text-muted); }
.cdot:focus-visible { outline: 2px solid var(--color-primary); outline-offset: -4px; }

@media (max-width: 700px) {
  .ctrack { aspect-ratio: 4 / 3; max-height: 56vh; }
  .cslide img { padding: var(--space-2); }
}
/* The snap animation is motion; the track stays scrollable without it. */
@media (prefers-reduced-motion: no-preference) {
  html.js .ctrack { scroll-behavior: smooth; }
}


/* A SENTENCE-LENGTH CLOSING HEADING NEEDS A SENTENCE'S MEASURE. The band's
   default cap is around 9ch, set for "Start your project"; Fahad's kitchen
   closing line is a full sentence and set on four lines in a 374px column
   inside a 1240px section. Opt-in via quote_band(wide_head=True) so the
   other eleven bands are untouched.

   34ch, AND THE NUMBER IS MEASURED. `ch` is the width of the "0" glyph, and
   in Anton, a condensed display face, that is about 20.8px at this size --
   so the base rule's 18ch is only 374px and the first attempt at a "wide"
   variant, 17ch, was NARROWER than the default while reading wider in the
   source. Do not reason about ch here from a body-text intuition. */
.quoteband.headwide h2.sec { max-width: 34ch; }


/* =====================================================================
   BLOCK RHYTHM FOR THE STACKED SECTIONS (Fahad's kitchen deck,
   2026-08-22). His sections stack prose against a list, a tab strip and a
   carousel, which no page had done before: every previous section held a
   `.prose` ALONE, so `.prose` never needed a margin against a sibling and
   never had one. Rather than give `.prose` or `.steptabs` a blanket margin
   -- which would move the other four service pages and the two basement
   pages, whose copy Fahad has not rewritten -- the air is opt-in and the
   kitchen builder is the only caller.
   ===================================================================== */
.prose.spaced { margin-top: var(--space-6); }
.stepwrap { margin-top: var(--space-6); }

/* THE STEP PICKERS DO NOT EXIST BELOW 901px (2026-08-29). `.sspick` is the
   invisible button that selects a step on the desktop stage. Below 901px
   there IS no stage -- the steps are a plain list, each above its own
   photograph -- so the button controls nothing, yet it still rendered as a
   331x4px tap target and still sat in the tab order announcing "Show step
   N". 38 of them across nine pages, every one a dead control. `display:
   none` rather than a hit-area fix on purpose: the right size for a button
   that does nothing is no button. */
@media (max-width: 900px) { .sspick { display: none; } }


/* =====================================================================
   STEP TABS — the Ashton planning-design reference translated to ZEF
   tokens (Fahad, 2026-08-17). A strip of Step tabs, one panel at a
   time. Rule 31: without JS the strip is hidden and every panel stays
   open; main.js arms behaviour + ARIA.
   ===================================================================== */
/* Same call as `.certs`: a flat bordered REGION with no elevation of its
   own, so on the grey ground a white fill read as an inset white box. The
   tab strip's own two-state system is untouched and still works, because
   inactive tabs are sunken (#E9E9E6) and the active one stays white, which
   now lifts off the panel instead of matching it. */
/* THE STEP TABS ARE IN THE SITE'S OWN FONT (Fahad, 2026-08-22: "change the
   font in the process table to match the font used in the website"). The
   component was built with `.mono` (IBM Plex Mono) on the "Step 01" label
   and on the panel's big ordinal, which made it the only place on a page
   where UI text left `--font-sans`.

   SCOPED TO `.steptabs`, NEVER TO `.mono` ITSELF. That class is also on the
   slideshow arrows, the dashboard actions, the half-tile ordinals and the
   testimonial strip's placeholder mark; re-pointing the token or the bare
   class would have quietly restyled the home and contact pages too.

   THIS DOES MOVE ALL SEVEN PAGES THAT CARRY STEP TABS, which is deliberate:
   it is one component with one defect, and leaving kitchens in the site font
   while washrooms, decks, the two renovation pages and the two basement
   pages kept the typewriter face would be the same widget in two fonts. */
/* THE STEP RAIL — Fahad's Ark UI "Steps with lines" reference, ported
   (2026-08-24). Equal columns, a full-width rule above each label, and the
   accent carried by every step up TO AND INCLUDING the current one, which is
   what makes it read as progress rather than as a selected tab.

   The reference's `data-complete` / `data-current` / `data-incomplete` are
   .done / .on / neither here, toggled by the same main.js that already ran
   this widget. Tailwind blue-600 is --color-primary. The box, the dividers
   and the sunken tab fill are gone: the rule IS the control now. */
.steptabs { border: 0; background: none; }
.steptabs .mono { font-family: var(--font-display); font-weight: 400;
  letter-spacing: .05em; }
.steptablist { display: none; }
html.js .steptablist {
  display: flex; align-items: flex-start; gap: var(--space-4);
  margin-bottom: var(--space-6);
}
.steptab {
  flex: 1 1 0; min-width: 0; padding: 0; text-align: left; cursor: pointer;
  background: none; border: 0; color: var(--color-text-muted);
  display: flex; flex-direction: column; align-items: stretch; gap: 9px;
}
.steprule {
  display: block; width: 100%; height: 4px; border-radius: 999px;
  /* SOFT, not --color-border. That token is --zef-ink, pure black, and it is
     sized for 1px hairlines: as a 4px bar it read as four solid black rules
     and buried the accent it is supposed to sit behind. */
  background: var(--color-border-soft);
  transition: background var(--dur-hover) var(--ease-out-strong);
}
.steptab .mono { font-size: var(--text-xs); letter-spacing: var(--track-caps); text-transform: uppercase; }
.steptab b {
  font-size: var(--text-sm); font-weight: 600; color: var(--color-text);
  font-family: var(--font-sans); line-height: 1.35;
  transition: color var(--dur-hover) var(--ease-out-strong);
}
/* Everything up to and including the current step is lit. */
.steptab.on .steprule,
.steptab.done .steprule { background: var(--color-primary); }
.steptab.on .mono,
.steptab.on b { color: var(--color-primary); }
.steptab:hover .steprule { background: var(--color-primary); }
.steptab:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 4px; border-radius: 2px; }
.steppanel {
  display: grid; grid-template-columns: 64px 1fr; gap: var(--space-5);
  align-items: start; padding: 0;
  border-bottom: 1px solid var(--color-border-soft);
  padding-bottom: var(--space-5); margin-bottom: var(--space-5);
}
.steppanel:last-child { border-bottom: 0; padding-bottom: 0; margin-bottom: 0; }
/* With JS the panels are one at a time, so the divider between them is
   meaningless and the last-child rule cannot reach the shown one. */
html.js .steppanel { border-bottom: 0; padding-bottom: 0; margin-bottom: 0; }
html.js .steppanel { display: none; }
html.js .steppanel.on { display: grid; }
.steppanel .num { font-family: var(--font-display); font-weight: 400;
  font-size: 34px; line-height: .9; letter-spacing: .03em; color: var(--color-primary); }
.steppanel h3 { margin-bottom: 6px; }
.steppanel p { color: var(--color-text-muted); line-height: 1.7; max-width: 68ch; }
@media (max-width: 640px) {
  /* The labels will not set side by side in four columns on a phone, so the
     rail keeps its columns and DROPS THE WORDS: four rules and the step
     numbers still read as progress, and the title is in the open panel
     directly beneath, where it is not competing for 80px of width. */
  html.js .steptablist { gap: 8px; }
  .steptab b { display: none; }
  .steptab { gap: 7px; }
  .steppanel { grid-template-columns: 44px 1fr; }
}


/* =====================================================================
   HOW A PROJECT RUNS — pinned-card board (2026-08-19)
   Fahad's 21st.dev "How It Works" reference, ported vanilla (Build 6.9
   precedent). Architecture copied: cards pinned to the board, scattered
   left/right with a slight rotation, joined by a dashed path that crawls,
   on a ruled ground. Skin is ZEF's: navy board (the home page keeps its
   dark-ground rhythm), white cards with SHARP corners (the site's
   personality; the reference's 25px rounds stay in the reference), steel
   ink, Caveat numbers. Photographs stay per the 2026-08-15 rulings, at
   ONE uniform box, CONTAINED never cropped (standing rule).

   Static content: readable end to end with no JS (rule 31), mobile-first
   (the scatter is a >=901px layer). Elevation and hover live in the DEPTH
   & MOTION SYSTEM block at the end of this file, like every other card.
   The one-at-a-time treatment this replaces is parked in
   `../06 - Archive/process-pre-pinned-2026-08-19/`.
   ===================================================================== */
/* LIGHT GROUND since 2026-08-19 night (Fahad: "keep the backgrounds
   for the pages homogenous"): the board leaves the navy — the one
   off-white block in the home flow — and joins the white ground the
   2026-08-17 ruling set. The ruled lines go ink-on-paper (the drawing
   grid's own alphas), the edge fade goes white, the type goes ink, and
   the cards trade the dark elevation ramp for the light one (see the
   DEPTH block). The navy skin is one commit back if ever wanted. */
.process {
  /* The shorthand here would wipe the background-image the banded rule sets
     above, which is the trap this component has had since the ruled ground:
     `background:` is a shorthand and resets every longhand it does not name. */
  background-color: var(--color-surface-band); color: var(--color-text);
  border-top: 1.5px solid var(--color-border);
  padding: var(--space-9) var(--space-4);
  position: relative; overflow: hidden;
}
/* THE RULED GROUND IS GONE (Fahad, 2026-08-20: "remove the paper lines
   behind process, the black horizontal lines"). `.process::before` drew a
   32px rule every line in --zef-ink-a05 and `::after` faded them toward
   both edges; both are removed. The board keeps its pinned cards, its
   rotations and the dash-crawl path between them — the ruled paper was the
   ground under all of that, not part of it.
   The `background` SHORTHAND warning that used to live here still matters
   if anyone re-adds a background-image to `.process`: the shorthand on the
   rule below cancels it, which is why the lines were pseudo layers. */
.prohead, .proboard { position: relative; z-index: 1; }
.prohead { width: 100%; max-width: var(--page-max); margin: 0 auto var(--space-8); }
.prohead h2 {
  font-size: clamp(32px, 5.6vw, 66px); font-weight: var(--weight-display);
  letter-spacing: var(--track-display); line-height: var(--lh-tight);
  color: var(--color-text);
}

.proboard { width: 100%; max-width: 1000px; margin: 0 auto; }
.propath { display: none; }
.prolist {
  list-style: none;
  display: flex; flex-direction: column; align-items: center; gap: var(--space-6);
}
.procard { width: min(340px, 100%); }
/* A whisper of the scatter survives on mobile; the full board needs width. */
.procard:nth-child(odd)  { --rot:  2deg; }
.procard:nth-child(even) { --rot: -2deg; }

.pcard {
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border-soft);
  border-radius: var(--radius-xs);
  padding: var(--space-2);
  transform: rotate(var(--rot, 0deg));
}
.ppin { display: block; width: 30px; height: 30px; margin: var(--space-2) auto var(--space-4); color: var(--color-primary); }
/* CONCRETE INSIDE THE POLAROID TOO (Fahad, 2026-08-23: "make the background
   behind the picture also the concrete ... the grey box where the text is
   written as well"). It now carries the same ground as `section.process`
   behind it, so the white card reads as a mount around a piece of concrete
   rather than around a flat grey fill.

   LONGHANDS, NEVER THE `background:` SHORTHAND. This rule used to be
   `background: var(--color-surface-sunken)`, and writing the texture as a
   shorthand here would reset `background-image` on the next edit and silently
   drop the tile -- the same trap `.process`, `.faqsec` and `section.band` each
   carry a warning about.

   THE TILE IS OFFSET PER CARD so four polaroids do not show the identical
   1254px tile corner. The cards are rotated, so a shared crop would repeat the
   same speckle in the same place four times at four angles, which reads as a
   texture bug rather than as four pieces of concrete. */
.ppanel {
  background-color: var(--color-surface-band);
  background-image: var(--band-texture);
  border: 1px solid var(--zef-ink-a05);
  border-radius: var(--radius-xs);
  padding: var(--space-4);
}
.procard:nth-child(2) .ppanel { background-position: -180px -120px; }
.procard:nth-child(3) .ppanel { background-position: -430px -300px; }
.procard:nth-child(4) .ppanel { background-position: -650px -90px; }
.procard:nth-child(5) .ppanel { background-position: -290px -520px; }
/* ONE uniform box for all four photographs (Fahad's ruling), contained
   never cropped (his standing rule): portraits letterbox on the panel. */
.pphoto { height: 168px; display: flex; align-items: center; justify-content: center; margin-bottom: var(--space-4); }
/* A GATED STAGE PHOTOGRAPH TAKES THE SAME BOX AS A REAL ONE (2026-08-24).
   The site-wide gate is `min-height: 220px` with generous padding, which is
   right in a full-width section and wrong in a 168px photo slot: it grew the
   card, and on this board the cards are absolutely positioned, so one tall
   card breaks the rhythm the connector is drawn through. */
.pphoto .gate { min-height: 0; height: 100%; width: 100%; padding: var(--space-4) var(--space-3); }
.pphoto .gate p { line-height: 1.6; }
.pphoto picture { display: contents; }
.pphoto img { width: auto; height: auto; max-width: 100%; max-height: 100%; object-fit: contain; }
.pscript {
  display: block; font-family: var(--font-script); font-weight: 600;
  font-size: 38px; line-height: 1; color: var(--color-primary);
  margin-bottom: var(--space-3);
}
.ppanel h3 {
  font-size: var(--text-2xl); font-weight: var(--weight-semi);
  letter-spacing: var(--track-snug); line-height: var(--lh-snug);
  color: var(--color-text); margin-bottom: var(--space-2);
}
.ppanel p { font-size: var(--text-md); color: var(--color-text-muted); line-height: 1.55; }

/* The scatter, from 901px: the list becomes a fixed-height board, the
   cards pin absolutely left/right at the reference's +-8deg, and the
   dashed connector runs beneath them (cards z 1, path z 0). Restored
   with the scroll-experience revert to the 6.11 two-column geometry
   Fahad approved. */
@media (min-width: 901px) {
  .process { padding: var(--space-9) var(--space-5); }
  /* 1370 -> 1730: the fifth card (Fahad, 2026-08-24) sits 360px below the
     fourth, the same gap the second-to-third pair uses. The propath viewBox
     height in generate.py must match this number or the connector stretches. */
  .proboard { position: relative; height: 1730px; }
  .propath {
    display: block; position: absolute; inset: 0;
    width: 100%; height: 100%; pointer-events: none;
    color: var(--zef-steel);  /* steel-light was for the navy ground */
  }
  .propath path { stroke-dasharray: 8 6; }
  .prolist { display: block; position: static; }
  .procard { position: absolute; width: 300px; z-index: 1; }
  .procard:nth-child(1) { top: 0;     left: 10%;  --rot:  8deg; }
  .procard:nth-child(2) { top: 250px; right: 10%; --rot: -8deg; }
  .procard:nth-child(3) { top: 610px; left: 10%;  --rot:  8deg; }
  .procard:nth-child(4) { top: 860px; right: 10%; --rot: -8deg; }
  .procard:nth-child(5) { top: 1220px; left: 10%;  --rot:  8deg; }
}
/* The crawl is decor: it exists only where motion is welcome. */
@media (prefers-reduced-motion: no-preference) {
  .propath path { animation: prodash 3s linear infinite; }
}
/* -140 is a multiple of the 14px dash period (8+6): the loop is seamless. */
@keyframes prodash { to { stroke-dashoffset: -140; } }

/* (The generic .reveal stagger left the cards 2026-08-19 night: the
   process stepper in main.js owns their entrance now.) */


/* =====================================================================
   TESTIMONIALS (2026-08-17, replaces the Where-we-work band)
   Fahad is sending a prompt for the treatment, and no real client quote has
   been cleared for publication yet, so this ships as a gate. Nothing is
   written into it before both land.
   ===================================================================== */
/* PRE-GLASS TREATMENT RESTORED (Fahad, 2026-08-19: "revert to the old look
   before I mentioned liquid glass"). The scrolling strip, pause mechanics
   and pop-up records all stay — those were his separate rulings and they
   are behaviour, not skin. What went: the navy ground, the blurred steel
   blooms, the translucent material, the specular sheen and the 3D tilt.
   Cards are plain white on the site's quiet sunken ground, carried by the
   DEPTH & MOTION SYSTEM like every other raised surface. The glass build
   is parked in the Build 6 archive copy of this file if it is ever wanted
   back. */
.tstm {
  position: relative; overflow: hidden;
  /* Was --color-surface-sunken, then the flat ground, which is what made
     Fahad call it "too white" on 2026-08-20. It carries the paper band now
     and reads as a band again. */
  /* background-COLOR, not the shorthand: the banded rule further down sets
     background-image (the ground tile) and `background:` would reset it. */
  background-color: var(--color-surface-band);
  border-top: 1.5px solid var(--color-border);
  padding-bottom: var(--space-9);   /* white space UNDER the reviews (Fahad) */
}
/* The heading block no longer carries a sub-line or a control row, so its
   bottom padding was pure dead space above the cards (Fahad: "remove the
   white space"). The gap is the strip's own margin now, and nothing else. */
.tstm .inner { padding-bottom: 0; }
.tstmgate { margin-top: var(--space-5); min-height: 0; text-align: left; place-items: start; }
.tstmgate p { max-width: 78ch; }

/* The pause control, kept but taken out of the layout. Fahad asked for the
   Pause button and the status line to go; WCAG 2.2.2 still requires a way to
   stop content that scrolls by itself, and hover serves neither keyboard nor
   touch. So it sits visually hidden and springs into view on keyboard focus,
   exactly like a skip link, and occupies no space the rest of the time. */
.reccontrols { position: relative; }
.recpause {
  position: absolute; width: 1px; height: 1px; overflow: hidden;
  clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap;
  font-family: var(--font-mono); font-size: var(--text-md); cursor: pointer;
  background: var(--color-surface-raised); color: var(--color-text);
  border: 1.5px solid var(--color-border); border-radius: var(--radius-xs);
  text-transform: uppercase; letter-spacing: var(--track-caps);
}
/* :focus, NOT :focus-visible. focus-visible is a heuristic and can decline to
   match, and a control that stays invisible when focused is worse than no
   control at all. Same reason skip links have always used :focus. */
.recpause:focus {
  position: static; width: auto; height: auto; overflow: visible;
  clip: auto; clip-path: none;
  display: inline-block; margin-top: var(--space-4);
  padding: var(--space-3) var(--space-5); min-height: 44px;
  outline: 3px solid var(--color-primary); outline-offset: 2px;
}

/* The columns (TestimonialsColumn vertical marquee port, 2026-08-19;
   replaces the horizontal strip). DEFAULT is a plain wrapped row of all
   six records, nothing moving, nothing masked: that is what a JS-less
   visitor gets. Only a container the script has marked data-armed gets
   the columns, the clip and the loop, so no card's visibility ever
   depends on JS (rule 31).
   The reference's top/bottom fade mask is kept as its signature, with
   two escape hatches for the contrast floor: focus-within lifts it (a
   focused card must never sit dimmed under the fade), and
   prefers-contrast: more removes it outright. */
.reccols { margin-top: var(--space-6); }
.reccols:focus-visible { outline: 3px solid var(--color-primary); outline-offset: -3px; }
.rectrack {
  list-style: none; display: flex; flex-wrap: wrap;
  gap: var(--space-4); justify-content: center;
}
.rec { flex: 0 0 auto; width: min(348px, 86vw); }

html.js .reccols[data-armed] {
  display: flex; justify-content: center; gap: var(--space-5);
  max-height: 740px; overflow: hidden;
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 25%, #000 75%, transparent);
          mask-image: linear-gradient(to bottom, transparent, #000 25%, #000 75%, transparent);
}
html.js .reccols[data-armed]:focus-within {
  -webkit-mask-image: none;
          mask-image: none;
}
html.js .reccols[data-armed] .reccol { width: min(348px, 86vw); }
html.js .reccols[data-armed] .rectrack {
  flex-direction: column; flex-wrap: nowrap;
  /* The bottom padding EQUALS the gap. The two copies plus that padding
     make the set's stride exactly half the track, so the -50% keyframe
     lands seamlessly on the first card again. */
  padding-bottom: var(--space-4);
  will-change: transform;
  animation: reccolscroll var(--col-dur, 16s) linear infinite;
}
html.js .reccols[data-armed] .rec { width: 100%; }

@keyframes reccolscroll {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(0, -50%, 0); }
}
/* Every pause path lands on the same declaration. Hover and focus-within are
   the conveniences; the button and the open dialog are the guarantees.
   Each selector carries .reccols[data-armed] NOT for scoping but for
   SPECIFICITY: the armed rule above sets the `animation` shorthand at
   (0,4,1), the shorthand resets animation-play-state to running, and a
   lower-specificity pause path silently loses — the same out-specify trap
   as the entrance-rule gotcha. These sit at (0,4,1)+ and later in source. */
/* HOVER PAUSES ONLY FROM A CARD (Fahad, 2026-08-19: "it should only stop
   when the cursor is moved onto a review not on the white spaces to the
   left or right"). `.reccols` is a full-width centred flex row while the
   columns are only min(348px, 86vw) each, so a bare `:hover` on it was
   armed across the whole band, including the empty ground either side of
   the columns. Resting the cursor anywhere near the section froze the
   strip. `:has(.rec:hover)` moves the trigger onto the cards themselves;
   the gutters and the column gaps are now dead to it. Keyboard is
   untouched: `:focus-within` still pauses, and the Pause button remains
   the WCAG 2.2.2 mechanism. */
html.js .reccols[data-armed]:focus-within .rectrack,
html.recpaused .reccols[data-armed] .rectrack,
html.sheet-open .reccols[data-armed] .rectrack { animation-play-state: paused; }

/* The hover path sits in its OWN rule, deliberately. A selector list is
   all-or-nothing: one unsupported selector and the browser drops the
   ENTIRE rule. Had `:has()` been joined to the three above, a browser
   without it would lose the Pause button and the open-dialog freeze as
   well, which is the WCAG 2.2.2 mechanism. Alone, the worst case is that
   hover-to-pause degrades to nothing, which is the convenience, not the
   guarantee. */
html.js .reccols[data-armed]:has(.rec:hover) .rectrack { animation-play-state: paused; }
@media (prefers-contrast: more) {
  html.js .reccols[data-armed] { -webkit-mask-image: none; mask-image: none; }
}

/* The review card, plain. Anatomy is still the reference Fahad sent:
   avatar, name, city, star row, quote. White surface, hairline border,
   the depth system's resting shadow, and the standard small lift on
   hover — the same physics as every other card on the site. */
.reccard {
  border-radius: 8px;  /* the Google-card corner (2026-08-20) */
  position: relative; width: 100%; height: 100%;
  margin: 0; padding: var(--space-6) var(--space-5);
  display: flex; flex-direction: column; gap: var(--space-3);
  border-radius: var(--radius-xs);
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border-soft);
  box-shadow: var(--el-rest), var(--edge-light);
  color: var(--color-text);
  transition: transform var(--dur-lift) var(--ease-out-strong),
              box-shadow var(--dur-lift) var(--ease-out-strong);
}
@media (hover: hover) and (pointer: fine) {
  .reccard:hover { transform: translate3d(0, -4px, 0); box-shadow: var(--el-raised), var(--edge-light); }
}
.rectrack { will-change: transform; }

.rechead { display: flex; align-items: center; gap: var(--space-3); }
/* The Google-review skin (Fahad, 2026-08-20): the G sits where Google
   puts it, top-right of the head row; the meta line under the name is
   "city · time ago"; the card corners round to Google's 8px — a scoped
   deviation from the sharp-corner personality, HIS call for this
   component. The claim this skin makes is governed by F16 (see
   PLACEHOLDER_REVIEWS in generate.py). */
.rechead .gmark { width: 20px; height: 20px; margin-left: auto; flex: none; align-self: flex-start; }
.recface {
  width: 48px; height: 48px; flex: none; border-radius: 50%;
  display: grid; place-items: center;
  background: var(--color-surface-sunken);
  border: 1px solid var(--color-border-soft);
  color: var(--color-primary);
  font-size: var(--text-lg); font-weight: var(--weight-semi);
  letter-spacing: var(--track-wide);
}
.recwho { display: grid; gap: 1px; min-width: 0; }
.recwho b {
  font-size: var(--text-2xl); font-weight: var(--weight-semi);
  letter-spacing: var(--track-snug); color: var(--color-text);
}
.recwho i {
  font-style: normal; font-size: var(--text-base);
  color: var(--color-text-muted);
}
.stars { display: inline-flex; gap: 3px; }
.star { width: 17px; height: 17px; fill: none; stroke: var(--zef-mist); stroke-width: 1.5; }
.star.on { fill: #FABB05; stroke: #FABB05; }  /* Google's review gold (the card skin, 2026-08-20) */
/* THE QUOTE IS CUT AT THREE LINES AND HANDED TO "... More" (Fahad,
   2026-08-20: "instead of clicking read review, there should be ... MORE just
   like google"). The outlined READ THE REVIEW button under the card is gone
   with it: on a Google card the affordance is the truncation itself, and a
   44px bordered button under six words of quote was the heavier object on a
   card whose whole job is to look like a review.

   The cap is stated in LINES, not pixels, so the line-height and the clamp
   cannot drift apart. main.js measures where the text actually runs out and
   trims to a word; a character budget would be a different number of lines in
   the fallback font, and wrong again the first time a real review lands. */
.recq {
  --recq-lh: 1.62; --recq-lines: 3;
  font-size: var(--text-lg); line-height: var(--recq-lh); margin-top: var(--space-1);
  color: var(--color-text-muted);
}
html.js .recq { max-height: calc(var(--recq-lh) * 1em * var(--recq-lines)); overflow: hidden; }
/* Hidden until main.js has PROVEN the review runs long: a "More" on a review
   that already shows whole is a lie, and Google does not print one either.
   That also takes it off the no-JS page, where the dialog it opens cannot
   exist and the card shows the full quote instead (rule 31). */
.recmore { display: none; }
html.js .recq[data-clipped] .recmore { display: inline; }
/* An inline link in a sentence, not a control: it inherits the quote's face
   and size so the last line reads as one run of text, and takes the primary
   at 600 to separate from the muted quote around it. Inline targets inside a
   block of text are exempt from the 44px minimum (WCAG 2.5.8), which is why
   this one is allowed to be the size of the word it sits beside. */
.recmore {
  font: inherit; font-weight: var(--weight-semi);
  background: none; border: 0; padding: 0; margin: 0;
  color: var(--color-primary); cursor: pointer;
  text-decoration: none; text-align: left;
}
.recmore:hover { text-decoration: underline; text-underline-offset: 2px; }
@media (prefers-contrast: more) {
  .reccard { box-shadow: none; border: 1.5px solid var(--color-border); }
}

/* The small window. A MODIFIER on .sheet, never a second base class, or every
   line of the existing dialog wiring in main.js is lost. Pre-glass: the
   panel is the standard light sheet surface, centered, with full corners. */
.sheet.mid { align-items: center; }
/* `.recwrap` exists only to anchor the close button. The panel cannot do it:
   it scrolls, and a scroller clips anything positioned past its own edge,
   which is precisely where the button now lives. The wrapper carries the
   card's width so the button can align to the card's right edge, and its
   height stays the card's, so a click either side of it still lands on the
   backdrop and closes the dialog. */
.sheet.mid .recwrap { position: relative; width: min(520px, 100%); }
.sheet.mid .panel {
  width: 100%;
  /* 60px shorter than the sheet standard: the strip above the card is not
     spare space any more, it is where the close button stands. Without this
     a tall review on a short screen would push the button off the top. */
  max-height: calc(86vh - 60px);
  border-radius: var(--radius-sheet);
  box-shadow: var(--el-floating), var(--edge-light);
}
/* THE CLOSE, ON THIS DIALOG ONLY (Fahad, 2026-08-20): white X, no background,
   sitting outside the review. The quote and booking sheets keep the filled
   icon button; there the X is one control among many and needs a surface to
   read as clickable. Here it is the only control, it sits on a dimmed page
   with nothing to compete with, and a filled chip inside the card was reading
   as part of the review. Overrides the icon skin from the glow ladder below
   at .sheet.mid specificity, so nothing there needs weakening. */
.sheet.mid .close {
  position: absolute; float: none; top: -52px; right: 0;
  background: none; background-image: none; border: 0; box-shadow: none;
  color: var(--zef-white); opacity: .85;
  transition: opacity var(--dur-hover) var(--ease-out-strong),
              transform var(--dur-press) var(--ease-out-strong);
}
.sheet.mid .close svg {
  width: 26px; height: 26px; stroke-width: 2.2;
  /* The scrim is 60% black, so over a pale section of the page behind it the
     X sits on mid grey, not on black. This is the icon's own shadow, not a
     background: it holds the 3:1 a graphical control needs without putting a
     chip back behind the glyph. */
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, .55));
}
@media (hover: hover) and (pointer: fine) {
  .sheet.mid .close:hover { opacity: 1; filter: none; box-shadow: none; transform: scale(1.08); }
}
.sheet.mid .close:active { opacity: 1; filter: none; box-shadow: none; transform: scale(.92); }
/* currentColor is the ring token, so the outline turns white with the glyph. */
.sheet.mid .close:focus-visible { opacity: 1; }
.sheet.mid .panel p { font-size: var(--text-lg); line-height: var(--lh-body); }
.sheet.mid .panel .note { margin-bottom: var(--space-5); }
.sheet.mid .panel p + p { margin-top: var(--space-4); }
.sheet.mid .panel .rechead { margin-bottom: var(--space-4); }
.sheet.mid .panel .stars { margin-bottom: var(--space-4); }
.recqfull { font-size: var(--text-2xl); line-height: 1.6; color: var(--color-text); }
.recmark {
  margin-top: var(--space-6); padding-top: var(--space-4);
  border-top: 1px solid var(--color-border-soft);
  font-size: var(--text-sm); line-height: 1.6; color: var(--color-text-muted);
}
@media (max-width: 620px) {
  .sheet.mid { align-items: flex-end; }
  .sheet.mid .panel { border-radius: var(--radius-sheet) var(--radius-sheet) 0 0; }
  /* Full-bleed sheet: the card's right edge is the screen's, so the button
     comes in off it rather than hanging in the corner. */
  .sheet.mid .close { right: var(--space-2); top: -50px; }
}

/* The global reduced-motion block already kills the animation. With the loop
   dead the armed columns would show only whatever fits 740px, so this opens
   the container to its full height, drops the fade, and hides the loop
   clones: a static, complete, honest list of the six records. */
@media (prefers-reduced-motion: reduce) {
  html.js .reccols[data-armed] {
    max-height: none;
    -webkit-mask-image: none; mask-image: none;
  }
  html.js .reccols[data-armed] .rec[aria-hidden="true"] { display: none; }
  html.js .reccols[data-armed] .rectrack { padding-bottom: 0; }
}

/* The home page's closing band, set larger (Fahad: "Start your project
   should have a larger tile"). */
.quoteband.big .inner { padding-top: var(--space-9); padding-bottom: var(--space-9); min-height: 58vh; display: flex; flex-direction: column; justify-content: center; }
.quoteband.big h2 { font-size: clamp(34px, 5.4vw, 68px); max-width: 14ch; }
.quoteband.big .lead { font-size: var(--text-2xl); max-width: 56ch; margin-top: var(--space-4); }
.quoteband.big .ctas { margin-top: var(--space-7); }


/* =====================================================================
   THE START-YOUR-PROJECT DASHBOARD (2026-08-17)
   Fahad: the contact page becomes "a complete page with an interactive
   dashboard which allows you to call, text, whatsapp or book a slot".
   The four cards are the lead dock's four channels at full size, with the
   detail the dock has no room for. Three are real links and work today;
   the fourth opens the booking sheet, which is still register item F11.
   ===================================================================== */
/* No ground of its own since 2026-08-20 night: `.band` carries the concrete.
   Both of this rule's old `background:` declarations were the SHORTHAND, which
   resets background-image, so leaving either in place would have quietly
   cancelled the tile. */
/* `.dash` has no border and no ground of its own: `.band` paints the concrete
   and `.ruled` draws the section rule inset to the content width. Its old
   full-bleed border ran beside that rule, two lines for one boundary. */
.dashhead { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
.dstatus {
  font-family: var(--font-mono); font-size: var(--text-md);
  display: inline-flex; align-items: center; gap: var(--space-2);
  text-transform: uppercase; letter-spacing: var(--track-caps); color: var(--color-text-muted);
}
.dstatus::before { content: ""; width: 9px; height: 9px; border-radius: 50%; background: var(--color-text-muted); }
.dstatus.isopen::before { background: #2E7D32; }

.dashgrid { margin-top: var(--space-6); display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--space-3); }
/* font-size and line-height are inherited explicitly: three of these cards
   are anchors and the fourth is a button, and a button inherits neither, so
   without this its text sets on a different line box than its neighbours. */
.dcard {
  appearance: none; text-align: left; cursor: pointer;
  font-family: inherit; font-size: inherit; line-height: inherit;
  background: var(--color-surface-raised); border: 1.5px solid var(--color-surface-inverse);
  color: var(--color-text); text-decoration: none; width: 100%;
  padding: var(--space-5); display: grid; gap: var(--space-2); align-content: start;
  transition: transform var(--dur-micro), border-color var(--dur-micro);
}
.dcard:hover { transform: translateY(-4px); border-color: var(--color-primary); }
.dicon { width: 42px; height: 42px; display: block; margin-bottom: var(--space-2); }
.dicon svg { width: 100%; height: 100%; display: block; }
.dtitle { font-size: var(--text-2xl); font-weight: var(--weight-semi); letter-spacing: var(--track-snug); }
.dbig { font-family: var(--font-mono); font-size: var(--text-lg); font-weight: var(--weight-bold); color: var(--color-primary); }
.dnote { font-size: var(--text-base); color: var(--color-text-muted); line-height: var(--lh-body); }
.daction {
  margin-top: var(--space-3); font-size: var(--text-sm); text-transform: uppercase;
  letter-spacing: var(--track-caps); color: var(--color-primary);
  display: flex; align-items: center; gap: var(--space-2);
}
.daction i { font-style: normal; display: inline-block; transition: transform var(--dur-micro); }
.dcard:hover .daction i { transform: translateX(4px); }
@media (max-width: 1000px) { .dashgrid { grid-template-columns: repeat(2, 1fr); } }
@media (max-width: 560px)  { .dashgrid { grid-template-columns: 1fr; } }


/* =====================================================================
   MOTION (2026-08-17, Fahad: "lack of animation")
   All of it decoration over content that is already visible, all of it
   inside html.js, and all of it dead under prefers-reduced-motion via the
   global kill switch above. Nothing here is load-bearing.
   ===================================================================== */
@keyframes zefRise { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: none; } }
@keyframes zefSettle { from { transform: scale(1.07); } to { transform: none; } }

/* The hero introduces itself: photograph settles from a slow push-in while
   the headline, the sentence and the buttons arrive in sequence. */
html.js .hero-photo img { animation: zefSettle 2s var(--ease-out) both; }
/* In the slideshow only the LIVE slide settles, and it settles again each
   time it comes round: the class toggle restarts the animation, which is the
   whole of the entrance, no JS animation code. A slide that is not on has no
   animation at all, so a slide fading out is never mid-scale. */
html.js .hero-photo .slide img { animation: none; }
html.js .hero-photo .slide.is-on img { animation: zefSettle 2.4s var(--ease-out) both; }
html.js .hero-copy > * { animation: zefRise .75s var(--ease-out) both; }
html.js .hero-copy > h1 { animation-delay: .05s; }
html.js .hero-copy > p { animation-delay: .19s; }
html.js .hero-copy > .ctas { animation-delay: .33s; }

/* The credential row deals itself out left to right. */
html.js .certs .badge {
  opacity: 0; transform: translateY(14px);
  transition: opacity .55s var(--ease-out), transform .55s var(--ease-out);
}
html.js .certs .inner.in .badge { opacity: 1; transform: none; }
html.js .certs .inner.in .badge:nth-child(2) { transition-delay: .08s; }
html.js .certs .inner.in .badge:nth-child(3) { transition-delay: .16s; }
html.js .certs .inner.in .badge:nth-child(4) { transition-delay: .24s; }
html.js .certs .inner.in .badge:nth-child(5) { transition-delay: .32s; }

/* The four process cards stagger through the generic .reveal entrance;
   their per-card --i delay sits with the process block (2026-08-19). */

/* Button depth and press response now live in the DEPTH & MOTION SYSTEM
   block at the end of this file. */




/* =====================================================================
   DEPTH & MOTION SYSTEM (2026-08-17)

   Fahad: "everything on the site looks too 2d ... I want it to be more
   animated and 3d where things pop like buttons ... this includes the get a
   quote button on the top right, the services tiles and everything."

   The trap in that brief: he has ALSO rejected this site twice as "boxy and
   cartoonish". Depth and tackiness live next door to each other, and the
   difference is entirely craft. So this system is built on four rules:

   1. ONE LIGHT SOURCE, from above. Every shadow falls downward and every
      raised surface gets a lit top edge. Shadows that disagree about where
      the light is are the single biggest tell of a template.
   2. SHADOWS COME IN PAIRS. A tight contact shadow for the ambient
      occlusion right under the element, plus a wide soft one for the cast.
      One big blurry shadow reads as a sticker; two read as an object.
   3. BIG THINGS MOVE LESS. A 348px tile lifting 6px reads as weight; a
      44px button lifting 6px reads as a balloon. Displacement is inversely
      scaled to surface area.
   4. PRESS IS THE POINT. Nothing makes an interface feel physical like
      responding on pointer-DOWN. Every pressable surface compresses on
      :active in 110ms and its shadow collapses with it, because a thing
      being pushed toward the page gets closer to its own shadow.

   Sources: the apple-design skill (materials, response, hierarchy) and the
   emil-design-eng skill (exact curves, press scale, sub-300ms rule, never
   `transition: all`, hover gated to fine pointers).
   ===================================================================== */
:root {
  /* Stronger than the built-in keywords, which are too weak to read as
     intentional. Never ease-in on UI: it delays the first frame, which is
     exactly the frame the user is watching. */
  --ease-out-strong: cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out-strong: cubic-bezier(0.77, 0, 0.175, 1);

  --dur-press: 110ms;
  --dur-hover: 200ms;
  --dur-lift:  260ms;

  /* The elevation ramp. Paired shadows, one light source, from above. */
  /* Strengthened 2026-08-17: Fahad's verdict on the first values was "I
     can't even tell the difference", and he was right. They were tuned
     against a grey ground and against a fear of looking cheap. Roughly
     doubled, still paired, still one light source. */
  --el-rest:     0 1px 2px rgba(8,12,20,.09),  0 4px 12px rgba(8,12,20,.09);
  --el-raised:   0 2px 6px rgba(8,12,20,.11),  0 14px 30px rgba(8,12,20,.16);
  --el-floating: 0 6px 14px rgba(8,12,20,.14), 0 30px 64px rgba(8,12,20,.24);
  --el-pressed:  0 1px 2px rgba(8,12,20,.16);

  /* On dark surfaces the same ramp needs more contrast to be seen at all. */
  --el-rest-dark:     0 2px 6px rgba(0,0,0,.28),  0 6px 16px rgba(0,0,0,.22);
  --el-raised-dark:   0 4px 10px rgba(0,0,0,.34), 0 16px 34px rgba(0,0,0,.30);
  --el-floating-dark: 0 8px 18px rgba(0,0,0,.40), 0 30px 64px rgba(0,0,0,.40);

  /* The lit top edge: light from above catching the upper lip of a surface. */
  --edge-light: inset 0 1px 0 rgba(255,255,255,.72);
  --edge-dark:  inset 0 1px 0 rgba(255,255,255,.34);
}

/* --- Buttons. The "Get a quote" button is .btn1 and it is the proof of the
   whole system, so it gets the full treatment: resting elevation, a lit top
   edge, a lift on hover, and a real compression on press. -------------- */
.btn1, .btn2 {
  transition: background var(--dur-hover) var(--ease-out-strong),
              border-color var(--dur-hover) var(--ease-out-strong),
              color var(--dur-hover) var(--ease-out-strong),
              box-shadow var(--dur-hover) var(--ease-out-strong),
              transform var(--dur-press) var(--ease-out-strong);
  will-change: transform;
}
.btn1 { box-shadow: var(--el-raised), var(--edge-dark); }
.btn2 { box-shadow: var(--el-rest); }

@media (hover: hover) and (pointer: fine) {
  .btn1:hover { transform: translateY(-2px); box-shadow: var(--el-floating), var(--edge-dark); }
  .btn2:hover { transform: translateY(-2px); box-shadow: var(--el-raised); }
}
/* Press: compress toward the page and let the shadow collapse with it. */
.btn1:active, .btn2:active {
  transform: translateY(0) scale(.97);
  box-shadow: var(--el-pressed);
  transition-duration: var(--dur-press);
}

/* --- Service tiles. He named these. Big surfaces: small displacement, and
   a real resting elevation so they sit ON the page rather than in it. --- */
.tiles { perspective: 1600px; }
.tile { box-shadow: var(--el-rest); will-change: transform; }
/* Specificity note: `html.js .reveal` (0,2,1) sets a 0.6s opacity+transform
   transition for the entrance, and it out-specifies a bare `.tile`. Left
   alone, tile hovers inherited 600ms (over the 300ms UI ceiling) and the
   shadow never transitioned at all. `html.js .tile.reveal` is 0,3,1, so it
   wins, and it carries all three properties. The entrance rides the same
   260ms, which is snappier and still staggered by --i. */
html.js .tile.reveal,
.tile {
  transition: opacity var(--dur-lift) var(--ease-out-strong),
              transform var(--dur-lift) var(--ease-out-strong),
              box-shadow var(--dur-lift) var(--ease-out-strong);
}
@media (hover: hover) and (pointer: fine) {
  .tile:hover { transform: translate3d(0,-6px,0) scale(1.008); box-shadow: var(--el-floating); }
}
.tile:active { transform: translate3d(0,-1px,0) scale(.997); box-shadow: var(--el-raised); transition-duration: var(--dur-press); }

/* The flagship panel is the heaviest object on the page, so it moves least. */
.tile.xl { box-shadow: var(--el-floating-dark); }
@media (hover: hover) and (pointer: fine) {
  .tile.xl:hover { transform: translate3d(0,-3px,0); box-shadow: var(--el-floating-dark); }
}

/* --- The two flagship doors: inset controls on a dark panel. ----------- */
.door2 { box-shadow: var(--edge-dark); transition: background var(--dur-hover) var(--ease-out-strong), border-color var(--dur-hover) var(--ease-out-strong), transform var(--dur-press) var(--ease-out-strong), box-shadow var(--dur-hover) var(--ease-out-strong); }
@media (hover: hover) and (pointer: fine) {
  .door2:hover { transform: translateY(-2px); box-shadow: var(--el-raised-dark), var(--edge-dark); }
}
.door2:active { transform: scale(.99); box-shadow: none; transition-duration: var(--dur-press); }

/* --- Contact dashboard cards. -------------------------------------- */
.dcard {
  box-shadow: var(--el-rest), var(--edge-light);
  transition: transform var(--dur-lift) var(--ease-out-strong),
              box-shadow var(--dur-lift) var(--ease-out-strong),
              border-color var(--dur-hover) var(--ease-out-strong);
}
@media (hover: hover) and (pointer: fine) {
  .dcard:hover { transform: translate3d(0,-5px,0); box-shadow: var(--el-floating), var(--edge-light); }
}
.dcard:active { transform: translate3d(0,-1px,0) scale(.994); box-shadow: var(--el-raised), var(--edge-light); transition-duration: var(--dur-press); }

/* --- Question cards. THEY POP ON HOVER (Fahad, 2026-08-20: "the boxes
   should animate so when you move your cursor over it it pops out"). Built
   in the first round with no hover at all, on the reading that a card with
   nothing to press should not pretend to respond; overruled, and the ruling
   stands. So they take the loudest lift on the site: `.tile`'s, 6px and a
   whisper of scale up to `--el-floating`, which is the treatment the service
   tiles wear and the one Fahad has already approved as "pops".

   TWO SPECIFICITY TRAPS, BOTH LIVE, BOTH ALREADY SPRUNG ONCE HERE:

   1. THE HOVER RULE CARRIES `html.js .faq .qa.reveal:hover` (0,5,1) as well
      as the plain `.faq .qa:hover` (0,3,0). The entrance below hands the
      card `transform: none` at (0,4,1) to cancel the site-wide reveal lift,
      and a bare `.faq .qa:hover` loses to it: the shadow changed, the card
      never moved, and it read as a broken hover. This is the same trap the
      tiles, badges and process steps each hit, in its transform form.
   2. ONE `transition` DECLARATION SERVES EVERY STATE, and it has to. The
      entrance is triggered by adding `.in`; if `.in` also changed which
      properties were transitionable, the browser would read the AFTER-change
      transition-property, find no clip-path in it, and snap the card into
      place with no wipe at all. So clip-path, transform, box-shadow and
      border-color are declared together, once, on a selector that matches
      before and after `.in` alike.
   -------------------------------------------------------------------- */
.faq .qa { box-shadow: var(--el-rest), var(--edge-light); }
html.js .faq .qa.reveal,
.faq .qa {
  transition: clip-path 620ms var(--ease-out-strong)
                        calc(min(var(--i, 0), 4) * 150ms),
              transform var(--dur-lift) var(--ease-out-strong),
              box-shadow var(--dur-lift) var(--ease-out-strong),
              border-color var(--dur-hover) var(--ease-out-strong);
  will-change: transform;
}
@media (hover: hover) and (pointer: fine) {
  html.js .faq .qa.reveal:hover,
  .faq .qa:hover {
    transform: translate3d(0, -6px, 0) scale(1.008);
    box-shadow: var(--el-floating), var(--edge-light);
    border-color: var(--color-border-soft);
    z-index: 1;   /* the lifted card's shadow falls over its neighbour, not under */
  }
}
/* No :active state, and that is deliberate: press is the response of a thing
   being pushed toward the page, and nothing here answers a click. The lift
   is the whole interaction. */
@media (prefers-contrast: more) {
  .faq .qa { box-shadow: none; border: 1.5px solid var(--color-border); }
}

/* --- THE WRITE-ON ENTRANCE (Fahad, 2026-08-20: "add an animation so that
   the cards start writing as you come onto the page 2 at a time").

   The card is INKED IN from the left rather than faded up, which is the
   drawing-set direction taken literally: the box is ruled onto the page and
   the words appear under the nib as it travels. `clip-path` does the whole
   job, so the text inside rides the same wipe and nothing needs its own
   timing. The card never fades, so it is never a translucent object sitting
   over the ground.

   FOUR THINGS ARE LOAD-BEARING:

   1. THE INSETS ARE NEGATIVE ON THREE SIDES, AND -100px IS MEASURED, NOT
      ROUND. `inset(0 100% 0 0)` would clip the resting shadow flat against
      the card's own edges and the depth would vanish for the entrance and
      never come back, because there is no way to transition TO
      `clip-path: none` and the settled value therefore keeps clipping
      forever. The number has to clear the LARGEST shadow the card can wear,
      which is the hover's `--el-floating`: its cast is offset 30px and
      blurred 64px, so it reaches 94px below the box. A first pass at -40px
      looked right at rest and sheared the bottom off every hover.
   2. THE RESTING STATE'S LEFT INSET IS 0, NOT -100px, and that is the
      difference between an invisible card and a ghost. With -100px on the
      left the clip region still spans a 100px strip to the LEFT of the card
      while the right inset holds at 100%, and the resting shadow spills
      into it: every row that had not started yet printed a faint vertical
      line exactly where its cards were going to appear. At 0 the region has
      zero width and renders nothing. The left inset then animates out to
      -100px alongside the right one, so the shadow's room opens as the card
      inks rather than before it.
   3. --i IS THE ROW INDEX, written by faq_grid(). Both cards of a row share
      it, so a row inks as a pair, which is what "2 at a time" means.
   4. THE DELAY IS CAPPED AT FOUR ROWS. Each card is observed individually,
      so a row that scrolls in on its own still pays its own delay; without
      the cap the last row of a long list would sit blank for most of a
      second after it is already on screen.
   -------------------------------------------------------------------- */
html.js .faq .qa.reveal {
  opacity: 1;
  transform: none;
  clip-path: inset(-100px 100% -100px 0);
}
html.js .faq .qa.reveal.in { clip-path: inset(-100px); }

/* Reduced motion. The wipe goes (main.js releases every reveal at once in
   this mode, so nothing's visibility is at stake) and the hover TRANSFORM
   goes, which is the same line the site already draws for every other
   hover: travel is removed, the shadow change stays as the feedback. */
@media (prefers-reduced-motion: reduce) {
  html.js .faq .qa.reveal,
  html.js .faq .qa.reveal.in { clip-path: none; }
  html.js .faq .qa.reveal:hover,
  .faq .qa:hover { transform: none; }
}

/* --- Credential badges: small marks, so the smallest displacement. ---- */
/* Same specificity trap as the tiles: the credential row's entrance rule
   `html.js .certs .badge` (0,3,1) sets a 550ms transform transition, which
   is right for the deal-out but far too slow for a hover. Match its
   specificity so the interaction runs at 200ms while the entrance keeps its
   own longer curve via .certs .inner:not(.in). */
html.js .certs .inner.in .badge,
.badge { transition: opacity var(--dur-hover) var(--ease-out-strong), transform var(--dur-hover) var(--ease-out-strong); }
@media (hover: hover) and (pointer: fine) { .badge:hover { transform: translateY(-3px) scale(1.02); } }

/* --- Pinned process cards (2026-08-19). Mid-size surfaces on the navy
   board, so the dark elevation ramp. The hover is the reference's scale
   ON TOP of the card's resting rotation: transform is composed, never
   replaced, or the card would snap upright. No entrance-specificity trap
   here: .reveal rides the LI wrapper, the hover rides .pcard inside it,
   so the two transitions never meet on one element. --------------------- */
.pcard {
  box-shadow: var(--el-raised), var(--edge-light);  /* light ramp: white ground (2026-08-19) */
  will-change: transform;
  transition: transform var(--dur-lift) var(--ease-out-strong),
              box-shadow var(--dur-lift) var(--ease-out-strong);
}
@media (hover: hover) and (pointer: fine) {
  .procard:hover { z-index: 3; }
  .procard:hover .pcard {
    transform: rotate(var(--rot, 0deg)) scale(1.04);
    box-shadow: var(--el-floating), var(--edge-light);
  }
}
.steptab { transition: background var(--dur-hover) var(--ease-out-strong), border-color var(--dur-hover) var(--ease-out-strong), transform var(--dur-press) var(--ease-out-strong); }
.steptab:active { transform: scale(.985); }

/* --- The pause control already reads as glass; give it the press. -----
   `.recmore` came off this line when it stopped being a button: `transform`
   does not apply to a non-replaced inline element, so the press would have
   been a rule that never fired. Its feedback is the underline instead. */
.recpause:active { transform: scale(.96); transition-duration: var(--dur-press); }

/* --- The doors on the walkout package, and the sheetnav chips. -------- */
.door { transition: background var(--dur-hover) var(--ease-out-strong), transform var(--dur-press) var(--ease-out-strong), box-shadow var(--dur-hover) var(--ease-out-strong); }
@media (hover: hover) and (pointer: fine) { .door:hover { transform: translateY(-2px); box-shadow: var(--el-raised); } }
.door:active { transform: scale(.99); box-shadow: none; }

/* --- Form fields: sink into the page, then lift to meet focus. -------- */
.field input, .field textarea, .field select {
  box-shadow: inset 0 1px 2px rgba(8,12,20,.07);
  transition: border-color var(--dur-hover) var(--ease-out-strong),
              box-shadow var(--dur-hover) var(--ease-out-strong);
}
.field input:focus-visible, .field textarea:focus-visible, .field select:focus-visible {
  box-shadow: inset 0 1px 2px rgba(8,12,20,.04), 0 0 0 3px color-mix(in srgb, var(--color-primary) 26%, transparent);
}

/* --- The lead dock is the one floating chrome element on every page. -- */
.dock a { transition: background var(--dur-hover) var(--ease-out-strong), transform var(--dur-press) var(--ease-out-strong); }
@media (hover: hover) and (pointer: fine) { .dock a:hover { transform: translateY(-2px); } }
.dock a:active { transform: scale(.94); transition-duration: var(--dur-press); }

/* --- The header lifts off the page once you scroll past the hero. ----- */
header.site { transition: background var(--dur-lift) var(--ease-out-strong), box-shadow var(--dur-lift) var(--ease-out-strong); }
header.site.on-paper { box-shadow: var(--el-raised); }


/* =====================================================================
   SOFT GLOW BUTTONS (Fahad, 2026-08-20, ported from a 21st.dev component)

   The reference is a Tailwind/shadcn button: a top-lit gradient in the
   primary colour, a hairline foreground border, a wide shadow TINTED in
   the primary colour, a 4px ring of the page ground holding that glow off
   the edge, and a brightness lift on hover that drops back to 100% on
   press.

   It is ported as a THREE-VARIANT recipe rather than one class, because
   this site runs a primary and a secondary button side by side in every
   `.ctas` row, and two identical filled glows would flatten that pair
   into one loud noise. Same glow, same physics, different weight:

     .sglow        SOLID  — filled gradient.          Primary CTAs.
     .sglow-quiet  QUIET  — the same glow, no fill.   Secondary CTAs.
     .sglow-icon   ICON   — the solid variant, square. Icon buttons.

   New markup should use those class names. The site's existing button
   classes are mapped onto them below, so no page had to be re-authored
   and generate.py did not have to change.

   THE SUBTLE PASS (Fahad, 2026-08-20: "far too shaded, make it more
   subtle"). Round 1 shipped the reference's own numbers and they were too
   heavy for a drawing-set site. Everything came down together, because
   dropping the shadow alone would have left the gradient reading as the
   loud part:

     gradient top    88% -> 92% fill      (a shallower ramp, less shading)
     glow strength   70% -> 30% solid, 32% -> 14% quiet
     cast geometry   20/25px -> 14/22px, and pulled in with a bigger spread
     contact shadow  .20 -> .13 light, .45 -> .30 dark
     ring            4px -> 3px
     hover lift      1.12 -> 1.10

   One knob now drives the whole family: --glow-strength. Raise it there to
   take the glow back up, per variant or per context, without touching the
   ladder. Contrast IMPROVED with the change (a shallower ramp keeps the top
   darker), so the AA figures below are the floor, not the ceiling.

   FIVE DEVIATIONS FROM THE REFERENCE, all deliberate:

   1. THE TOP OF THE GRADIENT MIXES TOWARD WHITE, NOT TOWARD TRANSPARENT.
      The original is `from-primary to-primary/85`, which lets the ground
      through at the top. On a light page that reads as a lit top edge; on
      the navy grounds this site actually has (`.quoteband`,
      `.hero-stage.compact`, the dock) a transparent top goes DARKER than
      the bottom and the light source ends up underneath the button.
      Mixing 12% white instead is ground-independent, which is depth
      rule 1.

   2. 92% FILL AND brightness(1.10), NOT 85% AND brightness(1.20). On this
      palette the reference numbers put white text on #6A8099 at hover:
      4.10:1, a failed AA gate. 92% tops the gradient out at #4A5E75
      (6.67:1) and lifts to #516781 on hover (5.82:1). The bottom of the
      gradient is 8.30:1 and 7.33:1 lit. Every state passes, measured in
      the browser off the computed gradient rather than off arithmetic.

   3. THE GLOW TINT FOLLOWS THE GROUND, THE FILL DOES NOT. A steel glow on
      a navy section is invisible, so dark contexts re-point --glow-color
      at the steel-light accent. The button's own fill never changes; only
      the light it throws.

   4. A NEUTRAL CONTACT SHADOW SITS UNDER THE COLOURED PAIR. Depth rule 2:
      shadows come in pairs, contact plus cast. A purely coloured shadow
      is all cast, and the button floats. The tight ink shadow is what
      keeps it an object instead of a sticker.

   5. THE RADIUS STAYS 2px. The reference is `rounded-md`. "Sharp is the
      personality" is this site's radius ruling and the glow is not
      carried by the corner. Changing --sglow-radius below rounds every
      glowing button at once if Fahad wants the softer shape.

   THE TWO BASEMENT DOORS (.door2, Fahad 2026-08-20: "the two steps for
   basements are also buttons") take the QUIET variant, not the solid one.
   They are two wide rows on the flagship's dark panel; filling both with
   the gradient turns that panel into a pair of steel bars and buries the
   photograph beside them. `.tile.xl` joined the dark-ground list so their
   glow reads in steel-light rather than disappearing into the navy. Their
   light twin `.door` on legal-basements is deliberately NOT glowing yet:
   it is the walkout package, a different control, and it is one row on a
   light ground rather than a pair.

   The existing hover LIFT and :active COMPRESSION are untouched: this
   block adds material, not movement.

   ---------------------------------------------------------------------
   WHY THE SHADOW LADDER IS DECLARED ON THE BUTTONS AND NOT ON :root.
   A custom property whose value contains var() is substituted at
   computed-value time ON THE ELEMENT THAT DECLARES IT. Composing
   --glow-rest on :root therefore BAKES IN root's --glow-halo and
   --glow-tint, and every later override — the dark grounds, the quiet
   variant, the booking sheet's ink — silently does nothing while looking
   completely correct in the source. It has to be declared where it is
   used. Do not "tidy" this ladder back up into :root.
   ===================================================================== */

/* --- The inputs. A ground or a context re-points these, nothing else. -- */
:root {
  --glow-fill:    var(--color-primary);   /* the surface */
  --glow-color:   var(--color-primary);   /* the light it throws */
  --glow-ground:  var(--color-surface);   /* what the halo is cut from */
  --glow-contact: rgba(8, 12, 20, .13);
  --glow-edge:    rgba(0, 0, 0, .10);     /* border-foreground/10 */
  --glow-ring:    3px;
  --glow-lift:    1.10;
  --glow-strength: 30%;                   /* SUBTLE PASS, below */
  --sglow-radius: var(--radius-xs);
}

/* DARK GROUNDS. Steel glow on navy is a black hole and an ink contact
   shadow on navy is nothing at all. Re-point the light; leave the fill. */
.quoteband,
.hero-stage.compact,
.dock,
.process,
.tile.xl,
footer.site {
  --glow-color:   var(--color-accent-inverse);
  --glow-ground:  var(--color-surface-inverse);
  --glow-contact: rgba(0, 0, 0, .30);
  --glow-edge:    rgba(255, 255, 255, .22);
}
/* The header used to CHANGE ground while you looked at it, so the glow was
   flipped on `.on-paper` to stop the "Get a quote" button wearing a white
   hairline on a white bar. Since 2026-08-20 the bar is paper from the
   first pixel, so the light values are simply the header's, once. */
header.site {
  --glow-color:   var(--color-primary);
  --glow-ground:  var(--color-surface);
  --glow-contact: rgba(8, 12, 20, .13);
  --glow-edge:    rgba(0, 0, 0, .10);
}

/* --- The ladder, resolved on the element. See the note above. --------- */
.sglow, .sglow-quiet, .sglow-icon,
.btn1, .btn2, .quote-btn, .ghost-btn,
.recpause, .door2,
.burger, .sheet .close, .dock a.call {
  --glow-top:  color-mix(in srgb, var(--glow-fill) 92%, var(--zef-white));
  --glow-grad: linear-gradient(to top, var(--glow-fill) 0%, var(--glow-top) 100%);
  --glow-tint: color-mix(in srgb, var(--glow-color) var(--glow-strength), transparent);
  --glow-halo: color-mix(in srgb, var(--glow-ground) 34%, transparent);

  --glow-rest:
    0 0 0 var(--glow-ring) var(--glow-halo),
    0 1px 2px var(--glow-contact),
    0 4px 8px -4px var(--glow-tint),
    0 14px 22px -10px var(--glow-tint);
  --glow-hover:
    0 0 0 var(--glow-ring) var(--glow-halo),
    0 2px 4px var(--glow-contact),
    0 7px 12px -6px var(--glow-tint),
    0 20px 30px -10px var(--glow-tint);
  --glow-press:
    0 0 0 2px var(--glow-halo),
    0 1px 2px var(--glow-contact),
    0 3px 7px -4px var(--glow-tint);

  box-shadow: var(--glow-rest);
}
/* `will-change` is deliberately NOT set on the ladder above. `.btn1` and
   `.btn2` already carry it from the depth system, and promoting every quiet
   button, icon button and review control to its own layer for a 110ms press
   is a layer count, not an optimisation. */

/* --- VARIANT: SOLID --------------------------------------------------- */
/* `.dock a.call` LEFT THIS FAMILY on 2026-08-28, and it had to: the glow IS
   colour. The variant paints --glow-grad (a tinted gradient), a --glow-edge
   border and --color-on-primary text, which is what kept forcing the Call
   mark white after the dock went monochrome, and what the steel smudge on
   the black bar actually was. Fahad's ruling that day -- "remove the blue
   inside as well, black and white is good" -- cannot be met by a control
   whose whole treatment is a tint, so Call is now governed by the plain
   .dock rules near the top of this file: white pill, ink contents. It came
   out of the hover, active and prefers-contrast lists below with it. The
   depth system still gives it the shared dock lift and press.
   Adding it back means re-opening that ruling. */
.sglow,
.btn1,
.quote-btn {
  background-image: var(--glow-grad);
  color: var(--color-on-primary);
  border: 2px solid var(--glow-edge);
  border-radius: var(--sglow-radius);
  transition: background-color var(--dur-hover) var(--ease-out-strong),
              border-color var(--dur-hover) var(--ease-out-strong),
              color var(--dur-hover) var(--ease-out-strong),
              box-shadow var(--dur-hover) var(--ease-out-strong),
              filter var(--dur-hover) var(--ease-out-strong),
              transform var(--dur-press) var(--ease-out-strong);
}

/* --- VARIANT: ICON ---------------------------------------------------- */
/* The reference's square. `.burger` and the sheet close already carry the
   44px minimum tap target, so this only re-skins them. */
.sglow-icon,
.burger,
.sheet .close {
  background-image: var(--glow-grad);
  color: var(--color-on-primary);
  border: 2px solid var(--glow-edge);
  border-radius: var(--sglow-radius);
  place-items: center;
  min-width: 44px; min-height: 44px;
  transition: box-shadow var(--dur-hover) var(--ease-out-strong),
              filter var(--dur-hover) var(--ease-out-strong),
              transform var(--dur-press) var(--ease-out-strong);
}
/* `float: right` is left alone on purpose: a floated box shrink-to-fits, so
   float + grid + the 44px minimum IS a right-aligned square. Switching it to
   `float: none; margin-left: auto` makes it a full-width block-level grid,
   because auto margins resolve to zero when width is auto. */
.sglow-icon, .sheet .close { display: grid; }

/* --- VARIANT: QUIET --------------------------------------------------- */
/* A third of the tint and no fill, so a secondary reads as the same object
   lit from the same place without competing for the click. Brightness is
   deliberately NOT applied here: on an unfilled control it brightens the
   LABEL rather than a surface, which washes the text out. These keep their
   existing currentColor-fill hover instead. */
.sglow-quiet,
.btn2,
.ghost-btn,
.recpause,
.door2 {
  --glow-strength: 14%;
  transition: background var(--dur-hover) var(--ease-out-strong),
              border-color var(--dur-hover) var(--ease-out-strong),
              color var(--dur-hover) var(--ease-out-strong),
              box-shadow var(--dur-hover) var(--ease-out-strong),
              transform var(--dur-press) var(--ease-out-strong);
}

/* --- STATES ----------------------------------------------------------- */
@media (hover: hover) and (pointer: fine) {
  .sglow:hover, .btn1:hover, .quote-btn:hover,
  .sglow-icon:hover, .burger:hover, .sheet .close:hover {
    filter: brightness(var(--glow-lift));
    box-shadow: var(--glow-hover);
  }
  .sglow-quiet:hover, .btn2:hover, .ghost-btn:hover,
  .recpause:hover, .door2:hover {
    box-shadow: var(--glow-hover);
  }
}
/* Press returns brightness to 100% and collapses the glow with the
   surface: depth rule 4, a thing pushed toward the page gets closer to
   its own shadow. */
.sglow:active, .btn1:active, .quote-btn:active,
.sglow-icon:active, .burger:active, .sheet .close:active,
.sglow-quiet:active, .btn2:active, .ghost-btn:active,
.recpause:active, .door2:active {
  filter: brightness(1);
  box-shadow: var(--glow-press);
  transition-duration: var(--dur-press);
}

/* --- REPAIRS TO THE RULES THIS BLOCK LANDS ON ------------------------- */
/* `.btn1:hover` sets `background` as a SHORTHAND, which resets
   background-image to none and would drop the gradient at exactly the
   moment the glow is brightest. Re-assert it at matching specificity
   rather than rewriting that rule, so its colour change still works
   underneath an opaque gradient. */
.btn1:hover { background-image: var(--glow-grad); }

/* The booking sheet's send is an INK pill by its own design (the footer
   reference's grammar). Point the whole recipe at that ink so it glows in
   its own colour instead of wearing a steel halo over a black fill. Its
   own :hover is another `background` shorthand, so it is repaired too. */
.sheet .panel.duo .btn1.send,
.sheet .panel.duo .btn1.send:hover {
  --glow-fill:  var(--bk-ink);
  --glow-color: var(--bk-ink);
  --glow-strength: 20%;   /* ink throws harder than steel at the same value */
  background-image: var(--glow-grad);
  border-radius: var(--radius-pill);
}

/* The booking sheet's close is a WHITE pill: Fahad's 2026-08-20 ruling that
   the duo panel follows its reference's skin, not the house style. Its rule
   is (0,3,0) and sets `background` as a SHORTHAND, so it beat the icon
   variant's gradient and reset background-image to none — leaving a white
   X on a white circle, invisible. It keeps the white pill and takes the
   glow recipe with the fill pointed at white and the light at ink. */
.sheet .panel.duo .close {
  --glow-fill:  var(--color-surface-raised);
  --glow-color: var(--bk-ink);
  --glow-strength: 13%;
  background-image: var(--glow-grad);
  color: var(--color-text);
  border: 2px solid var(--glow-edge);
  box-shadow: var(--glow-rest);
}

/* `.burger` was a bare glyph, hidden until 980px. The icon variant sets a
   display, which would show it on desktop. Restore the breakpoint gate. */
.burger { display: none; padding: 10px; }
@media (max-width: 980px) { .burger { display: grid; } }

/* =====================================================================
   FALLBACKS. Depth is NOT motion: shadows, lit edges and materials are
   static properties and they SURVIVE the reduced-motion switch. Only the
   movement goes. That distinction is the whole point of building depth
   into the surfaces rather than into the animation.
   ===================================================================== */
@media (prefers-reduced-motion: reduce) {
  .btn1:hover, .btn2:hover, .tile:hover, .tile.xl:hover, .dcard:hover,
  .badge:hover, .door:hover, .door2:hover, .dock a:hover {
    transform: none;
  }
  /* The pinned card's resting ROTATION is layout, not motion: only the
     hover scale goes, or the card would jump upright on approach. */
  .procard:hover .pcard { transform: rotate(var(--rot, 0deg)); }
  /* Press feedback is kept: it is confirmation, not decoration, and it is a
     110ms scale rather than travel across the screen. */
}
@media (prefers-contrast: more) {
  .btn1, .btn2, .tile, .dcard { box-shadow: none; }
  .tile, .dcard { border: 1.5px solid var(--color-border); }
  /* SOFT GLOW COMES OFF ENTIRELY HERE. A coloured halo is decoration, and
     a 4px ring of near-ground colour is exactly the edge a high-contrast
     user is relying on to find where the control stops. The gradient goes
     with it: a flat fill under white text is the higher-contrast state at
     every point, because the gradient's lit top is its weakest ratio. */
  .sglow, .sglow-quiet, .sglow-icon,
  .btn1, .btn1:hover, .quote-btn, .burger, .sheet .close,
  .ghost-btn, .recpause,
  .sheet .panel.duo .btn1.send, .sheet .panel.duo .btn1.send:hover {
    box-shadow: none; background-image: none; filter: none;
  }
  .sglow, .sglow-icon, .btn1, .quote-btn, .burger, .sheet .close {
    border-color: var(--color-border);
  }
}



/* =====================================================================
   THE CLOSING PAGE — the ContactCard port (Fahad's 21st.dev reference,
   2026-08-20), on the concrete the four sections above it stand on.

   PORTED, NOT INSTALLED (Build-5 precedent). The reference is React +
   Tailwind + shadcn; nothing in it needs any of the three to exist. A
   bordered card split two ways, a muted panel on one side, icon rows on the
   other, crop marks at the corners: all four are CSS.

   THE PROPORTIONS ARE INVERTED FROM THE REFERENCE, and that is the other
   instruction talking: seven fields in the reference's narrow third is a
   700px card, and "the size of the About us page" is 750px for the whole
   section. So the form takes the two-thirds and runs two fields to a row,
   and the four facts take the third and stack. Everything else about the
   reference is kept.

   ONE PAGE. `min-height: 100vh` still, never a fixed height: the panels
   stack on a phone and the card is taller than a screen there. The bottom
   padding clears the lead dock, which is fixed over every page.
   ===================================================================== */
.closer > .inner {
  min-height: 100vh;
  display: flex; flex-direction: column; justify-content: center;
  padding-top: var(--space-6); padding-bottom: 104px;
}

.ccard {
  position: relative;
  display: grid; grid-template-columns: 1fr 2fr;
  background: var(--color-surface-raised);
  border: 1.5px solid var(--color-border);
  box-shadow: var(--el-floating);
}
/* The reference's four PlusIcons, hung off the corners. They land better
   here than they do there: on a site whose whole language is a drawing set,
   four crosses outside the corners of a box are registration marks. */
.ccard-mark {
  position: absolute; width: 22px; height: 22px; display: grid; place-items: center;
  color: var(--color-text); pointer-events: none;
}
.ccard-mark svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.5; }
.ccard-mark.tl { top: -11px; left: -11px; }
.ccard-mark.tr { top: -11px; right: -11px; }
.ccard-mark.bl { bottom: -11px; left: -11px; }
.ccard-mark.br { bottom: -11px; right: -11px; }

/* ---- the facts side ---- */
.ccard-main {
  display: flex; flex-direction: column;
  padding: var(--space-9) var(--space-6);
  min-width: 0;
}
/* NO EYEBROW (Fahad, 2026-08-20): the ordinal, the tick and the kicker every
   other section head carries came off this one, so the card opens on the
   word. What is left is the section-head grammar minus its meta column --
   Anton, uppercase, the accent, the full stop -- at card scale, and the scale
   is what buys the one-page fit (the 178px banner plus its gap was 195px of
   the 750). */
.ccard-title {
  font-family: var(--font-display); font-weight: 400;
  font-size: clamp(34px, 3.6vw, 52px); line-height: .92;
  letter-spacing: .014em; text-transform: uppercase;
  color: var(--color-primary);
}
.cinfo-grid { margin-top: auto; padding-top: var(--space-6); display: grid; gap: var(--space-2); }
.cinfo { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-2) 0; }
/* NO TILE BEHIND THE MARK (Fahad, 2026-08-20: "remove the grey box behind
   them"). The reference put its icon on a muted square; these are colour
   glyphs and a logo, and they carry themselves.

   THE FIXED WIDTH STAYS, and it is doing the work the tile used to: an emoji,
   a wider emoji and a 52:40 logo each set their own advance width, and with
   the box gone the four labels beside them would start at four different
   places. 34px is the widest of them, so every row now starts on one line.

   The font stack is NAMED, and it has to be: left to inherit, the glyph is
   set in Instrument Sans first, which has no emoji coverage, and the browser
   falls back per character with its own metrics. Naming the platform emoji
   faces gets the colour glyph and a predictable box on every one of them. */
.cinfo-mark {
  flex: none; width: 34px; display: grid; place-items: center;
  font-family: "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif;
  font-size: 25px; line-height: 1;
}
/* The logo is a 52:40 box, so it is sized on WIDTH and left to find its own
   height, or it would be squashed into the emoji's square. */
.cinfo-mark svg { width: 32px; height: auto; display: block; }
.cinfo-text { display: grid; gap: 1px; min-width: 0; }
/* Anton, and the SAME 19px the Contact page's "Hours" and "Email" are set at
   (`.channel h2`), so the two pages say the same words in the same voice.
   The face itself comes from the display list further down the file, which
   is where every Anton run on this site is declared once. */
.cinfo-text b { font-size: var(--text-2xl); color: var(--color-text); }
.cinfo-text i { font-style: normal; font-size: var(--text-md); color: var(--color-text-muted); }
.cinfo-text a { color: var(--color-text-muted); }
.cinfo-text a:hover { color: var(--color-primary); }

/* ---- the form side ---- */
.ccard-form {
  background: var(--color-surface-sunken);
  border-left: 1.5px solid var(--color-border);
  padding: var(--space-9) var(--space-7);
  min-width: 0; display: flex; align-items: center;
}
/* `.form` keeps its class for the confirmation swap in main.js
   (`form.closest('.form, .sheet .panel')`) but not its skin: it is standing
   inside a panel that already is the card, and a second bordered box inside
   the first is the thing this reference exists to avoid. */
.ccard .form {
  background: none; border: 0; box-shadow: none; padding: 0; width: 100%;
}
/* Two to a row, every box the same, nothing spanning. */
.fgrid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--space-5); }
/* When "Something else" opens its line, the date starts a fresh row so it
   and its callback window are never split across two. */
.fgrid:has(> .fother:not([hidden])) > .fwhen { grid-column: 1; }

/* THE ONE-PAGE RHYTHM. Scoped to this card, never to `.field` at large: the
   Contact page and the booking sheet keep the roomier spacing they were
   signed off with. Only the SPACING moves -- every control is still 44px,
   which is the tap target, and is the one number here that is not free. */
.ccard-form .field { margin-top: var(--space-4); }
/* The field labels take the display face too (Fahad, 2026-08-20, pointing at
   "Your name"), so the card reads in one voice: the head, the fact labels and
   the field labels are all Anton now, at three sizes. The face itself is set
   in the display list further down, where every Anton run on this site is
   declared once; this is only its size and its air. Scoped to the card --
   the Contact page's form was signed off in Instrument Sans and nobody has
   asked for it to change. */
.ccard-form .field label { margin-bottom: 5px; font-size: var(--text-lg); }
.ccard-form .send { margin-top: var(--space-5); }
.ccard-form .formnote { margin-top: var(--space-2); }

/* THE DATE IS PICKED, NOT TYPED (his ruling). `readonly` is set by main.js,
   so the pointer only changes where the calendar it opens actually exists. */
html.js .dpick[data-locked] input { cursor: pointer; }

/* Tablet: the reference's own md: step, two equal columns. */
@media (max-width: 1060px) {
  .ccard { grid-template-columns: 1fr 1fr; }
  .ccard-main, .ccard-form { padding: var(--space-7) var(--space-5); }
}
/* Phone: the panels stack and the hairline moves to the top of the form,
   exactly as the reference's `border-t-0 border-l` does in reverse. */
@media (max-width: 820px) {
  .ccard { grid-template-columns: minmax(0, 1fr); }
  .ccard-form { border-left: 0; border-top: 1.5px solid var(--color-border); }
  .cinfo-grid { margin-top: var(--space-6); grid-template-columns: 1fr 1fr; }
}
@media (max-width: 620px) {
  .fgrid { grid-template-columns: 1fr; }
  .fgrid:has(> .fother:not([hidden])) > .fwhen { grid-column: auto; }
  .cinfo-grid { grid-template-columns: minmax(0, 1fr); }
  .ccard-form { padding: var(--space-5) var(--space-5) var(--space-6); }
}

/* =====================================================================
   SERVICES HOVER SLIDESHOW (Fahad's component, 2026-08-20)

   A React + `motion` component ported with NOTHING INSTALLED, because every
   moving part of it is a hover state and CSS does hover states without a
   runtime. `:has()` reads which link is engaged and drives both the wipe and
   the letter swap. That is not a shortcut: a motion-library port would have
   put the site's whole services list behind JS, and rule 31 says content
   visibility never depends on JS. With scripting off this still works
   completely, hover included.

   WHAT IT REPLACED: the six-tile grid. Same six services, same six
   photographs, same six destinations, same approved blurbs (now the caption
   under the photograph). Nothing was dropped; it is the same content in the
   component Fahad asked for. The tile builder is in this file's git history.

   THE LAYOUT IS padding-right PLUS ABSOLUTE SHOTS, NOT A GRID, and that is
   deliberate. Stacking the shots in a grid column needs `grid-row: 1 / -1`,
   and `-1` resolves against the EXPLICIT grid, which cannot exist here
   because the service count is data. Reserving the media column with
   padding and dropping the shots into it absolutely has no such edge.

   THE DIM IS A COLOUR, NOT `opacity: .2`. The reference dims inactive items
   to 20%, which on this ground measures about 1.5:1. Fine for a demo where
   the words are decoration; fatal here, where they are the only label on the
   site's main service list. Resting titles mix to 5.24:1 and the dim-to-bright
   change still reads, because it is carried by the letter travel as much as
   by the tone.
   ===================================================================== */
.hsl {
  /* Sized so the LONGEST title still sets on one line at 1440. Three of the
     six ("Interior Renovation", "Exterior Renovation", "Flooring and
     Painting") wrapped at 520/space-9/54px and the column went ragged; the
     media column gave up 40px, the gap 26px, and the type 8px to fix it.
     Below 1240 they wrap again, which is correct: that is a narrower column,
     not a mistake. */
  --hsl-media: 480px;
  --hsl-gap: var(--space-8);
  /* 70% against the BAND, not 58% against the light ground. The old value
     was tuned when this list sat on a flat near-white and measured 5.2:1;
     the concrete ground is darker, and mixing against --color-surface (which
     the band no longer is) dropped these to 3.68:1 typical and 2.97:1 over
     the texture's darkest patches. They are the only label on the site's
     main service list, so they are back above 5:1 measured against the
     darkest 1% of the tile, not against its average. Still far dimmer than
     the engaged title, which is pure ink, so the swap still reads. */
  /* GREY AT REST, BLACK ON HOVER (Fahad, 2026-08-20: "lets revert this to
     black as well ... so a shaded grey and then when you move cursor on top
     changes to black"). This REVERSES the accent ruling taken earlier the
     same day, which had made the resting list #47617F flat -- the bluest
     thing on the palette, and a deliberate exception to the 5:1 rule at
     3.33:1. It is the 70% ink mix again, which is what the list was before
     that, and the swap runs grey-to-black rather than steel-to-navy.

     The mix is against the BAND, not a flat grey, so the resting titles sit
     in the concrete's own family rather than on top of it. Measured 5.13:1
     against the ground's darkest 1% (luminance byte 187, sampled over all
     1.57M pixels), which is the number the earlier exception gave up. The
     engaged ink lands at 10.94:1 on the same ground, and the two states sit
     2.13:1 apart, which is what makes the roll read as a change of state
     rather than as a hover that did nothing.
     `prefers-contrast: more` further down still forces the whole list to
     pure ink for anyone who asks the OS for it. */
  --hsl-rest: color-mix(in srgb, var(--zef-ink) 70%, var(--color-surface-band));
  --hsl-clip-open:   polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%);
  --hsl-clip-closed: polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%);
  --hsl-char: .3s;
  --hsl-wipe: .8s;
  --hsl-ease-char: cubic-bezier(.25, .46, .45, .94);
  --hsl-ease-wipe: cubic-bezier(.33, 1, .68, 1);

  position: relative;
  margin-top: var(--space-8);
}

/* ---- the titles ---- */
.hsl-link {
  display: flex; align-items: center; gap: var(--space-4);
  text-decoration: none; color: inherit;
  padding: var(--space-2) 0;
}
.hsl-word {
  display: inline-block;
  font-size: clamp(28px, 3.7vw, 46px);
  font-weight: var(--weight-bold);
  letter-spacing: var(--track-display);
  line-height: 1.06;
  text-transform: uppercase;
}
/* Each character is two stacked copies in one clipped box: the resting one
   travels up and out, the incoming one arrives from below. */
/* One nowrap box per word: characters are inline-blocks, so without this a
   line can break between any two LETTERS. Breaks now fall between words. */
.hsl-w { display: inline-block; white-space: nowrap; }
.hsl-ch {
  position: relative; display: inline-block; overflow: hidden;
  vertical-align: top;
}
.hsl-ch .l1, .hsl-ch .l2 {
  display: inline-block;
  transition: transform var(--hsl-char) var(--hsl-ease-char) calc(var(--i, 0) * 25ms),
              color var(--hsl-char) var(--hsl-ease-char) calc(var(--i, 0) * 25ms);
}
.hsl-ch .l1 { color: var(--hsl-rest); transform: translateY(0); }
.hsl-ch .l2 {
  position: absolute; left: 0; top: 0;
  /* The engaged letter is INK, the thing the row rolls up to reveal. Back to
     the colour it carried before the accent ruling, per his reversal. */
  color: var(--color-text); transform: translateY(110%);
}
.hsl-go {
  /* Ink, with the ordinal and the tick (Fahad, 2026-08-20). The row itself
     is the accent now, so an accent arrow on an accent title said nothing. */
  color: var(--color-text);
  font-size: clamp(16px, 1.5vw, 22px);
  line-height: 1; align-self: center;
  opacity: 0; transform: translateX(-10px);
  transition: opacity var(--dur-hover) var(--ease-out-strong),
              transform var(--dur-hover) var(--ease-out-strong);
}

/* ---- the photographs ---- */
.hsl-shot { display: block; }
.hsl-shot picture { display: block; }
/* `cover`, AND THAT IS WHAT KEEPS EVERY PICTURE THE SAME SIZE (Fahad,
   2026-08-20: "All pictures need to be the same size even as placeholders").
   The panel box is fixed -- `--hsl-media` wide by the list's full height --
   so `cover` renders all seven identically whatever their source ratio is.
   `contain` was tried the same day and reverted: the sources run 0.50 to
   1.00, so each one fitted to a different width and the picture resized as
   the pointer moved down the list.
   TWO THINGS DEPEND ON A PANEL FILLING ITS BOX. It hides the resting
   BASEMENTS shot underneath at z0 -- a contained portrait covered about
   280px of the 480px box and the resting photograph showed through the
   slack, putting two rooms on screen at once -- and it keeps the column a
   constant shape while the wipe plays. If a future change stops a panel
   filling its box, it needs an opaque backing or both come back. */
.hsl-shot img {
  display: block; width: 100%; height: 100%; object-fit: cover;
}
.hsl-cap {
  display: block; margin-top: var(--space-2);
  /* Ink, not muted: this section's fine writing, per the 2026-08-20 ruling.
     Scoped here rather than at the token, which still serves body copy. */
  font-size: var(--text-lg); color: var(--color-text); line-height: 1.5;
}

/* =====================================================================
   THE ACTIVE STATE. One list of selectors drives the letter swap and the
   wipe together, so they can never disagree about which service is live.

   `:has(> a...)` rather than `.hsl:hover`, because the media panel is INSIDE
   .hsl: a plain :hover fires when the pointer is over the photograph and
   would blank the list. Specificity was checked rather than assumed — the
   per-index rules land at (0,4,2) against the reset's (0,4,1), so the
   engaged shot wins.
   ===================================================================== */
@media (min-width: 901px) {
  .hsl { padding-right: calc(var(--hsl-media) + var(--hsl-gap)); min-height: 420px; }
  .hsl-shot {
    position: absolute; top: 0; right: 0;
    width: var(--hsl-media); height: 100%;
    clip-path: var(--hsl-clip-closed);
    /* THE CLOSING DELAY IS THE WHOLE FIX (Fahad, 2026-08-24: "every time you
       move cursor from one service to another the basement photo shows up in
       the back").

       Every panel wipes from the TOP edge. Moving service to service, the
       outgoing panel retracted upward while the incoming one grew downward,
       so for most of the 800ms neither covered the lower box and the resting
       BASEMENTS panel at z0 showed through the slack. It read as a basement
       flash between every pair of services.

       So the close is delayed by exactly one wipe. The outgoing panel holds
       its open state for as long as the incoming one takes to cover it, then
       retracts behind it where nothing can see it happen. The new photograph
       now wipes over the PREVIOUS photograph, which is what a wipe is
       supposed to look like, and the basement is only ever revealed when the
       pointer leaves the list entirely. */
    transition: clip-path var(--hsl-wipe) var(--hsl-ease-wipe) var(--hsl-wipe);
  }
  /* Captions cannot ride along. Two open panels means two captions stacked at
     the bottom of the same box over transparent ground, which is the exact
     overprint Fahad screenshotted on 2026-08-20. The engaged panel sets
     --cap-o and only that caption is drawn; the outgoing one drops its
     caption at once even though its picture lingers. */
  .hsl-shot:not(.hsl-bshot) { --cap-o: 0; }
  .hsl-shot picture { height: calc(100% - 2.4em); }
  .hsl-cap { position: absolute; bottom: 0; left: 0; right: 0; margin-top: 0; }

  /* THE RESTING PANEL, and why it is not simply the first service's shot.

     It used to be: `.hsl-shot:first-of-type` opened with the page, which
     made KITCHENS the one service on the list that never animated. Hovering
     it went from open to open, so the wipe had nothing to play, and its
     title was pre-lit for the same reason (Fahad, 2026-08-20: "Kitchens
     picture does not animate like the rest of them").

     Now a panel belonging to no service holds the resting state, sits UNDER
     the six and never closes. All six wipe over it identically. It also
     fills the half of the column that used to go bare mid-wipe: every panel
     wipes from the top edge, so moving service to service emptied the
     bottom for the length of the transition. */
  .hsl-shot { z-index: 1; }
  /* The basement panel is the resting state: open by default, under the
     six, never closed. The separate resting panel it replaces carried a
     photograph belonging to no service and Fahad pulled it 2026-08-20. */
  .hsl-shot.hsl-bshot { z-index: 0; clip-path: var(--hsl-clip-open); }

  /* AND ITS CAPTION HAS TO STAND DOWN, or two sentences print on top of one
     another (Fahad, 2026-08-20, with a screenshot of exactly that).

     Captions are absolutely positioned at the bottom of the panel box and
     nothing behind them is opaque. A CLOSED panel's caption is clipped away
     with the rest of it, so only open panels show one — and this panel is
     the one that is always open. The moment a service wipes in, its caption
     lands on top of the basement's.

     This is the second time this exact bug shipped: the first resting panel
     had a caption too, and removing it was the fix. It came back when the
     basement panel inherited the resting job, because unlike that one it
     has a caption it needs at rest. So it is hidden on engagement rather
     than deleted. The basements link is inside `.hsl-group`, not a direct
     child, so hovering BASEMENTS correctly leaves its own caption up. */
  .hsl-cap { opacity: var(--cap-o, 1); transition: opacity var(--hsl-char) var(--hsl-ease-char); }
  .hsl:has(> .hsl-link:is(:hover, :focus-visible)) > .hsl-bshot .hsl-cap {
    opacity: 0;
  }

  /* The engaged service, whichever one it is. */
  /* TITLE N OPENS PANEL N+1. The offset is the basement panel, which sits
     first among the spans so the stacked mobile order stays title,
     photograph, title, photograph. Only DIRECT children count here, and the
     basements link lives inside `.hsl-group`, so it is not in this chain at
     all: `a:nth-of-type(1)` is Kitchens. Add a service and the offset still
     holds, because a new title brings a new panel with it. */
  .hsl:has(> a:nth-of-type(1):is(:hover, :focus-visible)) > span:nth-of-type(2),
  .hsl:has(> a:nth-of-type(2):is(:hover, :focus-visible)) > span:nth-of-type(3),
  .hsl:has(> a:nth-of-type(3):is(:hover, :focus-visible)) > span:nth-of-type(4),
  .hsl:has(> a:nth-of-type(4):is(:hover, :focus-visible)) > span:nth-of-type(5),
  .hsl:has(> a:nth-of-type(5):is(:hover, :focus-visible)) > span:nth-of-type(6),
  .hsl:has(> a:nth-of-type(6):is(:hover, :focus-visible)) > span:nth-of-type(7),
  .hsl:has(> a:nth-of-type(7):is(:hover, :focus-visible)) > span:nth-of-type(8),
  .hsl:has(> a:nth-of-type(8):is(:hover, :focus-visible)) > span:nth-of-type(9) {
    clip-path: var(--hsl-clip-open);
    /* No delay on the way IN, and above the panel that is still holding. */
    transition-delay: 0s;
    z-index: 2;
    --cap-o: 1;
  }

  /* BASEMENTS needs no open rule of its own: its panel is the resting one
     and is already open. The group still exists to keep `:hover` and
     `:focus-within` true while the pointer or the keyboard is on its two
     sub-links, which is what lets them be reached at all. */

  /* NO TITLE IS LIT AT REST any more. The first one used to be, and that
     was the other half of the same problem: its letters were already
     swapped and its arrow already showing, so hovering the first service
     changed nothing, photograph or type. Six services, six identical
     behaviours. */
}

/* The engaged title, at every width. The grouped one lights from the GROUP,
   not from its own link: travelling to a sub-option leaves the anchor, and
   a title that goes dark while you are reaching for its own sub-links reads
   as the row having been dropped. */
.hsl > .hsl-link:is(:hover, :focus-visible) .l1,
.hsl-group:is(:hover, :focus-within) .l1 { transform: translateY(-110%); }
.hsl > .hsl-link:is(:hover, :focus-visible) .l2,
.hsl-group:is(:hover, :focus-within) .l2 { transform: translateY(0); }
.hsl > .hsl-link:is(:hover, :focus-visible) .hsl-go,
.hsl-group:is(:hover, :focus-within) .hsl-go { opacity: 1; transform: none; }

/* =====================================================================
   THE BASEMENTS ROW. One title with two routes hanging off it.

   THE SUB-OPTIONS EXPAND SIDEWAYS, NOT DOWN, and that is a usability call
   rather than a stylistic one: opening them downward pushes the six
   services below by ~60px, straight under a pointer that is already moving,
   and the row you land on is not the row you aimed at. Growing along the
   row moves nothing else on the page.
   ===================================================================== */
.hsl-group { display: flex; align-items: center; min-width: 0; }
.hsl-group > .hsl-link { flex: none; }
.hsl-subs {
  display: flex; align-items: center; gap: var(--space-4);
  min-width: 0; max-width: 0; opacity: 0; overflow: hidden;
  transition: max-width var(--hsl-wipe) var(--hsl-ease-wipe),
              opacity var(--hsl-char) var(--hsl-ease-char),
              padding-left var(--hsl-wipe) var(--hsl-ease-wipe);
}
.hsl-group:is(:hover, :focus-within) .hsl-subs {
  max-width: 520px; opacity: 1; padding-left: var(--space-6);
}
.hsl-subs a {
  display: block; text-decoration: none; color: inherit;
  padding: var(--space-2) var(--space-4);
  border-left: 1.5px solid var(--color-border);
  white-space: nowrap;
}
.hsl-subs b {
  display: block; font-size: var(--text-lg); font-weight: var(--weight-semi);
  line-height: 1.2;
}
.hsl-subs i {
  display: block; font-style: normal; margin-top: 2px;
  /* Ink, with the rest of this section's fine writing. */
  font-size: var(--text-md); color: var(--color-text); line-height: 1.2;
}
.hsl-subs a:hover b, .hsl-subs a:focus-visible b { color: var(--color-primary); }

/* =====================================================================
   BELOW 901px there is no hover, so there is no slideshow: every service is
   its own block with its own photograph and its own line of copy, which is
   what the markup already says. Nothing is hidden and nothing needs a tap to
   be found. This is the same shape the six tiles had, minus the crop.
   ===================================================================== */
@media (max-width: 900px) {
  .hsl { display: grid; gap: var(--space-7); }
  /* The two basement routes are simply printed, under their title rather
     than beside it. Nothing on a phone can reveal them. */
  .hsl-group { display: block; }
  .hsl-subs {
    max-width: none; opacity: 1; overflow: visible; padding-left: 0;
    margin-top: var(--space-3); gap: var(--space-3);
    /* STACKED, WHICH IS WHAT THE COMMENT ABOVE ALWAYS CLAIMED (2026-08-29).
       `.hsl-subs` kept `display: flex` in its row direction here, and its
       links carry `white-space: nowrap`, so the two basement routes sat side
       by side and ran 40px past a 375px screen. The home page answered by
       widening its layout viewport to 415px -- which is why the audit's
       overflow check read zero: every element was being measured against the
       widened document, so nothing looked too wide. A page that reports an
       innerWidth wider than the device is the tell, not the overflow list. */
    flex-direction: column; align-items: stretch;
  }
  .hsl-subs a { padding-left: var(--space-3); }
  .hsl-link { padding-bottom: 0; }
  .hsl-shot { margin-top: calc(var(--space-4) * -1); }
  .hsl-shot img { aspect-ratio: 4 / 3; }
  /* Phones show one state per title -- there is no hover to resolve to -- so
     the list takes the ENGAGED colour rather than the resting one. Same ink
     the desktop list lands on when you reach it, and 20.4:1 rather than
     5.13:1, which is the right way round for the smaller screen. */
  .hsl-ch .l1 { color: var(--color-text); }
  .hsl-ch .l2 { display: none; }
  .hsl-go { opacity: 1; transform: none; }
}

/* No `:has()`: every photograph shows, stacked, and the titles are all live.
   Losing the slideshow costs nothing; losing the pictures would. */
@supports not selector(:has(a)) {
  @media (min-width: 901px) {
    .hsl { padding-right: 0; min-height: 0; display: grid; gap: var(--space-7); }
    .hsl-subs { max-width: none; opacity: 1; overflow: visible; }
    .hsl-shot { position: static; width: auto; height: auto; clip-path: none; }
    .hsl-shot picture { height: auto; }
    .hsl-cap { position: static; margin-top: var(--space-2); }
    /* Every title is live at once here, so it takes the engaged ink too. */
    .hsl-ch .l1 { color: var(--color-text); }
    .hsl-ch .l2 { display: none; }
  }
}

@media (prefers-reduced-motion: reduce) {
  /* The swap survives, the travel does not: the letters cross-fade in place
     and the photograph cuts rather than wipes. */
  .hsl-ch .l1, .hsl-ch .l2, .hsl-shot, .hsl-go { transition: none; }
}
@media (prefers-contrast: more) {
  /* The resting list is a 70% ink mix by ruling (see --hsl-rest); this takes
     it the rest of the way to pure ink for anyone who asks the OS for more
     contrast, so the resting and engaged states become one colour and the
     list stops relying on a tonal difference to be read. */
  .hsl-ch .l1 { color: var(--color-text); }
}

/* =====================================================================
   FLAT SECTIONS (Fahad's rulings, 2026-08-19)

   The stacked-sheet construction from 2026-08-17 is FULLY REMOVED in two
   steps the same day: first the rise motion ("not make the pages come
   from down to up"), then the overlap itself ("how the page tiles are
   overlapping" — his picture). No rounded top lips, no negative margins,
   no z-index ladder, no upward shadows: sections sit flush, one after
   the other, on the white ground his 2026-08-17 ruling set. Hairline
   borders on the banded components (.certs, .svcidx, .tstm) are back to
   carrying the section boundaries, the way Build 1 drew them.
   ===================================================================== */
main > section {
  position: relative;
  background: var(--color-surface);
}
/* THE BANDED COMPONENTS CARRY THE PAPER GROUND, on every page (Fahad said
   "for all pages"). `.certs`, `.svcidx` and `.tstm` are the site's three
   banded components and were already the ones drawing the section
   boundaries with hairlines; they now draw them with a ground as well. The
   home page's own three bands join them.

   The result is an alternating rhythm rather than a wash: the unclassed
   sections keep the lighter ground, so grey and light take turns down every
   page instead of the whole site reading as one white sheet. Heroes, the
   photographic CTA bands and the footer still set their own. */
/* ROOM BEFORE THE NEXT PAGE COMES UP, AND THE FLOOR UNDER IT (Fahad,
   2026-08-20, then shortened the same day). A beat only holds still for the
   distance it is TALLER than the viewport. Expertise measured 798px against
   an 813px viewport, so Process began rising over it on arrival; +190px
   bought a 175px hold and read as a 260px empty room.

   THIS VALUE HAS A HARD FLOOR. The section's content is 728px at the resting
   row pitch, so anything under ~92px here puts the whole beat below an 813px
   viewport and the takeover starts instantly -- the original bug, restored.
   The ladder, measured at 1440x813:
       +190px -> 988px section, 175px hold   (as built)
       +90px  -> 888px section,  75px hold   (here)
       +22px  -> 820px section,   7px hold   (floor; retire barely reads)
   Shortening the section shortens the retire window by the same amount.
   Raising the row pitch instead buys height without pooling it, and was
   tried and rejected on look. */
main > section.wb .inner { padding-bottom: calc(var(--space-9) + 90px); }

main > section.wb,
main > section.process,
main > section.about,
main > section.tstm,
main > section.closer,
main > section.svcidx,
main > section.certs,
/* `.band` IS THE OPT-IN GROUND (Fahad, 2026-08-20 night: "the concrete
   background ... pretty much what we did with FAQ page but I need it done
   across the site"). The five service pages, About and Contact carry it on
   every content section, so those pages read the way faq.html and index.html
   already do.

   IT IS AN ALLOWLIST, DELIBERATELY. The obvious alternative was a page-level
   class on <main> with a :not() chain excluding heroes, the navy quote band,
   `.facts` and `.dash`. A forgotten entry in a denylist does not fail loudly:
   it paints paper-coloured concrete under paper-coloured text and ships as a
   contrast bug nobody sees. Opt-in cannot do that, and it also keeps the three
   basement pages and index.html untouched by construction rather than by
   anyone remembering to exclude them.

   THIS SUPERSEDES THE ALTERNATION NOTE in CLAUDE.md, and the boundary job that
   note was really protecting is now done by `.sechead`'s own hairline: every
   section on these pages carries an editorial head, and each head opens with a
   1.5px rule. The inner pages never had the curtain chain, and the black
   section hairlines were retired on 2026-08-19, so the alternating ground was
   the last boundary cue. It has a replacement before it was removed. */
main > section.band {
  background-color: var(--color-surface-band);
  background-image: var(--band-texture);
}
/* section.process joined the white ground 2026-08-19 (homogenous-pages
   ruling); its own .process rule paints the surface now. */

/* =====================================================================
   THE SECTION RULE (`.ruled`, 2026-08-20 night)

   Fahad: one oversized head per page, everything under it a subheading. That
   left the sections below the title with no boundary at all -- same concrete
   above and below, no curtain chain on the inner pages, and the black section
   hairlines retired on 2026-08-19 for drawing a hard line across the screen.

   This is the soft rule that retirement note called for. `--color-border-soft`
   is ink at 20%, and it sits on `.inner` so it spans the content width and
   lines up with the hairline `.sechead` draws above the page title.

   OPT-IN, like `.band`: only the seven editorial pages emit it. The basement
   pages and index.html keep their borderless sections.
   ===================================================================== */
main > section.ruled > .inner { position: relative; }
main > section.ruled > .inner::before {
  content: ""; position: absolute; top: 0;
  left: var(--space-5); right: var(--space-5); height: 1.5px;
  background-color: var(--color-border-soft);
}

/* The photographic CTA band clips its own background image. */
main > section.quoteband.photo { overflow: hidden; }

/* =====================================================================
   ABOUT US (Fahad, 2026-08-20) — the home page's who-we-are band.

   THE GRAPH IS A DIMENSION LINE. Every chart convention on offer (bars,
   donut, sparkline) belongs to a dashboard, and this site is ink on a
   drawing set. A measured span with end ticks, a figure over it and the
   scale beneath is how a drawing set states a length, so the one chart
   on the site is the one the direction already speaks.

   THE SPLIT IS ONE NUMBER, passed in as --split by generate.py from the
   two dates. The segment widths and the position of the middle year label
   all resolve from it, so the graph cannot disagree with itself in the
   year the arithmetic changes.
   ===================================================================== */
.aboutgrid {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: var(--space-8); align-items: start;
  margin-top: var(--space-8);
}
.aboutcopy p { font-size: var(--text-xl); line-height: var(--lh-body); max-width: 46ch; }
.aboutcopy .btn2 { margin-top: var(--space-6); }
/* THE STORY (2026-08-21): Fahad's own four-paragraph passage. The opening
   line keeps the xl size as the lede; the paragraphs after it drop to lg so
   a 130-word story does not run at the same volume the old one-liner did.
   The 46ch measure holds for all of them. */
.aboutstory p + p { margin-top: var(--space-4); font-size: var(--text-lg); }

.tspan { margin: 0; }
.tspan-fig { display: flex; align-items: baseline; gap: var(--space-3); }
.tspan-fig b {
  font-family: var(--font-display); font-weight: 400;
  letter-spacing: .014em; line-height: .9;
  font-size: clamp(56px, 7vw, 92px);
}
.tspan-fig span {
  font-size: var(--text-xs); letter-spacing: var(--track-wide);
  text-transform: uppercase; color: var(--color-text-muted);
}

/* The measured span. The end ticks are drawn on the TRACK, not on the
   segments: a border per segment doubles at the join and reads as a 3px
   rule in the middle of a 1.5px drawing. */
.tspan-track {
  display: flex; margin-top: var(--space-5); height: 26px;
  border-left: 1.5px solid var(--color-border);
  border-right: 1.5px solid var(--color-border);
}
.tspan-seg {
  display: flex; align-items: center; justify-content: center;
  min-width: 0; overflow: hidden;
}
.tspan-seg.one { width: var(--split); background: var(--color-primary); }
.tspan-seg.two { width: calc(100% - var(--split)); background: var(--zef-ink); }
.tspan-seg i {
  font-style: normal; font-family: var(--font-mono);
  /* 10.5px, not the 8px --text-3xs this started on: these are the only
     numbers inside the graph and 8px mono on a 26px bar read as texture. */
  font-size: var(--text-xs); letter-spacing: var(--track-wide);
  text-transform: uppercase; color: var(--color-text-on-inverse);
  white-space: nowrap; padding: 0 var(--space-2);
}

/* The scale. The middle year sits ON the join, resolved from --split
   exactly as the segments are. */
.tspan-scale {
  position: relative; height: 1.5em; margin-top: 6px;
  font-size: var(--text-xs); color: var(--color-text-muted);
}
.tspan-scale span { position: absolute; top: 0; }
.tspan-scale span:nth-child(1) { left: 0; }
.tspan-scale span:nth-child(2) { left: var(--split); transform: translateX(-50%); }
.tspan-scale span:nth-child(3) { right: 0; }

.tspan-key { list-style: none; margin-top: var(--space-5); display: grid; gap: var(--space-2); }
.tspan-key li {
  display: flex; align-items: flex-start; gap: var(--space-3);
  font-size: var(--text-md); color: var(--color-text-muted); line-height: 1.5;
}
.tspan-key .sw { width: 14px; height: 14px; flex: none; margin-top: 3px; }
.tspan-key .sw.one { background: var(--color-primary); }
.tspan-key .sw.two { background: var(--zef-ink); }

/* THE SPAN DRAWS ITSELF, and only under html.js. Written the way the reveal
   system is (prod-rules #31): the resting widths are the FULL ones, so with
   no JS the graph is simply a finished graph. Only once `html.js` is set
   does it start at zero and grow when its .reveal lands. */
html.js .tspan-seg { width: 0; transition: width .9s var(--ease-out-strong) .1s; }
html.js .tspan.in .tspan-seg.one { width: var(--split); }
html.js .tspan.in .tspan-seg.two { width: calc(100% - var(--split)); }
@media (prefers-reduced-motion: reduce) {
  html.js .tspan-seg { transition: none; }
}

/* The figures. Hairline-separated cells rather than cards, because a card
   grid here would compete with the process board directly below it.
   SIX CELLS AT 2x3 since 2026-08-21 (was four across): the six stats Fahad
   ruled for the About story, sitting as `.aboutgrid`'s right column in the
   space the dimension line vacated, so the column count is TWO at every
   width above the phone break. The nth-child border/padding rules are
   COLUMN rules; if the column count ever changes, they change with it. */
.statrow {
  list-style: none;
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  border-top: 1.5px solid var(--color-border);
}
.statrow li {
  padding: var(--space-5) var(--space-5) 0 0;
  border-right: 1px solid var(--color-border-soft);
}
.statrow li + li { padding-left: var(--space-5); }
.statrow li:nth-child(2n) { border-right: 0; }
.statrow li:nth-child(2n+1) { padding-left: 0; }
.statrow li:nth-child(n+3) { border-top: 1px solid var(--color-border-soft); margin-top: var(--space-4); }
/* THE FIGURES REST IN STEEL AND HOVER TO INK (Fahad, 2026-08-21, board
   2026-08-21-stats-motion-demos: option C picked with the colours
   inverted). Steel #3A5069 on the ground tile measures 4.30:1 against the
   tile's DARKEST 1% (luminance byte 187) — above the 3:1 large-text floor
   at these sizes, same doctrine as --hsl-rest, and the prefers-contrast
   block below forces ink. NOTE THE SELECTOR: the note is `.statrow b +
   span`, never bare `.statrow span`, because the figure now carries spans
   of its own (.spre/.sfig/.ssuf) that must inherit the figure's size. */
.statrow b {
  display: inline-block; position: relative;
  font-family: var(--font-display); font-weight: 400;
  letter-spacing: .014em; line-height: 1;
  font-size: clamp(30px, 3.4vw, 44px);
  color: var(--color-primary);
  transition: color .22s ease;
}
.statrow b::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -7px;
  height: 3px; background: var(--color-text);
  transform: scaleX(0); transform-origin: left;
  transition: transform .28s var(--ease-out-strong);
}
.statrow li:hover b { color: var(--color-text); }
.statrow li:hover b::after { transform: scaleX(1); }
.statrow b + span { transition: color .22s ease; }
.statrow li:hover b + span { color: var(--color-text); }
.statrow b .spre { font-size: .55em; letter-spacing: .05em; }
.statrow b + span {
  display: block; margin-top: var(--space-2);
  font-size: var(--text-md); color: var(--color-text-muted); line-height: 1.5;
}

/* THE SLOT ROLL (same ruling). Digit strips exist only while the roll
   runs: main.js builds them when the section scrolls into view, adds
   .roll to the row, and restores the plain figures when it settles, so a
   screenshot at rest and a no-JS visitor both see the real numbers. The
   strip cells are 1em at line-height 1, so --d is a pure count. */
/* font-kerning AND letter-spacing are OFF inside the figure in BOTH
   states: kerning pairs and per-character spacing exist in text but not
   between flex columns, and either one makes the settle swap shift the
   figure sideways (measured: "2016" ran 56.07px as text against 54.38px
   as columns before letter-spacing was zeroed, and Fahad saw the snap
   immediately). Columns are width-locked by main.js to their FINAL digit
   via canvas advances, which only equal the text width while these two
   properties stay neutralised. Wider digits clip symmetrically mid-spin,
   which reads as motion blur at speed. */
.statrow .sfig { display: inline-flex; overflow: hidden; height: 1em; vertical-align: top; font-kerning: none; letter-spacing: 0; }
.statrow .scol { display: inline-block; overflow: hidden; text-align: center; }
.statrow .sstrip { display: block; transform: translateY(0); }
.statrow .sstrip i { display: block; font-style: normal; height: 1em; }
.statrow.roll .sstrip {
  transform: translateY(calc(var(--d) * -1em));
  transition: transform var(--t) cubic-bezier(.16, .84, .26, 1) var(--dl);
}
/* The steel-rest ruling leans on this the way --hsl-rest does: remove it
   and the 4.30:1 figure colour has to be re-argued. */
@media (prefers-contrast: more) {
  .statrow b { color: var(--color-text); }
}

@media (max-width: 900px) {
  .aboutgrid { grid-template-columns: minmax(0, 1fr); gap: var(--space-7); }
  /* The statrow's 2-column rules moved to the base declaration 2026-08-21,
     when it became `.aboutgrid`'s right column and two across stopped being
     the breakpoint case. Nothing statrow-specific is needed here now; the
     520px block below still collapses it to one column. */
}
@media (max-width: 520px) {
  .statrow { grid-template-columns: minmax(0, 1fr); }
  .statrow li { border-right: 0; padding-left: 0; }
  .statrow li + li { padding-left: 0; border-top: 1px solid var(--color-border-soft); margin-top: var(--space-4); }
}

/* THE CURTAIN CHAIN (2026-08-19 night) — NDA's site-wide law, ported.
   Fahad's brief: "the next page becomes the whole page, quite similar
   to how we did with NDA." NDA home.css carries his standing ruling
   (2026-08-07, "all pages need to scroll up"): every beat PINS once
   fully read, and the next one rises over it and becomes the page.
   Mechanism identical to NDA's: position sticky, top = min(0,
   vh − height) so short sections pin at the top of the screen and tall
   ones only after their last line was readable, ascending z-order so
   each new page covers the one before, all JS-SET and kept honest by a
   ResizeObserver per beat (NDA's fix for its one-shot-measure
   frozen-void bug — main.js). Works at every viewport (the formula is
   what makes tall mobile heroes safe). Without JS: sticky never gets a
   top and sections flow exactly as before. Lenis supplies the glide on
   top (the kodeimmersive half of the brief, Build 6.16). */
/* `html.curtain`, NOT `html.js`: the takeover is the home page's alone
   (Fahad, 2026-08-20). main.js adds the class only when it finds a
   `.hero-stage.home`. On the other nine pages none of the four curtain rules
   apply, `--retire` keeps its fallback of 1, no `.beat-dim` is created, and
   the page is an ordinary scrolling document. Do not widen these back to
   `html.js` -- that is what made every service page take itself over. */
html.curtain main > section { position: sticky; }

/* The retire dim (Fahad, 2026-08-19 night: "the following page
   COMPLETELY takes over"): main.js seeds one .beat-dim per beat and
   drives its opacity from real coverage geometry every scroll frame —
   the covered page visibly falls into shadow as the next one rises.
   Lighting, not travel, so it stays under reduced motion (the depth
   doctrine at the end of this file: depth is not motion). z60 sits
   above any in-beat content (the process cards hover at 3). */
/* THE CONTENT FADES WITH THE PAGE (Fahad, 2026-08-20: "nor the page nor
   the heading are fading"). main.js drives --retire from the same coverage
   the shadow uses, so a retiring beat dissolves as the next one takes over
   and its heading dissolves with it.

   IT IS THE CHILDREN, NOT THE SECTION. Fading the section itself would make
   it translucent and show the beat underneath through it, which is a
   different effect and a worse one. The children fade; the section's own
   background stays put, so the page dissolves against its own ground.

   .beat-dim is excluded or it would fade itself out along with everything
   else, which would cancel exactly half the effect.

   DEFAULT 1, AND ONLY UNDER html.js (rule 31). Without JS nothing sets
   --retire, the fallback is 1, and every section is simply at full opacity.
   Under reduced motion it still applies: this is depth, not travel, and
   main.js writes the value straight through with no easing. */
html.curtain main > section > *:not(.beat-dim) { opacity: var(--retire, 1); }

/* THE DIM COVERS THE TITLE TOO, and that is the point (Fahad, 2026-08-20,
   second pass: "I asked the title to fade as well along with the page").

   A lift was tried and reverted the same day. The headings were put at z61,
   one above this layer, so a section fell into shadow around its own title.
   With the oversized Anton heads staying at full black while everything
   under them went dark, the takeover stopped reading as a takeover at all:
   the biggest thing on screen never moved, so the fade looked like it had
   been removed. The whole beat retires together, title included. */
.beat-dim {
  position: absolute; inset: 0; z-index: 60; pointer-events: none;
  background: rgb(10, 15, 23); opacity: 0;
  /* FEATHERED BOTTOM EDGE (Fahad, 2026-08-19: "I can still see the border,
     make it smoother so that it cant be seen").

     The line he photographed was never a border. This layer covers its
     whole beat in a flat rectangle of near-black at up to .55, and it
     stopped dead at the beat's bottom edge. The page rising over it is
     opaque white, so the junction was a step from 55% dark straight to
     white, full width, one pixel tall: a drawn black rule, in effect.
     Removing `border-top` from .title-beat could never have fixed it,
     which is why he still saw it.

     The mask fades this layer out across its lower third, so the darkness
     tapers into the boundary instead of ending on a cut. Where the rising
     page's top edge crosses, the contrast either side of it is now close
     to nil, and there is no edge left to read as a line. The top two
     thirds keep the full retire effect. */
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 62%, transparent 100%);
          mask-image: linear-gradient(to bottom, #000 0%, #000 62%, transparent 100%);
}

/* THE HEADING ARRIVALS ARE GONE (6.26 — Fahad, 2026-08-20: "this heading
   thing just isnt working. Lets drop this whole headers idea and revert
   back to how it was before").

   Removed with them: `.title-beat` / `.arrival` / `.arrival-hold` /
   `.arrival-text`, the whole shutter (`.tbw`, `.tbc*`, `tb-resolve`,
   `tb-sweep-left`, `tb-sweep-right`) and the auto-fit. Section headings
   are plain `h2.sec` in the page again, on the ordinary `.reveal`
   entrance. Six rounds died on this between 2026-08-19 and 2026-08-20 —
   in-place materialize, spotlight overlay, hold + smoother, full-screen
   title pages, transparent section arrivals, and fitted full-screen
   arrivals with a hold. The build log carries all of them under 6.19 to
   6.25. Do not revive any of it without a new ruling.

   WHAT SURVIVES, ruled on separately and kept: the curtain chain, the
   retire dim, the Lenis glide, the scrub, and the footer reveal. */


/* The process stepper (6.20.3): cards wait out of frame — low, soft,
   unfocused — and step in one by one as the visitor scrolls the board
   through the viewport (IO in main.js adds .step-in, once). Gated:
   without JS or with reduced motion every card simply stands. */
@media (prefers-reduced-motion: no-preference) {
  html.js .procard {
    opacity: 0; transform: translateY(70px) scale(.96); filter: blur(6px);
    /* Slowed per Fahad 2026-08-20 (was .7/.8s): the steps land with
       more weight. */
    transition: opacity 1.15s var(--ease-out), filter 1.15s var(--ease-out),
                transform 1.3s cubic-bezier(.22, 1, .36, 1);
  }
  html.js .procard.step-in { opacity: 1; transform: none; filter: none; }
}

/* The nav bar is REMOVED from the top on desktop (same ruling): the
   page owns the whole screen from the first pixel. The bar survives as
   an overlay drawn in by intent — mouse to the top edge, pointer on
   the bar, keyboard focus into it (the a11y path; the skip link lives
   in the bar), or the mobile popover. Phones keep the bar: no hover
   exists there and the burger is the only navigation. */
@media (min-width: 901px) {
  html.js header.site {
    transform: translateY(-100%);
    transition: transform .3s var(--ease-out),
                background var(--dur-large) var(--ease-out),
                box-shadow var(--dur-large) var(--ease-out);
  }
  html.js header.site.nav-open { transform: none; }
}

/* `.dash` used to take a faint sunken tint here. It carries the concrete
   ground now (see `.band`), and this was the second of its two `background`
   shorthands. */

/* ==================================================================
   THE BLACK HAIRLINES, RETIRED (Fahad, 2026-08-19, asked three times:
   "remove the black border line on each page, it should be transparent"
   / "make it smoother so that it cant be seen" / "I still see it")

   THIS is the line he was pointing at, and the first two attempts missed
   it. `--color-border` is pure #000000, and these five banded components
   each open with a 1.5px rule in it. At every page boundary in the
   curtain chain one of them drew a hard black line straight across the
   screen -- exactly the artefact in his screenshot.

   Why it took three rounds: the probe written to hunt for it tested
   `colour.indexOf(', 0)') === -1` to decide "is this opaque", and
   `rgb(0, 0, 0)` ENDS in ', 0)'. Pure black -- the one colour being
   searched for -- was classified as transparent and skipped, so the
   enumeration twice reported "no visible borders" while two of these
   were sitting in plain sight. A test that cannot see the thing it is
   looking for is worse than no test: it actively redirected the search
   onto .title-beat and then .beat-dim, neither of which was at fault.

   Colour only. The 1.5px stays, so no beat height, sticky `top` or
   coverage measurement shifts by a pixel.

   NOTE for the flat-sections ruling: these hairlines were the section
   boundaries for the no-JS and reduced-motion layouts, where sections
   sit flush with no curtain to separate them. Those visitors now lean on
   the background changes alone (.tstm sunken, .facts steel, .process
   white). If that reads as sections running together, the fix is a
   softer rule on THOSE paths only, never the return of pure black here.
   ================================================================== */
/* .svcidx and .certs are ruled top AND bottom, so both edges go. Missing
   the bottom one left a black line on legal-basements.html after the top
   ones were done, which the sweep below caught. */
.svcidx,
.certs  { border-top-color: transparent; border-bottom-color: transparent; }
.process,
.tstm   { border-top-color: transparent; }


/* =====================================================================
   CONTENT HEADINGS — the only place Anton is allowed
   (Fahad, 2026-08-20: "Youve changed the font on everything. This only
   applied to content written inside the website like headings, sub
   headings, descriptions, etc. Not LOGO, call panel and everything else.
   ALSO UNBOLD SUBCOPIES, only make headings or subheadings bold.")

   Anton reaches WRITTEN HEADINGS and nothing else. The logo, the nav, the
   lead dock, buttons, form labels, the footer, the booking sheet and every
   paragraph stay on Instrument Sans, which is what `--font-sans` is for.

   WEIGHT 400 IS NOT A STYLE CHOICE, IT IS THE ONLY WEIGHT ANTON HAS. These
   selectors ask for 600, 700 and 800 in their own rules; against a
   single-weight face the browser answers by SYNTHESISING one, which smears
   an already-black face and is what made everything look over-bold. Forcing
   400 here removes the fake bold. The headings still read bold, because
   Anton is a black face by nature.

   TRACKING IS POSITIVE HERE AND NEGATIVE EVERYWHERE ELSE. The site scale is
   tuned for Instrument Sans, which sets loose and needs pulling in. Anton is
   a true condensed with almost no sidebearing and closes up until the
   letters touch. The two cannot share one value, so Anton carries its own.

   SUB-COPY IS DELIBERATELY ABSENT from this list. Anton has one weight and
   it is black, so "descriptions in Anton" and "unbold descriptions" cannot
   both be true. Descriptions stay Instrument Sans at 400, which is what
   makes the headings above them read as the bold thing on the page.
   ===================================================================== */
.hero-copy h1,
h1.sec,
h2.sec,
h3.sub,
.tile .tcopy b,
.tile.xl .xlcopy > b,
.faq .qa h2,
.faq .qa h3,
.channel h2,
.specs .spec h3,
.spec-group,
.ppanel h3,
.rec b,
.cinfo-text b,
.ccard-form .field label,
.dtitle,
.form h2,
.closeform h3,
.form .sent h3 {
  font-family: var(--font-display);
  font-weight: 400;
  letter-spacing: .014em;
}
/* The three big section heads keep their own size and their own tracking,
   which is tuned for 180px rather than for a 20 to 44px heading. */
.sechead h1.sec, .sechead h2.sec { letter-spacing: .01em; }

/* SUB-COPY, EXPLICITLY UNBOLD. These carried 500 or 600 so they would hold
   up against a lighter body face; beside an Anton heading they only read as
   competing with it. */
.tile .tcopy i,
.tile.xl .xlcopy > i,
.specs .spec p,
.ppanel p,
.rec p,
.dnote,
.channel p {
  font-weight: 400;
}

/* =====================================================================
   THE REVIEW OVERLAY — PREVIEW-ONLY CHROME (2026-08-21, third ruling of
   the feedback round: self-hosted, no third-party tool). A Review toggle
   on every page; on, tapping anything pins a note, General notes takes
   long-form text, everything posts to Fahad. verify() fails any build
   where this outlives the noindex. PINS LIVE ON .rvlayer (fixed, tracks
   targets on scroll) and are NEVER injected into content: extra children
   would shift the site's :nth-child / :nth-of-type chains (statrow
   columns, the Expertise panel pairing). z ladder: tab/general 44 (under
   the dock at 45), pin layer 65, sheet 75 (over the booking sheet's 60).
   ===================================================================== */
.rvtab, .rvgeneral {
  position: fixed; right: 0;
  writing-mode: vertical-rl;
  background: var(--zef-ink); color: #fff;
  font-family: var(--font-sans); font-size: 12px; font-weight: 600;
  letter-spacing: .12em; text-transform: uppercase;
  padding: 14px 9px;
  border: 1px solid rgba(255, 255, 255, .25); border-right: 0;
  border-radius: 3px 0 0 3px;
  z-index: 44; cursor: pointer;
  transition: background-color .2s ease;
}
.rvtab { top: 50%; transform: translateY(-50%); }
.rvgeneral { top: calc(50% + 76px); background: var(--zef-steel); }
.rvtab:hover, .rvtab:focus-visible,
.rvgeneral:hover, .rvgeneral:focus-visible { background: var(--zef-steel-dk, #2C3E52); }
.rvtab[aria-pressed="true"] { background: var(--zef-steel); }
.rvhint {
  position: fixed; left: 50%; bottom: 84px; transform: translateX(-50%);
  z-index: 65; max-width: min(92vw, 480px);
  background: var(--zef-ink); color: #fff;
  font-size: 13px; line-height: 1.45; text-align: center;
  padding: 10px 16px; border-radius: 3px;
}
body.rv-on main, body.rv-on .footer-stage { cursor: crosshair; }
.rvlayer { position: fixed; inset: 0; z-index: 65; pointer-events: none; }
.rvpin {
  position: absolute; width: 22px; height: 22px; margin: -11px 0 0 -11px;
  border-radius: 50%; border: 2.5px solid #fff;
  background: var(--zef-steel);
  box-shadow: 0 1px 4px rgba(0, 0, 0, .35);
  pointer-events: auto; cursor: pointer; padding: 0;
}
.rvpin.rvpin-mine { background: var(--zef-ink); }
.rvsheet {
  position: fixed; z-index: 75;
  background: #fff; border: 1.5px solid var(--zef-ink);
  padding: 18px 20px;
  left: 50%; bottom: 0; transform: translateX(-50%);
  width: min(100vw, 520px);
  box-shadow: 0 -6px 30px rgba(0, 0, 0, .18);
}
@media (min-width: 700px) {
  .rvsheet { left: auto; right: 18px; bottom: 18px; transform: none; width: 400px; }
}
.rvwhere {
  font-size: 12px; color: var(--zef-graphite);
  border-left: 3px solid var(--zef-steel); padding-left: 10px;
  margin-bottom: 12px; line-height: 1.45;
}
.rvlabel { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
.rvsheet textarea {
  width: 100%; font-family: var(--font-sans); font-size: 16px;
  line-height: 1.5; padding: 10px;
  border: 1px solid var(--zef-ink); border-radius: 2px;
}
.rvrow { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
.rvstatus { font-size: 13px; margin-top: 10px; min-height: 1em; }
.rvstatus.ok { color: var(--zef-steel); font-weight: 600; }
.rvstatus.bad { color: var(--zef-graphite); }

/* =====================================================================
   THE PINNED PROCESS — Fahad's scroll-reveal reference, ported 2026-08-24.
   A runway N screens tall, a stage stuck to the top of it, one filling rule
   per step, and the photograph on the right changing as each step takes
   over. See scrollsteps() in generate.py for what the port changes and why.

   EVERYTHING STRUCTURAL IS INSIDE `html.js`. With no JS, and under
   prefers-reduced-motion, this file describes a plain vertical list: step,
   rule, words, photograph, repeat. The pin, the stacking and the fills are
   enhancements on top of that, never the thing that makes it readable.
   ===================================================================== */
.scrollsteps { margin-top: var(--space-6); }
.sslist { list-style: none; margin: 0; padding: 0; }
.ssitem { display: grid; gap: var(--space-3); margin-bottom: var(--space-7); }
.ssitem:last-child { margin-bottom: 0; }
/* ANTON ON EVERY STEP NUMERAL (Fahad, 2026-08-24, board
   `2026-08-24-process-number-fonts`, pick 02). It replaces IBM Plex Mono,
   which he called robotic, everywhere a NUMBER is set -- the pinned process,
   the step rail, the panel ordinal and the labelled-photo dots. Mono keeps
   the LABELS it was chosen for ("WHAT WE DO", the eyebrows, the seals); this
   ruling is about numerals, not about the mono voice.
   Anton is already --font-display and already loaded, so it costs nothing.
   It ships at one weight (400) and is tightly condensed, so numerals want a
   little positive tracking or the digits touch. */
/* CAVEAT ON THE PINNED PROCESS (Fahad, 2026-08-24, second pick from the same
   board). It matches the home page's process board, which has carried
   handwritten Caveat numerals on its five cards since the pinned-board build,
   so the site's two process sections now speak with one voice.
   Anton stays on every other numeral: the section ordinals, the step rail on
   the remaining service pages, the panel ordinal, the labelled-photo dots and
   the basement hub's two doors. */
.ssnum { font-family: var(--font-script); font-weight: 600;
  font-size: 34px; line-height: .9; letter-spacing: 0;
  color: var(--color-primary); }
.sstext h3 { margin-bottom: 6px; }
.sstext p { color: var(--color-text-muted); line-height: 1.7; max-width: 46ch; }
.ssbar { display: none; }
.ssinline img { display: block; width: 100%; height: auto; border-radius: var(--radius-xs); }
.ssmedia { display: none; }

@media (min-width: 901px) {
  /* THE WHEEL IS NEVER TAKEN (Fahad, 2026-08-26, option B off the
     process-scroll-feel demo). This SUPERSEDES the 2026-08-24 ruling that
     the page must not scroll here ("the page length should stay the same").

     WHY THAT RULING WAS REVERSED, measured on the live build: taking the
     wheel meant a trackpad flick fired dozens of events, the handler
     cancelled every one and acted on one per 520ms, discarding the rest,
     while lenis.stop() killed the page's momentum. The photograph crossfaded
     over 450ms inside that same 520ms window, so it was mid-dissolve 87% of
     the time between steps. Both halves of Fahad's complaint -- "the page
     freezes" and "the picture is not clearly visible" -- came from the same
     decision. There is no timing that fixes hijacking the wheel; the fix is
     not to hijack it.

     THE COST IS PAGE LENGTH and it was accepted knowingly: the section runs
     about three screens instead of one. That is the trade the 2026-08-24
     ruling was avoiding, and it cannot be avoided AND have an unhijacked
     scroll. Do not reintroduce a wheel handler here.

     PHONES ARE UNTOUCHED BY ANY OF THIS. Below 901px there has never been a
     stage: the section is a plain vertical list and still is. */
  html.js .ssrunway { position: relative; height: auto; }
  html.js .ssstage {
    position: relative;
    display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: var(--space-8);
    /* CENTRE again. `start` was here because the media column stuck, and a
       sticky child in a centred row collapses. Nothing sticks now, and the
       step list and the photograph are close enough in height that starting
       both at the top left the shorter one hanging. */
    align-items: center;
  }
  /* The inline photograph is the no-JS carrier; with the stage running, the
     media column carries the pictures instead and this one comes off. */
  html.js .ssinline { display: none; }
  /* THE MEDIA COLUMN HOLDS ITS OWN ROW. Its shots are absolutely positioned,
     so without a ratio of its own it computed to ZERO height and the row was
     sized by the step list alone: a taller photograph would have spilled out
     of the grid. 3:2 is the sources' ratio, so this box is the picture. */
  /* THE PHOTOGRAPH NO LONGER PINS (Fahad, 2026-08-28: "I dont like the way
     it moves, revert back to how it was before", on seeing Build 30
     rendered in his own browser -- which is where that build's note said
     the scroll feel would have to be judged).

     WHAT WAS REVERTED IS THE PIN, AND ONLY THE PIN. Build 30 did two
     things: it stopped the section capturing the wheel, and it stuck the
     photograph at 12vh so the steps ran past it. The wheel capture is what
     caused "the page freezes" on 2026-08-24 and it stays gone -- nothing
     here touches wheel events, lenis or preventDefault, and the live step
     is still read off scroll position. Only `position: sticky` came out,
     so the picture now scrolls with its own steps and nothing in the
     section moves independently of the page.

     Restoring the pin is one word (`sticky`) plus a `top`; restoring the
     wheel counter is not, and is a rejected design either way. */
  html.js .ssmedia { display: block; position: relative;
    width: 100%; aspect-ratio: 3 / 2; }
  /* 3:2 IS THE SOURCES' OWN RATIO, so `cover` here crops nothing and all four
     render at one identical size. Do not swap in a taller box without
     re-cutting the photographs: the standing ruling is contained, never
     cropped. */
  html.js .ssshot {
    position: absolute; inset: 0; margin: 0;
    opacity: 0; transition: opacity var(--dur-slow, .45s) var(--ease-out-strong);
  }
  html.js .ssshot.on { opacity: 1; }
  html.js .ssshot img {
    display: block; width: 100%; height: 100%;
    object-fit: cover; border-radius: var(--radius-xs);
  }
  /* THE PER-STEP TRAVEL IS GONE WITH THE PIN (2026-08-28). Each step used to
     own 72vh so that "the step nearest the middle of the screen" resolved to
     exactly one step as the reader scrolled past a pinned photograph. With
     nothing pinned that travel buys nothing and costs the section its
     shape: the picture scrolled away at the top and steps 03 and 04 read
     against an empty right-hand column half the page wide. Photographed
     before this line changed.

     The list is compact again, which is what puts the photograph back
     beside the steps it belongs to and returns the section to roughly one
     screen -- the length Fahad's 2026-08-24 ruling asked for and Build 30
     had to spend. It can be spent again only by pinning something. */
  html.js .sslist { display: block; }
  html.js .ssitem {
    margin: 0 0 var(--space-6); grid-template-columns: 70px 1fr;
    grid-template-areas:
      "num  num"
      "bar  text";
    gap: 0 var(--space-4);
    opacity: .45; transition: opacity var(--dur-hover) var(--ease-out-strong);
  }
  html.js .ssitem:last-child { margin-bottom: 0; }
  html.js .ssitem.live { opacity: 1; }
  html.js .ssitem:not(.live):hover { opacity: .75; }
  /* THE NUMBERS ARE BUTTONS, and that is what keeps this section reachable
     without a wheel: keyboard, screen reader and anyone whose pointer does
     not scroll can step through it directly. A wheel-driven section with no
     other control is a section those users cannot read. */
  html.js .sspick {
    position: absolute; left: 0; top: 0; width: 30px; height: 30px;
    padding: 0; border: 0; background: none; cursor: pointer;
    font: inherit; color: inherit;
  }
  html.js .ssitem { position: relative; }
  html.js .sspick:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 3px; }
  html.js .ssnum { grid-area: num; margin-bottom: var(--space-3); padding-left: 34px; }
  html.js .sstext { grid-area: text; }
  /* The rule: a full-height track with a fill that grows as the step runs.
     `--f` is written per item by main.js, 0 to 1. */
  html.js .ssbar {
    grid-area: bar; display: block; position: relative;
    width: 2px; justify-self: center; min-height: 64px;
    background: var(--color-border-soft);
  }
  html.js .ssfill {
    position: absolute; top: 0; left: 0; width: 100%;
    height: calc(var(--f, 0) * 100%); background: var(--color-primary);
  }
}

/* Pinning the viewport for four screens is precisely what this preference
   exists to switch off. The list is the whole content, so nothing is lost. */
@media (prefers-reduced-motion: reduce) {
  html.js .ssrunway { height: auto; }
  html.js .ssstage { position: static; height: auto; display: block; }
  html.js .ssmedia { display: none; }
  html.js .ssinline { display: block; }
  html.js .ssitem { opacity: 1; display: grid; grid-template-columns: 1fr;
    grid-template-areas: "num" "text" ; margin-bottom: var(--space-7); }
  html.js .ssbar { display: none; }
}

/* THE NAMED AREAS — controls, not cards (Fahad, 2026-08-24, option D from
   the What-We-Handle layout board).

   They replaced a 2x2 grid of same-size heading-plus-text boxes. Two things
   were wrong with that: four equal boxes doing a section's structural work is
   the most recognisable generated-site tell, and the four areas repeated four
   of the photograph's own nine labels with nothing drawn between them. Now
   only the selected area is written out, and choosing one LIGHTS ITS POINTS
   on the house and dims the rest, so the two halves are one system.

   PROGRESSIVE, on the `.steptabs` contract: without JS the strip is hidden
   and every panel stands open, which is four headed paragraphs beside a
   labelled photograph. Nothing is hidden that was not already readable. */
.areas { margin-top: var(--space-6); }
.arealist { display: none; }
html.js .arealist {
  display: flex; flex-wrap: wrap; gap: 0 var(--space-5);
  border-bottom: 1px solid var(--color-border-soft); margin-bottom: var(--space-5);
}
.areatab {
  background: none; border: 0; border-bottom: 3px solid transparent;
  padding: 12px 0; margin-bottom: -1px; cursor: pointer;
  /* INK AT 700, NOT MUTED AT 600 (Fahad, 2026-08-24: "make the headings on
     this section darker, more bold so that customer can easily see it").
     These are the section's navigation: an unselected one still has to be
     read and clicked, so muted grey was the wrong resting state. The
     SELECTED one is separated by the accent rule and the accent colour, not
     by being the only legible item in the row. */
  font-family: var(--font-sans); font-size: var(--text-base);
  font-weight: var(--weight-bold); letter-spacing: -.005em;
  color: var(--color-text);
  transition: color var(--dur-hover) var(--ease-out-strong),
              border-color var(--dur-hover) var(--ease-out-strong);
}
.areatab:hover { color: var(--color-primary); }
.areatab.on { color: var(--color-primary); border-bottom-color: var(--color-primary); }
.areatab:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 3px; }
.areapanel { margin-bottom: var(--space-5); }
.areapanel:last-child { margin-bottom: 0; }
.areapanel h3 { font-size: var(--text-xl); margin-bottom: 6px; }
.areapanel p { color: var(--color-text-muted); line-height: 1.7; }
/* ALL FOUR PANELS SHARE ONE GRID CELL, so the block is always as tall as the
   LONGEST of them and switching areas cannot reflow the column. `display:
   none` per panel would have let the section change height on every click,
   which also re-centres the photograph beside it. `visibility: hidden` keeps
   the hidden ones out of the accessible tree and out of tab order while they
   still hold the space. */
html.js .areapanels { display: grid; }
html.js .areapanel { grid-area: 1 / 1; visibility: hidden; }
html.js .areapanel.on { visibility: visible; }
/* With the strip running, the panel's own heading repeats the tab that is
   already lit directly above it. */
html.js .areapanel h3 { position: absolute; width: 1px; height: 1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }

/* THE PHOTOGRAPH ANSWERS by LIGHTING the area's own points, never by fading
   the others (Fahad, 2026-08-24: "the buttons are faded and do not match the
   other pages"). A 30% dim made seven of the nine chips read as broken next
   to kitchens and washrooms, where every chip is a crisp white plate. So an
   unselected chip here is now IDENTICAL to an unselected chip anywhere else,
   and the relationship is carried entirely by the filled ones. */
.lphmark.hot .lphdot {
  background: var(--color-primary); border-color: var(--color-primary); color: #fff;
  transform: scale(1.18);
}
.lphdot { transition: background var(--dur-hover) var(--ease-out-strong),
                      color var(--dur-hover) var(--ease-out-strong),
                      border-color var(--dur-hover) var(--ease-out-strong),
                      transform var(--dur-hover) var(--ease-out-strong); }
.lphmark { transition: opacity var(--dur-hover) var(--ease-out-strong); }
@media (prefers-reduced-motion: reduce) {
  .lphmark.hot .lphdot { transform: none; }
}
/* A standing sentence with no heading. Set larger than body copy because it
   is the section, not a paragraph inside one. */
.statement {
  font-size: var(--text-xl); line-height: 1.55; max-width: 60ch;
  color: var(--color-text);
}

/* =====================================================================
   PHONE TAP TARGETS (2026-08-31, launch pass)

   The 44px floor, applied to the three controls that were genuinely under it
   once the audit stopped counting phantoms. It had been reporting ~130
   failures a run, all of them links inside the CLOSED mobile menu, which sits
   at `scale(.98)` at rest -- so a 44px row measured 43.12 and every page
   looked like it had a dozen problems. The driver now divides out ancestor
   scale and reads `::after` hit areas, and what is left is this.

   THE FOOTER PHONE NUMBER IS THE ONE THAT MATTERED. It was 15px tall: the
   single most valuable link on a contractor's site, at a third of the minimum
   target. The lead dock carries a Call button on every page so it was never
   the only route, but "there is another way to do it" is not a reason to ship
   a control nobody can hit.
   ===================================================================== */
@media (max-width: 900px) {
  /* Contact links in the footer: padded to the floor rather than made bigger,
     so the type size and the footer's rhythm are untouched. */
  footer.site .inner a[href^="tel:"],
  footer.site .inner a[href^="mailto:"] {
    display: inline-flex; align-items: center; min-height: 44px;
  }
  /* Both were a hair under: the area tabs at 43px and the Expertise rows at
     38px. Neither moves the layout perceptibly at these deltas. */
  .areatab { min-height: 44px; }
  .hsl-link { min-height: 44px; display: flex; align-items: center; }

  /* THE FOOTER NAV ROWS WERE TALL ENOUGH AND TOO NARROW. Each was already
     49px high, so the miss was horizontal only: "FAQ" gave a 25px-wide
     target because an inline-block link is exactly as wide as its word.
     `display: block` makes the whole row tappable without moving a pixel of
     what is drawn -- the text stays where it is, the target grows to meet it.
     A short word should not be a harder link to hit than a long one. */
  footer.site .inner ul a { display: block; }
}
