/* =============================================================================
   Pinnacle — site styles.

   RULES THIS FILE OBEYS, AND THE LINTER ENFORCES:
   · No raw colour, size, space, radius or z-index value. Only var(--token)
     from tokens.css (§3.8, §8.9).
   · Mobile-first. Every media query is min-width, and the only permitted
     widths are 480 / 768 / 1024 / 1280 — the four literals in §8.13, which are
     the sole exception to the rule above because custom properties are invalid
     inside a media query condition.
   · Cascade layers, not specificity wars. Forcing priority to beat WooCommerce
     is a bug — vendor CSS goes in the `vendor` layer instead (§8.14).
   · Logical properties over left/right. Native nesting one level deep, max.
   ========================================================================== */

@layer reset, vendor, base, layout, components, utilities;

/* =============================================================================
   RESET
   ========================================================================== */
@layer reset {

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

  * { margin: 0; }

  html {
    /* THIS IS WHAT PROTECTS THE NO-DARK-MODE DECISION (§8.2, §8.14). Without
       it, Chrome on Android can force-darken the page and re-render every
       product photo's colour — the one thing a flooring catalogue cannot
       allow. */
    color-scheme: only light;
    -webkit-text-size-adjust: 100%;
    scroll-behavior: smooth; /* honoured against reduced-motion in tokens.css */
    /* Reserves the scrollbar's own width whether or not it is currently
       scrollable, so locking scroll for a dialog (main.js, catalogue.js)
       never shifts the page a scrollbar-width sideways or leaves a second,
       inactive track visible behind the modal's own. Progressive: browsers
       without support keep the pre-existing (smaller) jump, not a broken
       layout. */
    scrollbar-gutter: stable;
  }

  body {
    min-height: 100vh;
    -webkit-font-smoothing: antialiased;
  }

  img, picture, video, canvas {
    display: block;
    max-inline-size: 100%;
    block-size: auto;
  }

  /* SVG IS DELIBERATELY NOT IN THAT RULE. `block-size: auto` is a CSS
     declaration and therefore beats the `height` attribute — so every inline
     icon lost the size icons.php asked for and collapsed to a speck. Raster
     media needs `block-size: auto` to stay in proportion when `max-inline-size`
     clamps it; an inline SVG carries width AND height and must keep both. */
  svg {
    display: block;
    max-inline-size: 100%;
  }

  input, button, textarea, select { font: inherit; color: inherit; }

  /* `::placeholder` IS A SEPARATE PSEUDO-ELEMENT, not matched by the rule
     above — it does not inherit its originating input's font by default in
     every engine, so without an explicit rule here every placeholder on
     the site (every `.field input`/`textarea` across every form) fell back
     to the browser's own UA-stylesheet font for placeholder text rather
     than --font-body, most visibly on WebKit/iOS. `font: inherit` pulls
     the input's own already-correct computed font (size, weight, family);
     colour is set separately since a placeholder still needs the --ink-500
     meta colour (§8.2, §8.12), not the input's actual (inherited) text
     colour. Replaces the two narrower, colour-only versions of this rule
     that used to live on `.search-panel__field` and `.mobile-nav__search
     input` — same value, now one definition instead of three. */
  /* SIZE IS PINNED, THE REST IS INHERITED, and the split is the whole point.
     `font: inherit` above pulls the input's computed size too — which on touch
     is --fs-input (16px), because iOS zooms the page when a focused input's
     text is under 16px and never zooms back (§8.3). That floor is about the
     text a visitor TYPES; it has nothing to do with the placeholder, which iOS
     does not measure. Inheriting it anyway made every placeholder on mobile
     render at 16px while the same field on a desktop pointer showed 12px.
     Overriding just the size keeps the family, weight and line-height coming
     from the input (which is what the rule above exists for) and puts the
     placeholder at the 12px UI baseline everywhere. Shrinking the INPUT to get
     the same look is the trap: it reads identically on a desktop browser and
     reintroduces the zoom bug on every real phone. */
  input::placeholder, textarea::placeholder {
    font: inherit;
    font-size: var(--fs-small);
    color: var(--ink-500);
  }

  p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; }

  ul[class], ol[class] { list-style: none; padding-inline-start: 0; }

  :target { scroll-margin-block-start: var(--chrome-h); }
}

/* =============================================================================
   BASE — element defaults
   ========================================================================== */
@layer base {

  :root {
    /* Fixed top chrome: header, plus the notice bar when it is present. Every
       anchor target uses this as scroll-margin, or in-page links land beneath
       the header (§8.13). Never exceeds 120px on mobile. */
    --notice-h: 0px;
    --chrome-h: calc(var(--header-h) + var(--notice-h));
  }

  body {
    background-color: var(--bg);
    color: var(--ink-700);
    font-family: var(--font-body);
    font-size: var(--fs-body);   /* 12px base, 14px from --bp-lg (§8.3) */
    font-weight: var(--fw-body);
    line-height: var(--lh-body);
  }

  /* ---- Image skeleton loading state (component states, §8.15) ----------
     Every lazy-loaded image on the site (product cards, galleries, review
     avatars -- anywhere wp_get_attachment_image() was called with
     loading="lazy") shows a shimmering placeholder instead of blank space
     until it decodes, sized by its own width/height attributes so nothing
     shifts when the shimmer is replaced by the photo. The LCP image on each
     page is deliberately never loading="lazy" (§8.13), so it never carries
     this and never waits behind it.

     `.is-loaded` is added by main.js on the image's load/error event; an
     image that is already complete when the script runs (bfcache, an
     instant cache hit) gets the class immediately instead of shimmering
     forever. Progressive-only: with JS disabled the attribute selector still
     matches and the image simply keeps its placeholder background until the
     browser paints the photo over it. */
  img[loading="lazy"]:not(.is-loaded) {
    background-image: linear-gradient(
      100deg,
      var(--bg-tint) 30%,
      var(--bg-alt) 55%,
      var(--bg-tint) 80%
    );
    background-size: 200% 100%;
    background-position: 200% 0;
    animation: pinnacle-skeleton var(--dur-shimmer) ease-in-out infinite;
  }

  @keyframes pinnacle-skeleton {
    to { background-position: -200% 0; }
  }

  /* NEVER PIN "wght" IN `font-variation-settings` — use `font-weight`.
     `font-variation-settings` is an INHERITED property and it OVERRIDES
     `font-weight` on the axis it names. A `"wght" 400` on `body` therefore
     cascades into every descendant and silently freezes the whole site at 400:
     every rule that asks for --fw-ui (500), --fw-subhead (600) or --fw-display
     (700) without ALSO restating the axis renders regular, and the stylesheet
     reads correct while the page does not. The product card is where it showed
     — the title is an <h3> and got its weight from the h3 rule's own pin, while
     the price beside it asked for 600 through `font-weight` and drew at 400,
     which is why the price looked like a different face from the theme (§5.7).
     `font-weight` sets the same axis, is not inherited past what it should be,
     and needs no per-rule duplication. Variation settings below pin ONLY the
     axes `font-weight` cannot reach: Bricolage's `wdth` and `opsz` (§8.3). */

  h1, h2, h3, h4 {
    color: var(--ink-900);
    line-height: var(--lh-heading);
    text-wrap: balance;
  }

  /* Bricolage is DISPLAY / H1 / H2 ONLY (§8.3). Three axes, ALL PINNED — the
     unpinned defaults do not match the specimen.

     `opsz` is an OPTICAL size axis: it is not a size, it tells the font which
     drawing to use at the size it is about to be rendered, thinning the strokes
     and tightening the spacing as it climbs. Pinning it to one value across
     both headings (it was 24 for everything) drew the H1 with the H2's
     letterforms. Each now carries roughly its own rendered size. */
  h1, h2 {
    font-family: var(--font-display);
    font-weight: var(--fw-display);
    letter-spacing: -0.01em;
  }

  h1 {
    font-size: var(--fs-display);
    line-height: var(--lh-display);
    font-variation-settings: "wdth" 100, "opsz" 30;
    letter-spacing: -0.015em;
  }

  h2 {
    font-size: var(--fs-h2);
    font-variation-settings: "wdth" 100, "opsz" 20;
  }

  /* ---- The section-heading role (§8.3) -------------------------------------
     A top-level section heading on a public page. It is an h2 semantically -
     the page has one h1 - but it takes --fs-section rather than --fs-h2,
     which is tuned for a heading inside a column of text and does not hold
     its own beside photography.

     It was briefly on --fs-display instead. That read as too loud: 32px is
     the h1 size, so on interior pages the section headings matched the page
     title outright, and a heading that repeats six or seven times down a page
     does not want the same weight as the one that names it.

     ONE RULE, NOT A DECLARATION PER COMPONENT. Six components had each grown
     their own copy of this (room tiles, home categories, before/after, the hub
     sections, destination sections, the blog featured title) while nine more
     peer headings never got one and silently fell through to --fs-h2. That is
     how the home page ended up rendering eight sibling section headings at two
     different sizes with no hierarchy to justify the split. Adding the copy to
     the other nine would have made fifteen copies of the same three lines, so
     the role is declared once here and the components keep only their margins.

     Only two declarations are needed: `h1, h2` above already supplies
     --font-display and --fw-display, so the per-component font-family and
     font-weight were restating what they already had.

     `opsz` is pinned to 25, between h2's 20 and h1's 30. It is an OPTICAL size
     axis, so it has to track the size the text is ACTUALLY rendered at, and
     --fs-section lands between the two. Left at the h2 default of 20 - which
     is what the six older per-component copies did, none of them setting the
     axis - the heading is drawn with letterforms meant for a smaller size,
     the same mismatch the h1/h2 comment above describes. The ladder now runs
     20 · 25 · 30 against 23 · 27 · 32. */
  .blog-featured__title,
  .category-cards__heading,
  .contact-close__heading,
  .contact-message__heading,
  .destination-section__heading,
  .faqs__heading,
  .home-appointment__heading,
  .home-before-after__title,
  .home-brands__heading,
  .home-categories__heading,
  .home-cities__heading,
  .home-faq__heading,
  .hub-section__title,
  .next-steps__heading,
  .process__heading,
  .projects-section__heading,
  .rail__heading,
  .room-tiles__heading {
    font-size: var(--fs-section);
    line-height: var(--lh-display);
    font-variation-settings: "wdth" 100, "opsz" 25;
  }

  /* H3 and below: Outfit. The irregularity is an asset at 44px and a liability
     at 17px in a spec box. */
  h3 {
    font-family: var(--font-body);
    font-size: var(--fs-h3);
    font-weight: var(--fw-subhead);
    letter-spacing: -0.005em;
  }

  /* H4 is body-size by design, separated by weight AND colour (§8.9). Weight
     alone was not enough separation once the sizes tightened. */
  h4 {
    font-family: var(--font-body);
    font-size: var(--fs-h4);
    font-weight: var(--fw-subhead);
  }

  /* SALE PRICES: WooCommerce renders them as <del> original + <ins> current,
     and the reset that normally tames those ships in woocommerce-general.css,
     which is dequeued for a catalogue that doesn't sell (§8.17). Without it the
     UA default underlines every current price — a treatment that appears
     nowhere else in the system, on the one number a visitor is deciding on.
     Both elements are aria-hidden in the markup, with screen-reader-text
     carrying the spoken version, so this is purely visual (§5.3, §5.7). */
  del {
    color: var(--ink-500);
    font-weight: var(--fw-body);
  }

  ins {
    text-decoration: none;
    background: none;
    color: inherit;
  }

  a { color: inherit; text-underline-offset: 0.2em; }

  /* Icons are solid marks filled with currentColor (icons.php), so they take
     their colour from whatever text they sit beside and need no colour rule of
     their own. `flex: none` keeps one from being squeezed to a sliver inside a
     tight CTA bar; the baseline nudge stops it riding high against 17px text. */
  .pinnacle-icon {
    display: inline-block;
    flex: none;
    vertical-align: -0.125em;
  }

  /* …EXCEPT INSIDE AN ICON-ONLY CONTROL, WHERE THAT NUDGE IS THE BUG.
   *
   * `vertical-align: -0.125em` sits an icon on a text baseline, which is right
   * when it runs beside a label. But these buttons wrap their mark in a <span>
   * — and that span is an inline box, so it builds a LINE BOX around the SVG:
   * the span ends up taller than the icon, the extra sits under it, and the
   * mark rides high inside a button that is itself perfectly centred. The
   * hamburger looked a couple of pixels off vertical against the header for
   * exactly this reason, and every other icon-only control had it too.
   *
   * Making the wrapper a grid removes the line box, so `place-items: center`
   * on the button actually centres the glyph rather than a text box containing
   * it. `line-height: 0` covers the case where the wrapper is missing and the
   * SVG is a direct child. */
  .nav-trigger__icon,
  .mobile-nav__close > [aria-hidden],
  .mobile-nav__search button > [aria-hidden],
  .notice-bar__close > [aria-hidden],
  .primary-nav__caret > [aria-hidden],
  .rail__arrow > [aria-hidden],
  .search-toggle > [aria-hidden],
  .floating-cta__icon {
    display: grid;
    place-items: center;
    line-height: 0;
  }

  /* FOCUS RING (§8.6). :focus-visible, not :focus — the latter also rings a
     button clicked with a mouse and reads as a stuck state. This is the ONE
     sanctioned decorative use of --accent, exempt from the scarcity rule in
     §8.2, because accessibility outranks the aesthetic. AODA applies (§6.10):
     removing the outline without a replacement is a bug, not a style. */
  :focus-visible {
    outline: var(--focus-ring);
    outline-offset: var(--focus-offset);
    border-radius: var(--r-sm);
  }

  /* GREY, NOT THE ACCENT TINT: double-click or drag-select text and the
     highlight is a plain neutral fill, not a wash of the maple-red accent
     the catalogue reserves for the one primary action per viewport (§8.2). */
  ::selection { background-color: var(--bg-alt); }

  /* The site-wide rule above leaves TEXT COLOUR alone, which is correct on
     --bg (a light fill under whatever ink colour the text already has) but
     breaks inside the estimator's dark card: --bg-alt is still a LIGHT grey,
     and --ink-on-dark text is white, so selecting it read as white-on-
     near-white — the exact bug this fixes. Same "neutral grey fill, not
     --accent" idiom as the rule above, just with both ends of its
     light/dark relationship flipped for a dark surface. */
  .estimator ::selection {
    background-color: var(--ink-on-dark-muted);
    color: var(--ink-900);
  }

  hr { border: 0; border-block-start: 1px solid var(--line); }

  small { font-size: var(--fs-small); }


}

/* =============================================================================
   LAYOUT
   ========================================================================== */
@layer layout {

  .container {
    inline-size: 100%;
    max-inline-size: var(--w-max);
    margin-inline: auto;
    padding-inline: var(--gutter-x);
  }

  /* Prose blocks — blog, city pages, FAQ — cap at ~60–70ch (§8.3, §8.4). */
  .prose {
    max-inline-size: var(--w-prose);
  }

  .prose > * + * { margin-block-start: var(--s-6); }
  .prose h2 { margin-block-start: var(--s-10); }
  .prose h3 { margin-block-start: var(--s-8); }
  .prose ul, .prose ol { padding-inline-start: var(--s-6); }
  .prose li + li { margin-block-start: var(--s-2); }

  .prose a {
    color: var(--ink-900);
    text-decoration: underline;
    text-decoration-color: var(--line);
  }
  .prose a:hover { text-decoration-color: var(--ink-500); }

  /* NO PADDING HERE, DELIBERATELY (§8.18 #4). Every section owns its own
     complete block-start AND block-end rhythm now — see the comment on
     .destination-section for the rest of that story — so a page's closing
     space comes from whichever section happens to render last, not from a
     shared compensator here. A second bottom padding on the wrapper around
     a section that already supplies its own would stack into a double
     gap at the exact spot §8.4 exists to prevent. */
  .site-main {
    display: block;
  }

  /* Adjacent major sections: a hairline, not empty page, is what says
     "new topic" (§8.4, §8.6). --line alone is 1.24:1 on --bg and disappears
     the way the breadcrumb separators did; mix it with --ink-500 so the
     rule stays in the token set and still reads. Recessed bands (--bg-alt)
     keep their fill and no longer paint their own block borders, so two
     rules never stack.

     Heroes are the first child, so this selector never fires on the hero
     itself — nothing should draw above the very first section on a page —
     but it fires normally on whatever comes right after one, the same as
     any other section-to-section boundary. */
  /* SCOPED TO .site-main, not just the wrapper classes below — .home and
     .page are also what WordPress's own body_class() puts on <body> for the
     front page and every static Page, and an unscoped selector matches BOTH
     the intended content wrapper and body's own direct children. That
     silently drew this hairline across <header>, <main>, <footer> and the
     floating-CTA <div> on every page, since each has a preceding sibling
     (the skip link, at minimum) once you're looking at body's children. */
  .site-main .catalogue > * + *,
  .site-main .destination-page > * + *,
  .site-main .projects-page > * + *,
  .site-main .page > * + *,
  .site-main .post-single > * + * {
    border-block-start: 1px solid color-mix(in srgb, var(--ink-500) 30%, var(--line));
  }

  /* Home is a ruled grid composition. Every direct section boundary uses the
     same canonical dark hairline so a generic grey separator can never sit
     against a component's black frame and read as a duplicate rule. Child
     grids still own their internal rules; this selector owns only the single
     boundary between adjacent home sections. */
  .site-main .home > * + * {
    border-block-start: 1px solid var(--mega-media-border);
  }

  /* .CATALOGUE__MAIN MANAGES ITS OWN TOP BOUNDARY AND MUST NEVER DOUBLE UP
     WITH THE GENERIC SIBLING RULE ABOVE. Its toolbar already carries a
     deliberate margin/rule pairing (§5.5) tuned against whatever sits right
     before it — the "Narrow it down" row zeroes that margin when it is the
     immediate previous sibling (below), and the projects rail supplies its
     own padding when IT is. A second hairline landing on .catalogue__main's
     own block-start, on top of either of those, is what read as a stray line
     with broken spacing directly under "Narrow it down": the toolbar's
     margin was correctly zeroed, but the rule from THIS block still painted,
     so the heading sat hard against a line that was never supposed to be
     there. */
  .catalogue > .catalogue__main {
    border-block-start: 0;
  }

  /* .CATALOGUE__MAIN CLOSES THE PAGE ITSELF, rather than any one child
     owning it: what renders last varies (the grid, the zero-results empty
     state, or the category FAQs — §6.7), and none of them is guaranteed to
     be present. .site-main no longer supplies a shared page-level bottom
     padding (see its own rule), so the wrapper carries it here instead. */
  .catalogue__main { padding-block-end: var(--section-y); }

  /* Recessed bands separate by fill, not a second hairline (§8.2). The band
     itself skips the rule, and so does whatever follows it: the fill's own
     edge IS the boundary, and a hairline drawn against it reads as a seam. */
  .destination-page > .destination-section--band,
  .destination-page > .destination-section--band + * {
    border-block-start: 0;
  }

  /* The generic page-hero's plain-title fallback and every other hero-family
     header were both retired once template-parts/page-hero.php learned to
     render the placeholder cutout shape with no image, too (§8.9) — every
     page except Home now carries that one shape. .page-head__description
     survives on its own: it is still the content-lead class on the
     Delivered Projects page (page-templates/delivered-projects.php),
     unrelated to any header wrapper now. */
  .page-head__description {
    margin-block-start: var(--s-4);
    max-inline-size: var(--w-prose);
    color: var(--ink-500);
  }

  /* The page gutter, the card padding and the section rhythms all step up at
     their breakpoints in ONE place — the swap block at the end of tokens.css.
     `.container` reads `--gutter-x` and `.site-main` reads `--section-y`, so
     both follow without a rule here. */
}

/* =============================================================================
   COMPONENTS
   ========================================================================== */
@layer components {

  /* ---- Screen-reader utilities (§8.16) ------------------------------------ */

  .screen-reader-text {
    position: absolute;
    inline-size: 1px;
    block-size: 1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
  }

  /* ---- Buttons (§8.5, §8.7, §8.15) ------------------------------------- */

  .btn {
    position: relative;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: var(--s-2);
    /* THE PAINTED BOX IS --btn-h AT EVERY POINTER TYPE — 32px on a phone and
       on a laptop alike, so a button never reads as more padded on touch. The
       40px touch target is restored by the transparent ::after below, which
       costs no visual weight (§8.4, tokens.css). Fields, selects and the header
       search still read --field-h directly: an input has to hold 16px of text
       on touch (§8.3), so its box genuinely needs the height a button does
       not. */
    min-block-size: var(--btn-h);
    padding-inline: var(--s-4);
    padding-block: var(--s-1);
    border: 1px solid transparent;
    border-radius: 0;
    font-size: var(--fs-small);
    font-weight: var(--fw-ui);
    line-height: var(--lh-small);
    text-align: center;
    text-decoration: none;
    cursor: pointer;
    transition:
      background-color var(--dur-fast) var(--ease),
      border-color var(--dur-fast) var(--ease),
      transform var(--dur-fast) var(--ease);
  }

  /* THE TARGET IS BIGGER THAN THE BOX ON TOUCH, never the reverse. This
     extends the hit area to --field-h without moving a pixel of paint, so a
     40px target sits under a 32px button. On a fine pointer --field-h IS
     --btn-h, the calc resolves to 0 and this does nothing — which is why it
     needs no capability query of its own.

     It replaces a `padding-block: 0` override that had been sitting in a
     fine-pointer query doing nothing at all: min-block-size already dominated
     the height of every single-line button, so shaving 4px of padding off it
     changed no rendered pixel. */
  .btn::after {
    content: "";
    position: absolute;
    inset-inline: 0;
    inset-block: calc( ( var(--field-h) - var(--btn-h) ) / -2 );
  }

  /* --accent appears on EXACTLY ONE element per viewport: the primary action
     (§8.2). If a screen seems to need two, one of them is not the primary
     action — demote it to .btn--ghost. */
  .btn--primary {
    background-color: var(--accent);
    color: var(--surface);
  }
  .btn--primary:hover {
    background-color: var(--accent-hover);
    transform: translateY(var(--lift)); /* the 2px lift, §8.7 */
  }

  .btn--ghost {
    background-color: transparent;
    border-color: var(--line);
    color: var(--ink-900);
  }
  .btn--ghost:hover {
    border-color: var(--ink-500);
    transform: translateY(var(--lift));
  }

  .btn--block { inline-size: 100%; }

  /* Disabled is not invisible — 3:1 contrast held, or it reads as a broken
     page (§8.15). */
  .btn[disabled],
  .btn[aria-disabled="true"] {
    color: var(--ink-500);
    background-color: var(--bg-alt);
    border-color: var(--line);
    cursor: not-allowed;
    transform: none;
  }

  /* ---- Notice bar (§9.6) ------------------------------------------------ */

  .notice-bar {
    position: relative;
    z-index: var(--z-notice);
    background-color: var(--bg-alt);
    border-block-end: 1px solid var(--ink-900);
    font-size: var(--fs-small);
  }

  /* Dismissal is resolved before paint (see notice-bar.php), so a returning
     visitor gets no flash and no layout shift (§8.17). */
  .notice-dismissed .notice-bar { display: none; }

  .notice-bar__inner {
    display: flex;
    align-items: center;
    gap: var(--s-4);
    padding-block: var(--s-3);
  }

  .notice-bar__message {
    flex: 1 1 auto;
    /* Max two lines at 360px — if it doesn't fit, the message is too long
       (§8.13, §3.11's counter). */
    display: -webkit-box;
    -webkit-line-clamp: 2;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }

  .notice-bar__link {
    text-decoration: underline;
    font-weight: var(--fw-ui);
  }

  .notice-bar__countdown {
    flex: 0 0 auto;
    color: var(--ink-500);
    font-variant-numeric: tabular-nums;
  }

  .notice-bar__dismiss {
    flex: 0 0 auto;
    display: grid;
    place-items: center;
    inline-size: var(--tap-min);
    block-size: var(--tap-min);
    background: none;
    border: 0;
    color: var(--ink-700);
    cursor: pointer;
    border-radius: var(--r-sm);
  }
  .notice-bar__dismiss:hover { background-color: var(--surface); }

  /* ---- Site header (§9.2) ---------------------------------------------- */

  .site-header {
    position: sticky;
    inset-block-start: 0;
    z-index: var(--z-sticky);
    background-color: var(--bg);
    box-shadow: var(--shadow-sm);
    transition: transform var(--dur-base) var(--ease);
  }

  /* Scroll-driven fold (main.js): hidden by translating up rather than by
     display/visibility, so the sticky element stays in flow and the same
     transition plays coming back down on scroll-up. */
  .site-header.is-hidden {
    transform: translateY(-100%);
  }

  /* Wider than --w-max: the header is chrome carrying more in one row than any
     content block, so it gets --w-header (§8.4). It still uses .container for
     its gutters and centring — only the cap differs. */
  .site-header__inner {
    display: flex;
    align-items: center;
    gap: var(--s-4);
    min-block-size: var(--header-h);
    max-inline-size: var(--w-header);
  }

  .site-header__actions {
    display: flex;
    align-items: center;
    gap: var(--s-3);
    margin-inline-start: auto;
  }

  /* Below --bp-lg the inline nav, the search toggle and the header CTA are all
     replaced by the mobile panel. display:none removes them from the tab order
     entirely, so the menu is reachable in exactly ONE tab order (§9.2) — and
     there is never a second search field behind the mobile panel's own.

     THE SEARCH PANEL ITSELF IS NOT IN THIS LIST, deliberately. `[popover]` is
     kept hidden by a USER-AGENT display rule, so any author `display:` on it —
     in either direction — beats that rule: `display:none` here would need a
     `display:block` at --bp-lg to undo it, and that one would pin the panel
     permanently OPEN. It is hidden below --bp-lg by having no reachable
     invoker, which is the toggle above (§8.14's `[hidden]` note, same trap). */
  .primary-nav,
  .search-toggle,
  .site-header__cta { display: none; }

  .nav-trigger {
    display: grid;
    place-items: center;
    inline-size: var(--s-10);   /* 48px */
    block-size: var(--s-10);
    background: none;
    border: 0;
    color: var(--ink-900);
    cursor: pointer;
    border-radius: var(--r-sm);
  }
  .nav-trigger:hover { background-color: var(--bg-alt); }

  /* ---- Logo (§9.9) ------------------------------------------------------ */

  .site-logo {
    display: inline-flex;
    align-items: center;
    text-decoration: none;
    color: var(--ink-900);
  }

  /* The Summit icon (§9.9) — ahead of the wordmark in both the asset-backed
     and text-fallback lockups. currentColor only: it inherits --ink-900 from
     .site-logo and must never carry --accent, which the header already
     spends on the Book CTA (§8.2's one-accent-per-viewport rule). Sized off
     the 4px space scale rather than a bespoke value (§8.4) — one size at
     every breakpoint, since (unlike the wordmark) it carries no two-line
     hierarchy to swap. */
  .site-logo__icon {
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    inline-size: var(--s-8);
    block-size: var(--s-8);
    margin-inline-end: var(--s-2);
    color: inherit;
  }
  .site-logo__icon svg { inline-size: 100%; block-size: 100%; }

  .site-logo__text {
    display: flex;
    flex-direction: column;
    line-height: 1;
  }

  .site-logo__wordmark {
    font-family: var(--font-display);
    font-size: var(--fs-logo);
    font-weight: var(--fw-display);
    /* opsz tracks the RENDERED size — at 24 the wordmark would be drawn with a
       24px drawing at 18px, which is the H1-with-H2-letterforms mistake the
       heading rules already avoid (§8.3). */
    font-variation-settings: "wdth" 100, "opsz" 20;
    letter-spacing: -0.01em;
  }

  /* THE FULL LOCKUP AT EVERY WIDTH, including mobile (§9.9).
     The old rule hid this below --bp-lg, on the argument that two lines inside
     a 56px header force the descender down to 9–10px. That is true when ONE
     asset is scaled to fit — it is not true when the two lines are typeset
     independently: 18px over 10px is 28px of the ~36px available. The footer
     logo has no height constraint at all, which is where the missing line was
     most obviously wrong.

     THE LOCKUP RUNS ON ITS OWN TWO TOKENS, not on the content scale — see
     tokens.css for why, including why the caption sits below --fs-micro and why
     nothing else may. THE SIZE GAP IS THE HIERARCHY: 1.8x, so the wordmark
     reads as the name and the line under it as a descriptor, never as two lines
     of one sentence. Uppercase, tracking and --ink-500 do the rest. */
  .site-logo__descender {
    font-size: var(--fs-logo-caption);
    letter-spacing: var(--tracking-micro);
    text-transform: uppercase;
    color: var(--ink-500);
    margin-block-start: var(--s-1);
  }

  .site-logo__mark--full { display: none; }

  /* ---- Primary nav ------------------------------------------------------ */

  .primary-nav__list {
    display: flex;
    align-items: center;
    gap: var(--s-4);
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .primary-nav__item {
    display: flex;
    align-items: center;
    gap: var(--s-1);
  }

  .primary-nav__link {
    display: inline-flex;
    align-items: center;
    gap: var(--s-2);
    min-block-size: var(--field-h);
    font-size: var(--fs-small);
    /* BODY WEIGHT, not --fw-ui. Eleven items in a row is the densest run of
       text in the chrome, and at 500 the whole header reads as emphasis with
       nothing left over for the item that actually is emphasised. The nav is a
       list of destinations, not a set of labels on controls — --fw-ui belongs
       on the things it names, buttons and form labels. 400 is the floor (§8.3);
       past this the lever is size or colour, never weight. */
    font-weight: var(--fw-body);
    text-decoration: none;
    white-space: nowrap;
  }
  .primary-nav__link:hover { text-decoration: none; }

  /* The mark is subordinate to the label — it aids scanning, it does not
     compete. NEVER --accent: eleven accent icons across the header is exactly
     how the accent-scarcity rule dies (§8.2).

     FIXED SIZE, NOT WHATEVER FLEX LEAVES IT. `.primary-nav__list` is a flex
     row and this wrapper is one of its items — without `flex-shrink: 0` it's
     as compressible as everything else in that row, and once it's narrower
     than the SVG's own 20px, the global `svg { max-inline-size: 100% }` reset
     (needed elsewhere so an icon can never overflow its own box) shrinks the
     mark down with it. Eleven items plus the logo and the CTA is tight enough
     for that to actually happen at the narrow end of the desktop range.

     SIZED IN em, NOT A SECOND PX CONSTANT: 1.5 times the link's own inherited
     --fs-small, so the mark is explicitly proportioned to the text it sits
     beside rather than an independently-chosen pixel value that happens to
     look fine today and drifts the moment the nav's font-size does. */
  .primary-nav__icon {
    display: grid;
    place-items: center;
    flex-shrink: 0;
    inline-size: 1.5em;
    block-size: 1.5em;
    color: var(--ink-500);
  }

  .primary-nav__icon .pinnacle-icon {
    inline-size: 100%;
    block-size: 100%;
  }

  .primary-nav__link:hover .primary-nav__icon,
  .current-menu-item > .primary-nav__link .primary-nav__icon { color: var(--ink-900); }

  /* One step above its neighbours, not two: with the row at 400, --fw-ui is
     already a visible difference, and --ink-900 against --ink-700 is carrying
     the state as well. State is never weight alone here (§8.2). */
  .primary-nav__list .current-menu-item > .primary-nav__link {
    color: var(--ink-900);
    font-weight: var(--fw-ui);
  }

  /* THE CARET IS ITS OWN CONTROL, separate from the link (§9.2). The link
     always navigates; this always opens the panel. It carries a real
     accessible name so a screen-reader user hears "Flooring menu", not
     "button". */
  .primary-nav__caret {
    display: grid;
    place-items: center;
    inline-size: var(--s-7);
    block-size: var(--tap-min);
    padding: 0;
    background: none;
    border: 0;
    color: var(--ink-500);
    cursor: pointer;
  }

  .primary-nav__caret:hover { color: var(--ink-900); }
  .primary-nav__caret[aria-expanded="true"] { color: var(--ink-900); }
  .primary-nav__caret[aria-expanded="true"] .pinnacle-icon { transform: rotate(180deg); }
  .primary-nav__caret .pinnacle-icon { transition: transform var(--dur-fast) var(--ease); }

  /* ---- The mega panel (§9.2) -------------------------------------------- *
     A `popover`, so light-dismiss, Esc and top-layer stacking come from the
     platform. IT IS THE ONE TOP-LAYER ELEMENT HERE THAT IS NOT MODAL (§8.8):
     no focus trap, no scroll lock. Anchored under the header and height-capped
     so it can never cover the bottom-anchored consent banner (§6.10). */
  .mega {
    position: fixed;
    inset-block-start: var(--header-h);
    inset-inline: 0;
    inset-block-end: auto;
    inline-size: 100%;
    max-block-size: min(70vh, 480px);
    overflow-y: auto;
    margin: 0;
    padding: 0;
    background-color: var(--surface);
    border: 0;
    border-block-end: 1px solid var(--ink-900);
    box-shadow: var(--shadow-md);
  }

  .mega::backdrop { background: transparent; }

  /* Matches the header's cap, not the content one — the panel is anchored to
     the header and would otherwise be visibly narrower than the item that
     opened it.

     EVERY PANEL SHARES ONE RATIO AND ONE MINIMUM HEIGHT (§9.2 — the mega menu
     is one size everywhere, not one size per category). There used to be a
     second, three-track layout for panels carrying a "By room" column, which
     is why the same category's photo ran narrower than Rooms' or Service
     Areas' did — that column is gone, so every panel now lays out on the one
     two-track grid below and the frame is the same width at every panel. */
  .mega__inner {
    --mega-divider-gutter: var(--s-10);
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.55fr);
    /* TIGHTER GAP, WIDER FRAME: --s-6 between columns (was --s-8) so the
       two tracks read as one panel rather than separated blocks, and
       --s-12 on the sides (was --s-11) so the content sits further off the
       header's own edge — the panel was reading closer to full-bleed than
       the chrome it hangs from. */
    gap: 0;
    padding-block: var(--s-7);
    padding-inline: var(--s-12);
    max-inline-size: var(--w-header);
    /* THE FIXED FLOOR THAT MAKES EVERY PANEL THE SAME SIZE. Without it each
       panel sized itself off its own row count — Carpets (two children) ran
       barely a third the height of Service Areas (thirteen) — and the header
       visibly resized as a visitor moved along the nav row. This is tall
       enough for the longest list (Service Areas, two-up) with room to
       spare; a shorter list's frame simply stretches down to fill the same
       box, per `.mega__col--media` below. Clamped to the viewport for the same
       reason `.mega`'s own max-block-size is: a short screen must still be
       able to see the whole panel. */
    min-block-size: min(70vh, 430px);
  }

  /* A flex column so its last row can be pinned to the foot of the panel
     rather than left hanging under a short list. */
  .mega__col--links {
    display: flex;
    flex-direction: column;
    min-inline-size: 0;
    padding-inline-end: var(--mega-divider-gutter);
    border-inline-end: 1px solid var(--ink-900);
  }

  .mega__head { margin-block-end: var(--s-5); }

  /* Pinned to the bottom of its column, which is what keeps the panel from
     reading as a block of text in the top-left corner of a wide white band. */
  .mega__foot { margin-block-start: auto; padding-block-start: var(--s-5); }

  .mega__proof {
    display: flex;
    align-items: center;
    gap: var(--s-3);
    inline-size: 100%;
    padding: var(--s-3);
    border: 1px solid var(--mega-media-border);
    border-radius: 0;
    font-size: var(--fs-small);
    font-weight: var(--fw-ui);
    color: var(--ink-700);
    text-decoration: none;
  }
  .mega__proof:hover { color: var(--ink-900); text-decoration: underline; }
  .mega__proof .pinnacle-icon { color: var(--brass); }

  .mega__all {
    display: inline-flex;
    align-items: center;
    gap: var(--s-2);
    font-size: var(--fs-h4);
    font-weight: var(--fw-subhead);
    color: var(--ink-900);
    text-decoration: none;
  }
  .mega__all:hover { text-decoration: underline; }

  /* The category's own "why this, here" line (§5.6). --ink-700, not --ink-500:
     it is a sentence a visitor reads to make a decision, and meta grey on body
     copy is the anti-pattern §8.12 names. */
  .mega__why {
    max-inline-size: 46ch;
    margin-block: var(--s-4) 0;
    font-size: var(--fs-small);
    line-height: var(--lh-small);
    color: var(--ink-700);
  }

  /* TWO COLUMNS, EVERY PANEL. With the "By room" column gone, a category's
     sub-list is the only thing left beside the frame, and one column of two
     to seven short words ran narrow and tall against the fixed panel height
     above — Carpets (two children) read as a sliver of text beside a full
     photo. Two columns fills the width the room column used to take and
     roughly halves the row count everywhere, which is what keeps the tallest
     list (Service Areas, thirteen) inside the fixed height instead of
     scrolling. Row-major fill (left-to-right, top-to-bottom) rather than a
     column-major split, since the item count isn't fixed and CSS alone can't
     balance an unknown total into two even columns without JS. */
  .mega__browse-label {
    margin-block-end: var(--s-2);
    font-size: var(--fs-micro);
    font-weight: var(--fw-ui);
    letter-spacing: var(--tracking-micro);
    text-transform: uppercase;
    color: var(--ink-500);
  }

  .mega__list {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 0;
    margin: 0;
    padding: 0;
    border-block-start: 1px solid var(--mega-media-border);
    border-inline-start: 1px solid var(--mega-media-border);
    list-style: none;
  }

  .mega__list > li {
    min-inline-size: 0;
    border-inline-end: 1px solid var(--mega-media-border);
    border-block-end: 1px solid var(--mega-media-border);
  }

  /* Cells draw only the borders they actually occupy. Do not close the grid
     as one outer rectangle: when the final row has a single item, the unused
     second grid cell must remain genuinely empty rather than appearing as a
     blank bordered box. */
  .mega__list > li:nth-child(even) {
    border-inline-end: 1px solid var(--mega-media-border);
  }

  /* A ROW, NOT A LINK IN A PARAGRAPH. Each one drives the preview frame beside
     it, so its hover state has to read as "this is the one selected" rather
     than as text with a line under it — which is also why the underline is
     gone: an underline plus a fill plus a photo swap is three answers to one
     hover. The fill is --bg-tint, the same step the header itself sits on. */
  .mega__link {
    display: flex;
    align-items: center;
    gap: var(--s-3);
    padding: var(--s-2) var(--s-3);
    border-radius: var(--r-sm);
    font-size: var(--fs-small);
    color: var(--ink-700);
    text-decoration: none;
    transition:
      background-color var(--dur-fast) var(--ease),
      color var(--dur-fast) var(--ease);
  }

  /* The mark that appears at the leading edge of the selected row. A 1px brass
     tick rather than a chevron: eighteen inline SVG chevrons per panel is
     markup in the header of every page for an affordance that only ever shows
     one at a time (§8.17). */
  .mega__link::before {
    content: "";
    flex: none;
    inline-size: 2px;
    block-size: var(--s-4);
    background-color: var(--ink-900);
    opacity: 0;
    transition: opacity var(--dur-fast) var(--ease);
  }

  .mega__link:hover,
  .mega__link:focus-visible,
  .mega__link--on {
    background-color: var(--bg-tint);
    color: var(--ink-900);
  }

  .mega__link:hover::before,
  .mega__link:focus-visible::before,
  .mega__link--on::before { opacity: 1; }

  .mega__link::after {
    content: "→";
    flex: none;
    color: var(--ink-900);
    opacity: 0;
    transform: translateX(calc(var(--s-1) * -1));
    transition:
      opacity var(--dur-fast) var(--ease),
      transform var(--dur-fast) var(--ease);
  }

  .mega__link:hover::after,
  .mega__link:focus-visible::after,
  .mega__link--on::after {
    opacity: 1;
    transform: translateX(0);
  }

  .mega__link-text {
    flex: 1 1 auto;
    min-inline-size: 0;
    overflow-wrap: break-word;
  }

  /* The product count. aria-hidden in the markup — "Tile 41" spoken as two
     numbers in a nav is noise, and the count is on the category page (§5.6). */
  .mega__link-count {
    align-self: stretch;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    min-inline-size: var(--s-8);
    margin-block: calc(var(--s-2) * -1);
    padding-inline-start: var(--s-3);
    border-inline-start: 1px solid var(--mega-media-border);
    font-size: var(--fs-micro);
    font-variant-numeric: tabular-nums;
    color: var(--ink-500);
  }

  /* ---- The preview frame (§9.2) ----------------------------------------- *
     THE FRAME CANNOT CHANGE THE PANEL'S HEIGHT, and that is the whole reason
     it is built this way. It holds one photo per row in the link column
     beside it, so anything that sized the panel from the image would make the
     header grow by whichever crop happened to be tallest. Instead the stack is
     absolutely positioned inside its grid area: the row's height comes from
     the panel's fixed min-block-size (`.mega__inner`) or the link column's own
     content, whichever is taller, the frame stretches to match, and every
     photo crops to fill it (object-fit: cover). A short list's frame is
     therefore the same size as a long one's — the box grows with the panel,
     never the other way round. */
  .mega__col--media {
    position: relative;
    min-inline-size: 0;
    margin-inline-start: var(--mega-divider-gutter);
  }

  .mega__media {
    position: absolute;
    inset: 0;
    overflow: hidden;
    outline: 2px solid var(--mega-media-border);
    border-radius: 0;
    background-color: var(--bg-tint);
  }

  .mega__slide {
    position: absolute;
    inset: 0;
    margin: 0;
    opacity: 0;
    transition: opacity var(--dur-base) var(--ease);
  }

  .mega__slide--current { opacity: 1; }

  .mega__slide-img {
    display: block;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
    object-position: center center;
  }

  .mega__slide-img--bottom-center {
    object-position: center bottom;
  }

  /* The caption names what is on screen, over a scrim rather than in a line
     below the photo — a caption outside the frame would put the panel's height
     back in the image's hands. Sentence case, as authored: a project caption
     is "Kitchen, Mississauga · February 2026", and shouting it in micro caps
     is the generated-site tell §8.18 is written against. The empty frame
     keeps the uppercase treatment — it is a label, not a caption. */
  .mega__slide-label {
    position: absolute;
    inset-inline: 0;
    inset-block-end: 0;
    padding: var(--s-8) var(--s-5) var(--s-4);
    font-size: var(--fs-small);
    color: var(--bg);
    background-image: linear-gradient(
      to top,
      color-mix(in srgb, var(--ink-900) 82%, transparent),
      color-mix(in srgb, var(--ink-900) 0%, transparent)
    );
  }

  /* A CATEGORY OR ROOM WITH NO PHOTOGRAPHY YET GETS A DESIGNED FRAME, not a
     stock kitchen and not a gap (§8.15, §8.10 #9). The catalogue launches
     before the photography does (§10), so this is the state the panel has to
     look finished in — and the moment a hero or a featured image is set, that
     row shows it with no other change anywhere. */
  .mega__slide--empty {
    display: grid;
    place-items: center;
    padding: var(--s-5);
    background-color: var(--bg-tint);
    box-shadow: inset 0 0 0 1px var(--line);
  }

  .mega__slide-empty {
    display: grid;
    justify-items: center;
    gap: var(--s-4);
    font-size: var(--fs-micro);
    text-transform: uppercase;
    letter-spacing: var(--tracking-micro);
    color: var(--ink-500);
    text-align: center;
  }

  .nav-empty { font-size: var(--fs-small); color: var(--ink-500); }

  /* ---- Header search (§5.5, §9.2) ---------------------------------------- *
     DESKTOP SEARCH IS AN ICON THAT OPENS THE MEGA PANEL, not a field parked in
     the header row. The panel itself needs no geometry of its own: it carries
     `.mega` for the anchoring, the cap and the box, and only its contents are
     specced here. One overlay pattern in the header, not two (§9.2). */

  .search-toggle {
    place-items: center;
    inline-size: var(--field-h);
    block-size: var(--field-h);
    padding: 0;
    background: none;
    border: 0;
    /* Subordinate to the CTA beside it, and NEVER --accent — the primary
       action owns the accent on this viewport (§8.2). */
    color: var(--ink-500);
    cursor: pointer;
    border-radius: var(--r-sm);
  }

  .search-toggle:hover { color: var(--ink-900); background-color: var(--bg-alt); }
  .search-toggle[aria-expanded="true"] {
    color: var(--ink-900);
    background-color: var(--bg-alt);
  }

  /* The field is prose-width and centred rather than full-bleed: a single input
     stretched across 1600px of chrome reads as a mistake, and 720px is the
     measure the rest of the site already reads at (§8.4). */
  .search-panel__inner {
    max-inline-size: var(--w-header);
    /* 16px, not 24. The panel holds ONE field (§9.2) — it is a control that
       drops out of the header, not a section, and 24px above and below a
       single input made it read as a band the visitor had opened by accident. */
    padding-block: var(--s-5);
  }

  .search-panel__form {
    position: relative;
    display: flex;
    align-items: center;
    max-inline-size: var(--w-prose);
    margin-inline: auto;
  }

  /* --field-h, NOT --tap-min. Only three things are pinned to the 44px floor at
     every pointer type (§8.4) and a search field is not one of them, so this
     comes down to 32px under a mouse and stays 44 under a finger — which is
     what a touch tablet in landscape gets, since this panel exists from --bp-lg
     and that range includes them. */
  .search-panel__field {
    inline-size: 100%;
    block-size: var(--field-h);
    /* Trailing padding equals the submit's width, so text can never run under
       the icon — one token keeps the two in step. */
    padding-inline: var(--s-4) var(--field-h);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    /* --fs-input is the FLOOR here, not the intent. This panel exists from
       --bp-lg, and that range includes tablets in landscape — a touch device,
       where an input under 16px zooms the page on focus and does not zoom back
       (§8.3). The capability query below is what makes the smaller size safe;
       without it this must stay 16. */
    font-size: var(--fs-input);
  }

  /* ON A FINE POINTER IT STEPS DOWN TO THE 12px UI BASELINE, like every other
     input on the site (§8.4). The query proves there is no iOS focus-zoom to
     trigger, which is the only reason 16px was ever being held here — it was a
     functional floor, never a type choice, and at 16px the placeholder sat two
     steps above the nav beside it (§8.3). */
  @media (hover: hover) and (pointer: fine) {
    .search-panel__field { font-size: var(--fs-small); }
  }

  .search-panel__submit {
    position: absolute;
    inset-inline-end: 0;
    display: grid;
    place-items: center;
    inline-size: var(--field-h);
    block-size: var(--field-h);
    background: none;
    border: 0;
    color: var(--ink-500);
    cursor: pointer;
  }
  .search-panel__submit:hover { color: var(--ink-900); }

  /* ---- Mobile nav dialog (§9.2, §8.14) ---------------------------------- */

  /* A <dialog> in the top layer: focus trap, Esc-to-close, backdrop and
     inertness come from the platform, which is what makes the unclosable-modal
     failure in §8.8 structurally impossible. */
  .mobile-nav {
    padding: 0;
    border: 0;
    background: transparent;
    max-inline-size: 100%;
    max-block-size: 100%;
    inline-size: 100%;
    block-size: 100%;
  }

  /* Derived from a token with color-mix rather than a raw value — scrims are
     exactly what §8.14 sanctions color-mix for, and no raw colour may appear
     outside tokens.css (§3.8). */
  .mobile-nav::backdrop {
    background-color: color-mix(in srgb, var(--ink-900) 55%, transparent);
    transition: background-color var(--dur-base) var(--ease);
  }

  /* FULL WIDTH, not a 380px column with a strip of scrim beside it — the same
     answer the filter drawer already gives below --bp-lg (§8.13). At 360px a
     partial panel spends a tenth of the screen on a sliver of dimmed page and
     leaves the menu rows in ~300px.

     One consequence, and it is why the close control is not optional: with no
     scrim showing there is nothing outside the panel to tap. Esc and the close
     button in the head are the two ways out, and the head is the first thing in
     the panel so it is reachable without scrolling at 320px (§8.8, §5.5's
     equivalent rule for the drawer). */
  .mobile-nav__panel {
    display: flex;
    flex-direction: column;
    gap: var(--s-7);
    inline-size: 100%;
    block-size: 100%;
    padding: var(--s-5);
    background-color: var(--surface);
    overflow-y: auto;
    /* Keep the drill-down geometry fixed when a long category starts
       scrolling. Android Chromium otherwise allocates inline space for its
       scrollbar only on overflowing levels, which shifts the entire menu
       (including the close control) a few pixels toward inline-start. The
       panel remains touch/keyboard scrollable; only the transient scrollbar
       chrome is suppressed so every level keeps the main-menu width. */
    scrollbar-width: none;
    translate: 0 0;
    transition: translate var(--dur-base) var(--ease);
  }

  .mobile-nav__panel::-webkit-scrollbar {
    display: none;
  }

  /* SLIDES IN FROM THE INLINE-END, same technique and the same no-exit-
     animation convention as .modal/.filter-drawer/.compare-view (§8.14):
     @starting-style gives the panel a "before" position for the one moment
     it goes from not-rendered to open; closing stays instant. */
  @starting-style {
    .mobile-nav[open]::backdrop { background-color: transparent; }
    .mobile-nav[open] .mobile-nav__panel { translate: 100% 0; }
  }

  /* THE CLOSE BUTTON LANDS EXACTLY WHERE THE HAMBURGER WAS.
     Opening the menu should move the control's label, not the control. The
     panel's own top padding is removed and this head takes --header-h instead,
     so its centre line is the header's centre line — the close button ends up
     at the same distance from the top of the viewport and the same distance
     from the inline edge (the panel's inline padding equals the page gutter),
     which is the only way the swap reads as one button changing state rather
     than two buttons in two places. */
  .mobile-nav__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    min-block-size: var(--header-h);
    /* Cancels the panel's own block padding for this row only, so the head
       spans the full panel width and its border reaches both edges. */
    margin-block-start: calc(var(--s-5) * -1);
    margin-inline: calc(var(--s-5) * -1);
    padding-inline: var(--s-5);
    border-block-end: 1px solid var(--ink-900);
  }

  .mobile-nav__title {
    font-size: var(--fs-micro);
    text-transform: uppercase;
    letter-spacing: var(--tracking-micro);
    color: var(--ink-500);
  }

  .mobile-nav__close {
    display: grid;
    place-items: center;
    inline-size: var(--s-10);
    block-size: var(--s-10);
    background: none;
    border: 0;
    color: var(--ink-900);
    cursor: pointer;
    border-radius: var(--r-sm);
  }

  .mobile-nav__search { display: flex; gap: var(--s-2); }

  /* --fs-input, EXPLICITLY, and it is a bug fix rather than a style choice.
     This inherited --fs-body (14px) through the reset's `font: inherit`, and
     this field is the one search box that only ever appears on TOUCH — below
     --bp-lg, inside the mobile panel. Under 16px iOS zooms the page on focus
     and does not zoom back (§8.3), so the menu a visitor just opened ends up
     zoomed and pannable. No capability query here: there is no fine-pointer
     case to step down for, because at --bp-lg+ this panel is not rendered at
     all (§9.2). */
  .mobile-nav__search input {
    flex: 1 1 auto;
    block-size: var(--field-h);
    padding-inline: var(--s-4);
    background-color: var(--bg-tint);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    font-size: var(--fs-input);
  }

  .mobile-nav__search button {
    display: grid;
    place-items: center;
    inline-size: var(--field-h);
    block-size: var(--field-h);
    background-color: var(--bg-alt);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    color: var(--ink-900);
    cursor: pointer;
  }

  /* THE ROW IS THE CONTROL, and an item has exactly one of them: the link, or
     the button that stands in for it once the script claims the panel. The row
     rule therefore sits on the item, where it reaches both edges whichever of
     the two is showing. */
  .mobile-nav__list li { display: block; }

  /* The primary level is one closed grid, not a stack of floating rules.
     The list owns the outside frame; rows own only their internal separators.
     This keeps both inline edges continuous without doubling any hairline. */
  .mobile-nav__list {
    border: 1px solid var(--ink-900);
  }

  .mobile-nav__list > li:not(:last-child) {
    border-block-end: 1px solid var(--ink-900);
  }

  .mobile-nav__list a,
  .mobile-nav__drill,
  .mobile-nav__back {
    display: flex;
    align-items: center;
    gap: var(--s-3);
    inline-size: 100%;
    min-block-size: var(--s-10); /* 48px full-width rows (§9.2) */
    padding: 0;
    padding-block: var(--s-3);
    font-family: inherit;
    font-size: var(--fs-h4);
    /* Matches .primary-nav__link above — it is the same menu, and a phone
       visitor seeing it heavier than a desktop one is the drift §9.2 exists to
       stop. Its sub-menu rows were already at body weight. */
    font-weight: var(--fw-body);
    text-align: start;
    text-decoration: none;
    color: inherit;
    background: none;
    border: 0;
    cursor: pointer;
  }

  /* The same mark the desktop row carries, and subordinate to the label in the
     same way — never --accent (§8.2, §9.2). */
  .mobile-nav__icon {
    display: grid;
    place-items: center;
    color: var(--ink-500);
  }

  /* The current page marks its row whichever control that row is — with the
     script the link is hidden and the button is the row, so a rule written only
     against `a` would quietly stop marking anything (§8.15). */
  .mobile-nav__list .current-menu-item > a,
  .mobile-nav__list .current-menu-ancestor > a,
  .mobile-nav__list .current-menu-item > .mobile-nav__drill,
  .mobile-nav__list .current-menu-ancestor > .mobile-nav__drill,
  .mobile-nav__list .current-menu-parent > .mobile-nav__drill { color: var(--ink-900); }

  .mobile-nav__list .current-menu-item > a .mobile-nav__icon,
  .mobile-nav__list .current-menu-ancestor > a .mobile-nav__icon,
  .mobile-nav__list .current-menu-ancestor > .mobile-nav__drill .mobile-nav__icon,
  .mobile-nav__list .current-menu-parent > .mobile-nav__drill .mobile-nav__icon { color: var(--ink-900); }

  /* THE TRAILING MARK, and the only thing in the row that says there is more of
     this section behind it — the label is the same word the link carried, so
     nothing else distinguishes a row that drills from a row that navigates.
     Subordinate to the label at --ink-500, exactly as the leading mark is
     (§8.2). It points along the inline axis, not down: the children arrive
     beside this panel, not underneath this row. */
  .mobile-nav__chev {
    display: grid;
    place-items: center;
    margin-inline-start: auto;
    color: var(--ink-500);
  }

  /* Keeps a long item title shrinking inside the row rather than pushing the
     chevron off the trailing edge. */
  .mobile-nav__label { min-inline-size: 0; }

  /* ---- The drill-down (§9.2) --------------------------------------------

     TWO CLASSES, BOTH SET BY THE SCRIPT, AND THEY DO DIFFERENT JOBS.

     `.is-drill` says the panel is a drill-down at all. It goes on once, at
     init, and it is what closes every group — which is why the groups can ship
     open in the markup: with no script this class never lands, nothing is
     closed, and the panel is core's nested list with every child reachable
     (§8.17). Everything below is scoped to it, so the no-JS rendering keeps the
     indented sub-list treatment further down untouched.

     `.is-drilled` says a section is currently open, and it is what empties the
     top level out of the way. Splitting the two is what lets a group be closed
     without the rest of the menu disappearing with it. */
  .mobile-nav__menu.is-drill .mobile-nav__sub { display: none; }

  .mobile-nav__menu.is-drill .mobile-nav__list > li.is-open > .mobile-nav__sub {
    display: block;
    animation: mobile-nav-drill var(--dur-base) var(--ease) both;
  }

  /* The row that was tapped goes with the level it opened: the panel head now
     names the section, and a visitor who wants that row back presses Back. */
  .mobile-nav__menu.is-drilled .mobile-nav__list > li:not(.is-open) { display: none; }
  .mobile-nav__menu.is-drilled .mobile-nav__list > li.is-open { border-block-end: 0; }
  .mobile-nav__menu.is-drilled .mobile-nav__list > li.is-open > .mobile-nav__drill { display: none; }

  /* Drilled children are the whole panel, so they are ordinary rows: no indent,
     no hairline down the side, and the same size as the level above. Nothing is
     subordinate here any more — there is nothing on screen to be subordinate
     to. */
  .mobile-nav__menu.is-drill .mobile-nav__list > li.is-open > .mobile-nav__sub {
    margin-inline-start: 0;
    padding-inline-start: 0;
    padding-block: 0;
  }

  .mobile-nav__menu.is-drill .is-open > .mobile-nav__sub::before { content: none; }

  .mobile-nav__menu.is-drill .is-open > .mobile-nav__sub > li {
    border-block-end: 1px solid var(--ink-900);
  }

  .mobile-nav__menu.is-drill .is-open > .mobile-nav__sub a {
    min-block-size: var(--s-10);
    padding-block: var(--s-3);
    font-size: var(--fs-h4);
    color: inherit;
  }

  /* BACK IS THE FIRST ROW AND IT IS QUIETER THAN THE DESTINATIONS BELOW IT.
     It is the way out of a level, not one of the things in it — micro-scale and
     --ink-500 put it in the same register as the head's own label, so the eye
     starts on the section's real contents. */
  .mobile-nav__backrow { border-block-end: 1px solid var(--ink-900); }

  .mobile-nav__back {
    gap: var(--s-2);
    min-block-size: var(--tap-min);
    font-size: var(--fs-small);
    color: var(--ink-500);
  }

  .mobile-nav__back .mobile-nav__chev { margin-inline-start: 0; }

  /* "All Flooring" — the section's own page, which is what the parent row used
     to link to before the row became the way in. Weighted a step up because it
     is the head of the list rather than a sibling of the eighteen finishes
     under it. */
  .mobile-nav__menu.is-drill .is-open > .mobile-nav__sub > .mobile-nav__allrow a {
    font-weight: var(--fw-ui);
    color: var(--ink-900);
  }

  /* A LEVEL ARRIVES FROM THE TRAILING EDGE, which is the direction Back sends
     it home in. Short and small: this is a panel changing contents, not a page
     transition, and the global reduced-motion block in tokens.css cuts it to
     nothing (§8.7). */
  @keyframes mobile-nav-drill {
    from { opacity: 0; transform: translateX(var(--s-5)); }
    to   { opacity: 1; transform: translateX(0); }
  }

  .mobile-nav__menu.is-drill .is-open > .mobile-nav__sub > li:last-child {
    border-block-end: 0;
  }

  /* ---- The no-JS sub-list (§8.17) ---------------------------------------

     WHAT THE PANEL IS WHEN NOTHING CLAIMS IT: every group open, inline under
     its parent link. Only `.is-drill` above turns any of this off, so this is
     the rendering a visitor with no JavaScript gets — and the reason the groups
     may ship open at all.

     A LINE THAT FLOATS BESIDE THE CHILDREN, NOT A BORDER WELDED TO THEM.
     Indentation alone is a weak signal here, so the group keeps a rule down its
     inline start — but a 2px border ran the full height of the group and met
     the row rules above and below it at a corner, which reads as the edge of a
     table cell rather than as a hint. A 1px hairline inset from both ends,
     drawn as a pseudo-element so it can be, touches nothing: it starts inside
     the first child and stops inside the last.

     The children also drop their own row rules. The floating line and the
     smaller type carry the grouping between them, and a stack of full-width
     borders inside an already-bordered row is the weight this is meant to
     lose. */
  .mobile-nav__list .sub-menu {
    position: relative;
    margin-inline-start: var(--s-3);
    padding-inline-start: var(--s-5);
    padding-block: var(--s-2);
  }

  .mobile-nav__list .sub-menu::before {
    content: "";
    position: absolute;
    inset-block: var(--s-4);
    inset-inline-start: 0;
    inline-size: 1px;
    background-color: var(--line);
  }

  .mobile-nav__list .sub-menu a {
    min-block-size: var(--tap-min);
    padding-block: var(--s-2);
    font-size: var(--fs-small);
    font-weight: var(--fw-body);
    color: var(--ink-700);
  }

  .mobile-nav__foot {
    margin-block-start: auto;
    display: flex;
    flex-direction: column;
    gap: var(--s-5);
  }

  /* ---- Contact channels (§9.3) ------------------------------------------ */

  .contact-channels {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-3);
    margin: 0;
  }

  /* ul.contact-channels, NOT .contact-channels alone — same fix as
     ul.social-links below (§7.3 comment there has the full reasoning).
     This list sits inside .prose on the Contact page's "Want to talk
     right now?" section, and .prose ul's padding-inline-start is
     class+type specificity: a bare class here loses to it and leaves
     the whole chip row indented 20px right of the heading above it. */
  ul.contact-channels { padding-inline-start: 0; }

  /* flex-shrink: 0 on the ITEM, not the chip inside it. Without this the
     default flex-shrink: 1 let a narrow row squeeze the email chip (the
     longest value, since an email address usually runs longer than the
     other two) below its content width before wrap kicked in on the row,
     wrapping the address onto a second line and growing that one chip
     taller than the other two — three same-height chips reading as
     uneven. Now a chip that doesn't fit drops to its own row whole, at
     the height every other chip has. */
  .contact-channels__item { flex-shrink: 0; }

  /* A CHIP, NOT A SLAB. THE PADDING IS THE SPEC'S, AND IT STAYS THERE: 8px in
     from the inline edge, 2px on the block axis. It came down a token once and
     went back — --field-h governs the height either way (§8.4), so tightening
     the box bought nothing a visitor could see and only pulled the value
     closer to the border it sits against.

     THE MARK IS WHAT CAME DOWN INSTEAD, to 16. That is BELOW §9.1's
     beside-a-label band by deliberate exception, recorded in the icon table
     there rather than left as a local override: the chip carries a phone
     number or an address, the visitor reads THAT, and the mark is only naming
     which channel it belongs to. At 20 and again at 18 it still led the line
     it was labelling.

     NOTHING HERE IS BEHIND A MEDIA QUERY, so this one rule is the whole
     responsive story — there is no per-breakpoint value to keep in step, and
     the tap area is untouched at every pointer type and every width. */
  .contact-channel {
    display: inline-flex;
    align-items: center;
    gap: var(--s-2);
    min-block-size: var(--field-h);
    padding-inline: var(--s-3);
    padding-block: var(--s-1);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    background-color: var(--surface);
    text-decoration: none;
    font-size: var(--fs-small);
    transition: border-color var(--dur-fast) var(--ease);
  }
  .contact-channel:hover { border-color: var(--ink-500); }

  /* THE MARK TAKES currentColor, like every other icon on the site. Brass is a
     detail colour — a hairline under a heading, used sparingly (§8.2 culture
     note) — and three brass marks in a row read as a fourth colour in the
     chrome rather than as icons belonging to the text beside them. Inheriting
     is what makes them consistent with the nav, the rail arrows and the
     feature strip. */
  .contact-channel__icon {
    display: grid;
    place-items: center;
  }

  /* ONE VISIBLE LINE now — the label beside it is screen-reader-only, and
     .screen-reader-text is taken out of flow, so this no longer needs to be a
     column. Heading leading rather than --lh-small (1.45) because a chip is a
     glance, not a paragraph (§8.3). */
  .contact-channel__text { line-height: var(--lh-heading); }

  .contact-channel__value {
    font-weight: var(--fw-ui);
    color: var(--ink-900);
  }

  .contact-channels--nav { flex-direction: column; }
  .contact-channels--nav .contact-channel { inline-size: 100%; }

  /* PAGE — the Contact page's own CTA section (§7.3), not chrome. The
     16px icon and tight padding above are sized for a header bar, a
     footer row or a floating widget, all fighting for space; this one
     sits under its own h2 and lead line with nothing to compete with, so
     the tight chrome version read thin next to them. Bumped to the
     standard 18-20px "beside a label" icon band (§9.1) and --bg-tint —
     named for exactly this in §8.2 ("chip fills") — rather than flat
     --surface, for a touch more visual weight without leaving the token
     system or the chip idiom itself. */
  .contact-channels--page .contact-channel {
    padding-inline: var(--s-4);
    padding-block: var(--s-2);
    background-color: var(--bg-tint);
  }

  .contact-channels--page .pinnacle-icon {
    inline-size: var(--s-6);
    block-size: var(--s-6);
  }

  /* ---- Floating CTA (§9.8) ---------------------------------------------- */

  .floating-cta {
    position: fixed;
    inset-block-end: var(--s-5);
    inset-inline-end: var(--s-5);
    z-index: var(--z-float);
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    gap: var(--s-3);
  }

  /* ALWAYS VISIBLE, FROM FIRST PAINT (§9.8). It used to fade in after 300px of
     scroll, on the reasoning in §8.11 — but that budget governs INTERRUPTIONS,
     and this widget interrupts nothing: it is non-modal and covers no content
     until it is pressed. Hiding it until scroll meant the visitor most likely
     to want it — someone who has just landed and wants to call — had nothing
     to press.

     Being pure CSS rather than a JS-applied class also means it is still there
     with the script absent, instead of permanently invisible because nothing
     added the class (§8.17). */

  /* NEVER renders alongside the mobile product CTA bar — the bar wins on
     product pages (§5.8, §8.8). At --bp-lg+ the bar is hidden and this returns
     (§8.13). */
  .has-product-cta-bar .floating-cta { display: none; }

  /* 48px, DOWN FROM 64. A 64px accent disc is the largest single object on a
     phone screen and it sits over the content on every page — it read as a
     chat widget bolted on rather than part of the site. 48px is still well
     above the 44px touch floor (§8.4) and still the only --accent element in
     the viewport (§8.2). */
  .floating-cta__toggle {
    display: grid;
    place-items: center;
    inline-size: var(--s-10);
    block-size: var(--s-10);
    border: 0;
    border-radius: var(--r-full); /* --r-full is for this and avatars ONLY */
    background-color: var(--accent);
    color: var(--surface);
    box-shadow: var(--shadow-md);
    cursor: pointer;
    transition: background-color var(--dur-fast) var(--ease), transform var(--dur-fast) var(--ease);
  }
  .floating-cta__toggle:hover {
    background-color: var(--accent-hover);
    transform: translateY(var(--lift));
  }

  /* NO ATTENTION CUE ON THE DISC. It used to carry a ring on an `::after` that
     expanded out of the button and faded, on a loop. Two things were wrong
     with it: §9.8 only ever sanctioned a cue that runs TWICE AND STOPS, and
     this one shipped as `infinite`; and at rest the ring sat on the disc's own
     edge, so what a visitor actually saw was a stray hairline around the
     button rather than a cue.

     A mark moving in the corner of every page also competes with the primary
     CTA it is meant to support, which is the "flashy" §8.7 rules out. The disc
     is already the only --accent element in the viewport (§8.2) and is visible
     from first paint, so it does not need pointing at.

     DO NOT REINTRODUCE A LOOPING RING HERE. `position: relative` stays: the
     icon swap below wants a positioned box. */
  .floating-cta__toggle { position: relative; }

  .floating-cta__icon--close { display: none; }
  .floating-cta__toggle[aria-expanded="true"] .floating-cta__icon--open { display: none; }
  .floating-cta__toggle[aria-expanded="true"] .floating-cta__icon--close { display: grid; }

  .floating-cta__options {
    display: flex;
    flex-direction: column;
    gap: var(--s-2);
    padding: var(--s-3);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-md);
    box-shadow: var(--shadow-md);
  }

  .floating-cta__options .contact-channels { flex-direction: column; }
  .floating-cta__options .contact-channel { inline-size: 100%; border-color: transparent; }

  /* =========================================================================
     THE FLOATING CHAT BOX (§9.8)

     Replaces a plain Call/WhatsApp options list with a small chat panel: a
     head (who this is, plus a Call mark), a canned greeting bubble, and a
     one-line composer that sends straight to WhatsApp. It renders on every
     page (footer.php), never only on catalogue views, so every rule here
     lives in main.css rather than catalogue.css (§8.17) — .icon-btn is a
     catalogue-only class and must not be reused on this widget.
     ====================================================================== */

  .floating-chat {
    display: flex;
    flex-direction: column;
    gap: var(--s-3);
    inline-size: min(320px, calc(100vw - var(--s-9)));
    padding: var(--s-4);
    background-color: var(--surface);
    border-radius: var(--r-md);
    box-shadow: var(--shadow-md);
  }

  .floating-chat__head {
    display: flex;
    align-items: center;
    gap: var(--s-3);
    padding-block-end: var(--s-3);
    border-block-end: 1px solid var(--ink-900);
  }

  .floating-chat__avatar {
    display: grid;
    place-items: center;
    flex: 0 0 auto;
    inline-size: var(--s-9);
    block-size: var(--s-9);
    border-radius: var(--r-full); /* --r-full is for this and avatars ONLY (§8.5) */
    background-color: var(--bg-alt);
    color: var(--ink-700);
  }

  .floating-chat__head-text {
    display: flex;
    flex-direction: column;
    min-inline-size: 0;
  }

  .floating-chat__name {
    font-size: var(--fs-h4);
    font-weight: var(--fw-subhead);
    color: var(--ink-900);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }

  .floating-chat__status {
    font-size: var(--fs-micro);
    color: var(--ink-500);
  }

  /* THE CALL MARK LIVES IN THE HEAD, not a second row under the composer —
     one control costs nothing in a row that already has the avatar and
     name, where a full-width row would (§8.4). Same neutral treatment as
     the send mark below: a bordered square, never --accent (§8.2) — the
     toggle disc is already the one accent element on screen while this
     panel is open. */
  .floating-chat__call {
    display: grid;
    place-items: center;
    flex: 0 0 auto;
    margin-inline-start: auto;
    inline-size: var(--field-h);
    block-size: var(--field-h);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    color: var(--ink-700);
  }
  .floating-chat__call:hover { border-color: var(--ink-500); color: var(--ink-900); }

  /* AN INCOMING-MESSAGE BUBBLE, --bg-tint fill so it reads as a message
     rather than a card (§8.2's chrome step, not a section background). One
     radius token throughout, per §8.5 — no fifth corner invented for a tail. */
  .floating-chat__bubble {
    padding: var(--s-3);
    background-color: var(--bg-tint);
    border-radius: var(--r-md);
  }

  .floating-chat__bubble p {
    margin: 0;
    font-size: var(--fs-small);
    line-height: var(--lh-small);
    color: var(--ink-700);
  }

  /* ONE FRAME, dividers inside it — the same idiom as the product card's CTA
     bar and the detail-page action strip (§5.7, §5.8): a bordered input and
     a bordered button read as two competing controls in ~280px, while one
     frame with an internal rule reads as a single composer. */
  .floating-chat__composer {
    display: flex;
    align-items: stretch;
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    background-color: var(--surface);
  }

  .floating-chat__input {
    flex: 1 1 auto;
    min-inline-size: 0;
    padding-inline: var(--s-3);
    border: 0;
    background: transparent;
    font-family: inherit;
    font-size: var(--fs-input);
    color: var(--ink-900);
  }
  @media (hover: hover) and (pointer: fine) {
    .floating-chat__input { font-size: var(--fs-small); }
  }
  /* Replacement ring is drawn on the composer FRAME below, since the input
     has no border of its own to ring against a hairline. */
  .floating-chat__input:focus-visible { outline: none; } /* spec-lint:allow */
  .floating-chat__composer:has(.floating-chat__input:focus-visible) {
    outline: var(--focus-ring);
    outline-offset: var(--focus-offset);
  }

  /* THE SEND MARK IS NEUTRAL, NOT --accent: the toggle disc is already the
     one accent element visible while this panel is open (§8.2's scarcity
     rule), so a second filled-accent control here would compete with it.
     The WhatsApp glyph itself is what says where this goes. */
  .floating-chat__send {
    display: grid;
    place-items: center;
    flex: 0 0 auto;
    inline-size: var(--field-h);
    block-size: var(--field-h);
    border-inline-start: 1px solid var(--line);
    color: var(--ink-700);
    cursor: pointer;
  }
  .floating-chat__send:hover { background-color: var(--bg-tint); color: var(--ink-900); }

  /* =========================================================================
     FORMS (§7.1)
     ====================================================================== */

  /* ──────────────────────────────────────────────────────────────────────
     THE HONEYPOT. DO NOT DELETE, DO NOT CHANGE TO display:none.

     The bot trap in pinnacle-core/tracking.php DEPENDS ON THIS RULE. It is
     deliberately NOT display:none, because some bots skip undisplayed fields.

     IF THIS RULE IS LOST, real visitors see the trap field, fill it in, and
     EVERY SUBMISSION ON THE SITE IS REJECTED — silently, with a generic
     message. Keep the rule and the markup together (§7.1).
     ────────────────────────────────────────────────────────────────────── */
  .pinnacle-hp {
    position: absolute;
    inset-inline-start: -9999px;
    inline-size: 1px;
    block-size: 1px;
    overflow: hidden;
  }

  .field { margin-block-end: var(--s-3); }          /* 8px between fields */
  .field + .btn,
  .lead-form__heading + .field { margin-block-start: var(--s-5); } /* 16px between groups */

  .field label {
    display: block;
    margin-block-end: var(--s-1);
    font-size: var(--fs-small);
    font-weight: var(--fw-ui);
    color: var(--ink-900);
  }

  /* VISIBLE LABELS ALWAYS. Never placeholder-as-label — the placeholder
     vanishes on input, which is worst for the 35–65 buyer §8.3 is written for,
     and it fails the label requirement outright (§8.16). */
  /* INPUT TEXT IS PINNED AT 16px AND THIS IS NOT A STYLE CHOICE. Mobile Safari
     zooms the whole page in when a focused input's text is under 16px, and it
     does not zoom back out — so a small field turns the appointment form into a
     pan-and-scroll exercise at the moment of conversion (§7.2). It uses
     --fs-input, NOT --fs-body, precisely so that shrinking the body scale can
     never drag an input under the threshold by accident. The fields got
     shorter and tighter; their text did not get smaller. */
  .field input,
  .field select,
  .field textarea {
    inline-size: 100%;
    min-block-size: var(--field-h);
    padding: var(--s-2) var(--s-4);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    font-size: var(--fs-input);
    color: var(--ink-900);
  }

  .field textarea {
    min-block-size: calc(var(--field-h) * 2.5);
    padding-block: var(--s-3);
    resize: vertical;
  }

  /* On a fine pointer the input text steps down to the 12px UI baseline along
     with the box. It CANNOT do that on touch: under 16px iOS zooms the page on
     focus and does not zoom back (§8.3), which is the whole reason --fs-input
     is a separate token. The capability query is what makes the smaller size
     safe here rather than a bug. */
  @media (hover: hover) and (pointer: fine) {
    .field input,
    .field select,
    .field textarea {
      font-size: var(--fs-small);
      padding-block: 0;
    }
    .field textarea { padding-block: var(--s-2); }
  }

  /* One row: the input grows, the button takes what it needs. Used by the
     footer newsletter (§7.4), which is a single field plus its consent. */
  .lead-form__row {
    display: flex;
    align-items: flex-end;
    gap: var(--s-3);
  }

  .lead-form__row .field { flex: 1 1 auto; margin-block-end: 0; }
  /* THE ONE PLACE A BUTTON TAKES --field-h RATHER THAN --btn-h. It sits on one
     row beside the email input and the row is flex-end aligned, so a 32px
     Subscribe against a 40px field reads as a mistake rather than as compact
     (§7.4). Paired with a field, match the field. */
  .lead-form__row .btn { flex: 0 0 auto; min-block-size: var(--field-h); }

  /* Carries the CASL disclosure now that the newsletter has no consent
     checkbox (§7.4) — so it is legal text, not a caption, and must stay
     legible rather than fading into the footer. --ink-500 is the floor. */
  .lead-form__note {
    margin-block-start: var(--s-3);
    font-size: var(--fs-small);
    line-height: var(--lh-small);
    color: var(--ink-500);
  }

  .field input[aria-invalid="true"] { border-color: var(--error); }

  .field__hint {
    margin-block-start: var(--s-2);
    font-size: var(--fs-small);
    color: var(--ink-500);
  }

  /* ERRORS ARE NEVER COLOUR-ONLY — icon plus text (§8.15, §8.2). */
  .form-error {
    display: flex;
    align-items: center;
    gap: var(--s-2);
    margin-block-start: var(--s-2);
    font-size: var(--fs-small);
    color: var(--error);
  }
  .form-error::before {
    content: "!";
    display: grid;
    place-items: center;
    flex: 0 0 auto;
    inline-size: var(--s-6);
    block-size: var(--s-6);
    border: 1px solid var(--error);
    border-radius: var(--r-full);
    font-size: var(--fs-micro);
    font-weight: var(--fw-subhead);
  }

  /* Native controls styled, not rebuilt (§7.1, §8.14). */
  .field input[type="checkbox"],
  .field input[type="radio"],
  .consent input {
    accent-color: var(--accent);
    inline-size: var(--s-6);
    block-size: var(--s-6);
    min-block-size: 0;
  }

  .consent {
    display: flex;
    align-items: flex-start;
    gap: var(--s-3);
    font-size: var(--fs-small);
    cursor: pointer;
  }

  .lead-form { max-inline-size: var(--w-prose); }
  .lead-form__heading { font-size: var(--fs-h3); margin-block-end: var(--s-5); }

  .lead-form--done,
  .appointment--done,
  .favourite--done,
  .estimator--done {
    padding: var(--s-7);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-inline-start: 3px solid var(--success);
    border-radius: var(--r-md);
  }

  .lead-form__success-title,
  .appointment__success-title {
    font-size: var(--fs-h3);
    font-weight: var(--fw-subhead);
    color: var(--ink-900);
    margin-block-end: var(--s-3);
  }

  /* ---- Appointment (§7.2) ---------------------------------------------- *
     Month calendar with prev/next, times as one undivided list beside it
     from 768px, and step 2 as a PAGE that replaces step 1 — not a dropdown
     under the picker.                                                       */

  /* BOTH PAGES SHARE ONE WIDTH. It used to be fit-content on page 1 and grown
     to --w-prose on page 2 — but fit-content is measured from whatever is
     currently rendered, and the hidden page contributes nothing to that
     measurement, so the two pages landed at two different widths and the
     card visibly resized under the visitor mid-flow. Page 2's fields are
     already `inline-size: 100%` (§8.4), so they simply fill whatever width
     this card resolves to — a fixed `--appointment-w` from 480px (below),
     and 100% of the content column below that.

     BELOW 480px THAT HAS TO BE 100%, not fit-content. The calendar cancels
     this card's own --s-5 padding with a negative margin equal to
     --gutter-x (the "320px problem" note above `.calendar`) so its cells
     hit the tap-min floor — but --gutter-x and --s-5 are the same 16px only
     by coincidence, and a negative margin still counts toward a
     fit-content box's shrink-to-fit width. With `fit-content`, the browser
     sized the card to the calendar's OWN shrunk contribution (its content
     width minus those two negative margins), landing narrower than the
     page's actual content column — the card sat inset from the rest of the
     page even though the calendar inside it broke out to the true edge.
     `inline-size: 100%` fills the column directly, so the calendar's
     negative margin has the right thing to cancel. */
  .appointment {
    inline-size: 100%;
    padding: var(--s-5);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-md);
  }

  .appointment__panel { border: 0; padding: 0; margin: 0; min-inline-size: 0; }

  .appointment__times {
    display: flex;
    flex-direction: column;
    min-block-size: 0;
    min-inline-size: 0;
    border: 0;
    padding: 0;
    margin-block-start: var(--s-5);
    overflow: hidden;
  }

  /* Match the calendar month bar so "Time" and "August 2026" share a line.

     THIS USED TO BE A <legend> INSIDE A <fieldset>, styled with a `float` to
     escape the user agent's special legend rendering (a <legend> is normally
     positioned straddling the fieldset's border edge, ignoring the
     fieldset's own padding). `float` has no effect on a flex item, and
     `.appointment__times` is `display: flex` — so the float did nothing, and
     "Time" rendered off the shared baseline this rule is meant to produce.
     Rather than chase the exact legend/flex interaction across browsers,
     it's now a plain <p> inside a plain <div>: an ordinary flex child with
     no special-case rendering to fight, positioned by the shared
     min-block-size/padding/border below like everything else on the page. */
  .appointment__times > .appointment__legend {
    display: grid;
    place-items: center;
    min-block-size: var(--field-h);
    margin: 0;
    padding-block-end: var(--s-3);
    text-align: center;
  }

  .appointment__legend {
    padding: 0;
    margin: 0 0 var(--s-3);
    font-size: var(--fs-h4);
    font-weight: var(--fw-subhead);
    font-family: var(--font-body);
    line-height: var(--lh-heading);
    color: var(--ink-900);
  }

  /* Title | picker | action — hairlines, not empty padding, are what group
     the card (§8.6). Both panels' heading reads this the same way — page 2
     used to skip it (scoped to `[data-panel="1"]` only), so "Where and how
     to reach you" sat at the same weight as the fields under it instead of
     reading as the section it heads. */
  .appointment__panel > .appointment__legend {
    padding-block-end: var(--s-3);
    margin-block-end: var(--s-4);
    border-block-end: 1px solid var(--ink-900);
  }

  /* Page 1 ONLY: since the kicker merged into this heading (§9.1 em-dash
     fix's follow-up), it now reads as a full sentence rather than a short
     section title, so it drops the hairline and the bold weight the shared
     rule above gives every other panel heading — page 2's "Where and How to
     Reach You" is unaffected and keeps both. */
  .appointment__panel[data-panel="1"] > .appointment__legend {
    border-block-end: 0;
    font-weight: var(--fw-body);
  }

  /* "Book your free consultation:" carries the weight the heading lost
     above — the rest of the sentence ("pick a day and a time") stays
     regular so only the kicker's old meaning still reads as emphasised. */
  .appointment__legend-strong {
    font-weight: var(--fw-subhead);
  }

  /* A light border, same idiom as .btn--ghost: bare text read as a stray
     label rather than a control, at the one spot in the widget where going
     back is the only way to correct a date/time pick. */
  .appointment__back {
    display: inline-flex;
    align-items: center;
    gap: var(--s-2);
    min-block-size: var(--tap-min);
    margin: 0 0 var(--s-3);
    padding-inline: var(--s-3);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    background: none;
    font-size: var(--fs-small);
    font-weight: var(--fw-ui);
    color: var(--ink-700);
    cursor: pointer;
  }

  .appointment__back:hover {
    border-color: var(--ink-500);
    color: var(--ink-900);
    transform: translateY(var(--lift));
  }

  /* NO padding-block-start here, deliberately: .appointment__continue is a
     class on the SAME <button> element as .btn, and .btn already sets
     `padding-block: var(--s-1)` (both start and end). Adding
     padding-block-start here used to win the cascade for that one longhand
     (this rule comes later in the file) without touching padding-block-end,
     so the button's own top/bottom padding went from 2px/2px to 18px/2px —
     the button grew to fit that, and "Continue" rendered hard against the
     bottom edge instead of centred. margin-block-start is the gap before the
     rule; the rule then sits flush on the button's own top edge. */
  .appointment__continue {
    margin-block-start: var(--s-5);
    border-block-start: 1px solid var(--line);
  }

  .appointment__picked {
    margin-block-end: var(--s-5);
    padding: var(--s-3) var(--s-4);
    background-color: var(--bg-tint);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    font-size: var(--fs-small);
    font-weight: var(--fw-ui);
    font-variant-numeric: tabular-nums;
    color: var(--ink-900);
  }

  .appointment__picked:empty,
  .appointment__picked[hidden] { display: none; }

  .appointment__contact {
    display: grid;
    gap: var(--s-3);
  }

  /* Same "hairline before the action" idiom as `.appointment__continue`
     (§8.6) — a rule separating the contact fields from the submit button,
     not a class on `.btn` itself this time, so there's no risk of the same
     padding-block-start collision that button ran into: `data-submit` is a
     JS hook shared by every form's submit button (contact, estimator,
     favourites, newsletter), so this stays scoped to the appointment
     widget's own page 2 rather than styling every other form's button. */
  .appointment__panel[data-panel="2"] [data-submit] {
    margin-block-start: var(--s-5);
    border-block-start: 1px solid var(--line);
  }

  /* JS on: hidden means hidden. JS off: both pages stack and Back/Continue
     (which do nothing without the script) stay out of the way (§8.17). */
  .appointment[data-pages-ready] [data-panel][hidden] { display: none; }

  .appointment:not([data-pages-ready]) [data-panel="1"][hidden] { display: block; }

  .appointment:not([data-pages-ready]) [data-panel="2"][hidden] { display: block; }

  .appointment:not([data-pages-ready]) .appointment__back,
  .appointment:not([data-pages-ready]) .appointment__continue { display: none; }

  /* ---- Month calendar (§7.2) ------------------------------------------- *
     These cells are the highest-risk element on the site — small, on mobile,
     at the exact moment of conversion — so the 44px floor (§8.4) cannot bend.

     THE 320px PROBLEM: seven columns at 44px need 308px. At the 320px floor
     the page gutter leaves 288px, so below `sm` the calendar cancels the
     gutter and runs edge to edge. This is the one component allowed to.     */

  .calendar {
    margin-inline: calc(var(--gutter-x) * -1);
    background-color: var(--surface);
    border-block: 1px solid var(--line);
    padding-block: var(--s-4);
  }

  .calendar__bar {
    display: grid;
    grid-template-columns: var(--field-h) minmax(0, 1fr) var(--field-h);
    align-items: center;
    gap: var(--s-3);
    padding-inline: var(--s-4);
    padding-block-end: var(--s-3);
    margin-block-end: var(--s-3);
    border-block-end: 1px solid var(--ink-900);
  }

  .calendar__label {
    text-align: center;
    font-weight: var(--fw-subhead);
    font-size: var(--fs-h4);
    color: var(--ink-900);
  }

  .calendar__nav {
    display: grid;
    place-items: center;
    inline-size: var(--field-h);
    block-size: var(--field-h);
    background: none;
    border: 0;
    padding: 0;
    border-radius: var(--r-sm);
    color: var(--ink-700);
    cursor: pointer;
  }

  /* visibility, not display: the bar's three grid tracks (prev/label/next)
     must hold their place so the label stays centred when an end arrow has
     nowhere to go — display:none would collapse the track and pull the
     label off-centre toward whichever arrow is still showing. */
  .calendar__nav[disabled] {
    visibility: hidden;
  }

  .calendar__weekdays,
  .calendar__grid {
    display: grid;
    grid-template-columns: repeat(7, var(--tap-min));
    inline-size: calc(var(--tap-min) * 7);
    margin-inline: auto;
  }

  /* THE MONTH IS DRAWN AS A GRID, not as a field of floating chips. One frame
     around the whole block, then a rule between every cell — which is what a
     date picker is expected to look like and what makes a week scan as a row.

     THE RULES ARE box-shadow, NOT border, and that is deliberate: a border
     would eat into each cell's 44px box and drop the touch target under the
     §8.4 floor, which is the one thing this component may not do. A shadow
     paints in the same place and costs no layout. */
  /* No border-block-start: the calendar bar above already ends in its own
     hairline (`.calendar__bar`'s border-block-end), var(--s-3) above this.
     A top border here as well read as a second, fainter line right under
     it — this frame only needs to hold the sides and bottom. */
  /* THE FRAME'S OWN HAIRLINES HAVE TO BE IN THIS NUMBER. Everything here is
     `box-sizing: border-box`, so declaring seven columns' worth of width on an
     element that also carries a 1px border on each side leaves a CONTENT box
     2px narrower than the grid inside it — and with `overflow: hidden` below,
     those 2px came off the last column. Wide enough to go unnoticed on a plain
     date, but the today ring is `inset 0 0 0 2px`, so its right edge was shaved
     off completely whenever today fell in the final column.
     `--s-1` is 2px: one hairline per side. `--appointment-w` carries the same
     two pixels so the panel around this still fits it exactly. */
  .calendar__body {
    inline-size: calc(var(--tap-min) * 7 + var(--s-1));
    margin-inline: auto;
    border-inline: 1px solid var(--line);
    border-block-end: 1px solid var(--ink-900);
    overflow: hidden;
  }

  /* THE DAY ROW IS THE HEADER, so it reads as one: a recessed fill, full ink
     and UI weight, held off the dates by its own rule. At --ink-500 on white
     it was the faintest thing in the widget while being the row that tells a
     visitor which column is which. */
  .calendar__weekdays {
    background-color: var(--bg-alt);
    border-block-end: 1px solid var(--ink-900);
    margin-inline: 0;
  }

  /* PINNED, not organic. Every other row in the calendar is an explicit
     var(--tap-min) — this was the one left to sink to its own padding plus
     line-height, which is also what left the time column (below) with
     nothing to match: at 768px+ the first time slot sat flush under "Time",
     level with this row instead of with the actual first date row beneath
     it. Fixing the height here is what gives the time column a number to
     reproduce as its own spacer, rather than guessing at a font metric. */
  .calendar__weekday {
    display: grid;
    place-items: center;
    min-block-size: var(--s-8);
    font-size: var(--fs-micro);
    font-weight: var(--fw-ui);
    text-transform: uppercase;
    letter-spacing: var(--tracking-micro);
    color: var(--ink-900);
    box-shadow: 1px 0 0 var(--line);
  }

  /* The last column's rule would land on the frame's own edge. */
  .calendar__weekday:last-child { box-shadow: none; }

  .calendar__weekday abbr { text-decoration: none; }

  .calendar__grid { margin-inline: 0; }

  .calendar__grid[data-cal-hidden] { display: none; }

  .calendar__pad {
    block-size: var(--tap-min);
    box-shadow: 1px 1px 0 0 var(--line);
  }

  .calendar__day {
    position: relative;
    display: grid;
    place-items: center;
    block-size: var(--tap-min);
    cursor: pointer;
    font-size: var(--fs-small);
    color: var(--ink-700);
    box-shadow: 1px 1px 0 0 var(--line);
  }

  /* THE GRID IS THE STRUCTURE NOW, so the number fills its cell rather than
     sitting in it as a chip with a second border and fill of its own. A
     selected day is a highlighted square in the grid, which is what a
     bordered month wants; a floating chip inside a ruled cell is two
     competing frames at 44px. */
  .calendar__num {
    display: grid;
    place-items: center;
    inline-size: 100%;
    block-size: 100%;
    font-variant-numeric: tabular-nums;
  }

  .calendar__day input {
    position: absolute;
    opacity: 0;
    inset: 0;
    inline-size: 100%;
    block-size: 100%;
    cursor: pointer;
  }

  /* Unavailable days keep their cell so the grid has no holes, and recede
     behind an actual fill — not text colour alone — so the bookable range
     reads as the lit-up cells against these closed ones, not the other way
     around. */
  .calendar__day.is-unavailable {
    background-color: var(--bg-tint);
    color: color-mix(in srgb, var(--ink-500) 55%, var(--bg-tint));
    cursor: default;
  }

  button.calendar__day.is-unavailable {
    inline-size: 100%;
    padding: 0;
    border: 0;
    font: inherit;
    cursor: pointer;
  }

  button.calendar__day.is-unavailable:focus-visible .calendar__num {
    outline: var(--focus-ring);
    outline-offset: var(--focus-offset);
  }

  .appointment__toast {
    position: fixed;
    inset-inline: var(--s-4);
    inset-block-end: var(--s-4);
    z-index: var(--z-toast);
    display: flex;
    align-items: center;
    gap: var(--s-2);
    margin-inline: auto;
    padding: var(--s-3) var(--s-4);
    border: 1px solid var(--line);
    background-color: var(--surface);
    color: var(--ink-900);
    box-shadow: var(--shadow-md);
  }

  .appointment__toast[hidden] {
    display: none;
  }

  /* Today gets a ring, not a fill — --brass stays hairline/icon-detail only,
     never a block of colour behind text (tokens.css), and a ring reads as
     "today" without competing with the solid --accent fill a selected day
     gets, including when today IS the selected day. */
  .calendar__day.is-today .calendar__num {
    box-shadow: inset 0 0 0 2px var(--brass);
  }

  /* --bg-tint, not --bg-alt: --bg-alt is the weekday header's fill now, and a
     hovered date taking it made the date look like part of that row. */
  .calendar__day:not(.is-unavailable):hover .calendar__num {
    background-color: var(--bg-tint);
  }

  .calendar__day:has(input:checked) .calendar__num {
    background-color: var(--accent);
    color: var(--surface);
    font-weight: var(--fw-subhead);
  }

  .calendar__day:has(input:checked):hover .calendar__num {
    background-color: var(--accent-hover);
  }

  .calendar__day:has(input:focus-visible) .calendar__num {
    outline: var(--focus-ring);
    outline-offset: var(--focus-offset);
  }

  .calendar:not([data-cal-ready]) .calendar__grid[data-cal-hidden] { display: grid; }
  .calendar:not([data-cal-ready]) .calendar__bar { display: none; }

  @media (min-width: 480px) {
    /* ONE WIDTH FOR BOTH PAGES, derived from the calendar's own fixed size
       rather than measured from whichever page happens to be visible (see
       the note on `.appointment` above). Page 2's fields fill it at 100%. */
    .appointment {
      --appointment-w: calc(
        ( var(--tap-min) * 7 ) + var(--s-1) + ( var(--s-4) * 2 ) + ( var(--s-5) * 2 )
      );
      inline-size: var(--appointment-w);
      max-inline-size: 100%;
    }

    /* One hairline frame around date AND time, split internally — two
       separate boxes made the picker read as two forms. */
    .appointment__when {
      border: 1px solid var(--line);
      border-radius: var(--r-md);
      overflow: hidden;
    }

    .calendar {
      margin-inline: 0;
      inline-size: fit-content;
      max-inline-size: 100%;
      border: 0;
      border-block-end: 1px solid var(--ink-900);
      border-radius: 0;
      padding: var(--s-4);
    }
    .calendar__bar { padding-inline: 0; }

    .appointment__times {
      margin-block-start: 0;
      padding: var(--s-4);
    }
  }

  /* ---- Time list: one row, no morning/afternoon (§7.2) ----------------- *
     A HORIZONTAL SCROLLER below 768px, same idiom as the product gallery's
     thumbnail strip (`.product-gallery__thumbs`) and the catalogue rails
     (`.rail__track`) — one row, `overflow-x: auto`, snap-to-chip, rather
     than stacking every slot down the page. A 3-across grid was tried
     first and dropped: it didn't change the chip's SHAPE so much as
     flatten it, squeezing "10:30 AM" into a ~90px cell to fit three per
     row. Each chip keeps a fixed minimum width here instead, so the row
     scrolls at a consistent rhythm rather than jumping between narrow and
     wide chips as the label text varies.

     `overflow-y` HAS TO BE SET EXPLICITLY, not left at its default
     `visible`: per the CSS overflow spec, when one axis is `auto`/`hidden`/
     `scroll` and the other is left `visible`, the `visible` one computes to
     `auto` instead — so `overflow-x: auto` alone was quietly turning
     `overflow-y` into `auto` too. `.time-chip::after` below extends each
     chip's real tap target from --btn-h up to the full --tap-min AAA floor
     (§7.2, tokens.css) by painting 6px above and below the chip's own box —
     harmless while `.time-chip` itself doesn't clip, but that 6px on every
     chip is exactly the kind of vertical content this row's now-implicit
     `overflow-y: auto` was finding, so the row picked up a vertical
     scrollbar of its own for 12px of invisible tap-target padding. Reserving
     that same 6px as real `padding-block` here means nothing escapes
     `.time-list`'s box in the first place, and `overflow-y: hidden` makes
     the axis explicit so this can't recur silently if a future change adds
     something taller. */
  .time-list {
    display: flex;
    gap: var(--s-2);
    min-block-size: 0;
    padding-block: calc( ( var(--tap-min) - var(--btn-h) ) / 2 );
    overflow-x: auto;
    overflow-y: hidden;
    scroll-snap-type: inline mandatory;
  }

  .time-chip {
    flex: 0 0 auto;
    min-inline-size: var(--s-12);
    scroll-snap-align: start;
  }

  .time-chip,
  .choice-chip {
    position: relative;
    display: grid;
    place-items: center;
    min-block-size: var(--btn-h);
    padding-inline: var(--s-3);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    background-color: var(--bg-tint);
    cursor: pointer;
    font-size: var(--fs-small);
    font-weight: var(--fw-ui);
    font-variant-numeric: tabular-nums;
    color: var(--ink-700);
    text-align: center;
  }

  .time-chip::after,
  .choice-chip::after {
    content: "";
    position: absolute;
    inset-inline: 0;
    inset-block: calc( ( var(--tap-min) - var(--btn-h) ) / -2 );
  }

  .time-chip input,
  .choice-chip input {
    position: absolute;
    opacity: 0;
    inset: 0;
    cursor: pointer;
  }

  .time-chip:hover,
  .choice-chip:hover { background-color: var(--bg-alt); }

  .time-chip:has(input:checked),
  .choice-chip:has(input:checked) {
    background-color: var(--accent);
    border-color: var(--accent);
    color: var(--surface);
    z-index: var(--z-raised);
  }

  .time-chip:has(input:checked):hover,
  .choice-chip:has(input:checked):hover {
    background-color: var(--accent-hover);
    border-color: var(--accent-hover);
    color: var(--surface);
  }

  .time-chip:has(input:focus-visible),
  .choice-chip:has(input:focus-visible) {
    outline: var(--focus-ring);
    outline-offset: var(--focus-offset);
  }

  /* Same hairline idiom as the rest of the widget (§8.6): a rule under the
     "what are you planning" chips separates that question from the address/
     contact fields that answer a different one ("where and how to reach
     you"), rather than leaving the two to run together as one grey block. */
  .choice-chips {
    border: 0;
    border-block-end: 1px solid var(--ink-900);
    padding: 0 0 var(--s-4);
    margin: 0 0 var(--s-5);
  }

  .choice-chips__label {
    padding: 0;
    margin-block-end: var(--s-2);
    font-size: var(--fs-small);
    font-weight: var(--fw-ui);
    color: var(--ink-900);
  }

  .choice-chips__row {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-2);
  }

  .choice-chip { flex: 1 1 auto; }

  @media (min-width: 768px) {
    /* Widen the fixed width from the 480px block to match the calendar AND
       the time column now sitting beside it — still the same one width for
       both pages, just recomputed for the two-column layout below. */
    .appointment {
      padding: var(--s-5);
      --appointment-w: calc(
        ( var(--tap-min) * 7 ) + var(--s-1) + ( var(--s-4) * 2 ) + 1px +
        ( var(--tap-min) * 3 ) + ( var(--s-4) * 2 ) +
        ( var(--s-5) * 2 )
      );
      inline-size: var(--appointment-w);
      max-inline-size: 100%;
    }

    .appointment__when {
      display: grid;
      grid-template-columns: max-content max-content;
      justify-content: start;
      gap: 0;
      align-items: stretch;
      inline-size: max-content;
    }

    .calendar {
      padding: var(--s-4);
      border: 0;
      border-block-end: 0;
      border-inline-end: 1px solid var(--ink-900);
      border-radius: 0;
    }

    /* Date and time start on the SAME LINE at this width. Both bars declare
       the same height and padding, so their baselines meet across the
       divider instead of the time column riding a step higher — the
       calendar side keeps its own hairline (`.calendar__bar` alone, below);
       box-sizing: border-box means dropping it on the time side doesn't
       change this shared height. */
    .calendar__bar,
    .appointment__times > .appointment__legend {
      min-block-size: var(--field-h);
      margin: 0;
      padding-block-end: var(--s-3);
    }

    .calendar__bar {
      padding-inline: 0;
      border-block-end: 1px solid var(--ink-900);
    }

    .appointment__times > .appointment__legend {
      place-items: center;
      text-align: center;
    }

    .appointment__times { margin-block-start: 0; }

    /* THE HAIRLINE ISN'T THE LAST THING BEFORE "Su Mo Tu": `.calendar__bar`
       adds `margin-block-end: var(--s-3)` after its own hairline before
       `.calendar__body` (which wraps the weekday row) begins — so "Su Mo Tu"
       starts `--s-3` below the hairline, not flush against it.
       `.appointment__times` has no equivalent gap of its own, so this margin
       reproduces just that much space, landing "9:00 AM" level with "Su Mo
       Tu" — the first thing with visible content in both columns — rather
       than starting higher than it.

       THE BOTTOM USED TO BE A GUESS, NOT A MATCH: `max-block-size` was
       `--tap-min * 6 + --s-7`, a stand-in for "how tall does a 6-row month
       render" that only worked for months that actually draw 6 rows, and
       even then didn't reproduce the calendar's true height (bar + weekday
       row + rules) closely enough — the list fell short of the calendar's
       bottom edge instead of matching it. `.appointment__times` already
       stretches to the calendar's actual rendered height (grid
       `align-items: stretch` on `.appointment__when`), so growing into that
       real space is exact for every month, 5-row or 6-row, instead of
       reproducing it by arithmetic. It still scrolls if the slot list
       (§3.11, editable) is ever taller than the space that leaves. */
    .time-list {
      display: flex;
      flex-direction: column;
      gap: var(--s-1);
      margin-block-start: var(--s-3);
      inline-size: max-content;
      min-inline-size: calc(var(--tap-min) * 3);
      flex: 1 1 0;
      min-block-size: 0;
      overflow-x: hidden;
      overflow-y: auto;
      overscroll-behavior: contain;
      padding: 0;
      background-color: transparent;
      border: 0;
      border-radius: 0;
    }

    .time-chip {
      flex: 0 0 auto;
      justify-content: start;
      min-block-size: var(--tap-min);
    }

    .time-chip::after { content: none; }

    .appointment__contact {
      grid-template-columns: repeat(2, minmax(0, 1fr));
    }
  }

  /* ---- Favourites (§7.5) ------------------------------------------------ */

  /* A STAR IN THE TOOL STRIP, not a labelled button (§7.5). The form itself is
     `display: contents` so the star sits as a direct child of the strip in
     §5.8 and shares its single frame — a form wrapper would otherwise put a
     block between the strip and its buttons and break the row. */
  .favourite { display: contents; }

  .favourite__star { position: relative; }

  /* GREY, NOT BRASS: a pressed control reads as "chosen" against a neutral
     ink, not a warm accent colour — the same reasoning as the compare and
     density-switch pressed states (§5.7, §8.2). --accent itself stays off
     this control entirely; it belongs to the one primary action per
     viewport, which on this page is Book. State is never colour alone —
     aria-pressed and the swapped label carry it too. */
  .favourite__star[aria-pressed="true"] { color: var(--ink-900); }

  /* THE TOOLTIP IS AN AFFORDANCE, NOT THE NAME. It is aria-hidden and the
     screen-reader label beside it does the naming (§8.16), so this can be
     purely visual. Shown on hover AND on focus-visible — a tooltip that only
     answers a mouse leaves a keyboard visitor with an unexplained mark. */
  .favourite__tip {
    position: absolute;
    inset-block-end: calc(100% + var(--s-2));
    inset-inline-start: 50%;
    translate: -50% 0;
    z-index: var(--z-raised);
    padding: var(--s-1) var(--s-3);
    background-color: var(--ink-900);
    color: var(--bg);
    border-radius: var(--r-sm);
    font-size: var(--fs-micro);
    white-space: nowrap;
    opacity: 0;
    pointer-events: none;
    transition: opacity var(--dur-fast) var(--ease);
  }

  .favourite__star:hover .favourite__tip,
  .favourite__star:focus-visible .favourite__tip { opacity: 1; }

  .favourite__note { margin-block-end: var(--s-4); font-size: var(--fs-small); color: var(--ink-500); }

  /* ---- Modal panels (§8.14, §7.5, §5.10) --------------------------------- *
     A <dialog> built by forms.js around a panel that is authored visible, so
     the form still works with the script absent (§8.17). showModal() supplies
     the focus trap, Esc, the backdrop and inertness — none of it is
     reimplemented here (§8.8). */

  /* CENTRED ON THE PAGE, BOTH AXES, AND STATED EXPLICITLY.
     A modal <dialog> is centred by the user agent's `margin: auto` — but that
     only holds while nothing else sets a margin, and `max-inline-size` alone is
     enough to make a UA `margin: auto` behave differently between engines. The
     inset/translate pair below pins it to the middle of the VIEWPORT rather
     than to a box the browser sized, so it lands in the same place whatever
     the content height and whatever else the page is doing. */
  .modal {
    position: fixed;
    inset-block-start: 50%;
    inset-inline-start: 50%;
    translate: -50% -50%;
    margin: 0;
    padding: 0;
    border: 0;
    background: transparent;
    inline-size: min(28rem, calc(100% - var(--s-8)));
    max-inline-size: min(28rem, calc(100% - var(--s-8)));
    max-block-size: calc(100dvh - var(--s-8));
    overflow: auto;
  }

  .modal::backdrop { background-color: color-mix(in srgb, var(--ink-900) 55%, transparent); }

  .modal__box {
    position: relative;
    padding: var(--s-6);
    background-color: var(--surface);
    border-radius: var(--r-md);
    box-shadow: var(--shadow-md);
  }

  .modal__head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--s-4);
    margin-block-end: var(--s-5);
    padding-block-end: var(--s-4);
    border-block-end: 1px solid var(--ink-900);
  }

  .modal__title { font-size: var(--fs-h3); }

  /* SELF-CONTAINED, NOT BORROWED FROM .icon-btn. That class lives in
     catalogue.css, loaded only where pinnacle_is_catalogue() is true — fine
     for the favourites/estimator dialogs this button already served, since
     both only ever render on product pages, but this modal system is also
     used by the submission-result modal (§7.1) on the Contact page and the
     home/appointment widget, neither of which loads catalogue.css. Giving
     .modal__close its own rule removes that implicit cross-file dependency
     rather than working by coincidence of where the two callers so far
     happen to render. */
  .modal__close {
    display: grid;
    place-items: center;
    flex: 0 0 auto;
    inline-size: var(--field-h);
    block-size: var(--field-h);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    color: var(--ink-700);
    cursor: pointer;
  }
  .modal__close:hover { border-color: var(--ink-500); }

  /* A close button with no .modal__head to sit in — the result modal has no
     title bar, just the confirmation content — floats top-right of the box
     instead. Distinguished purely structurally (a DIRECT child of the box,
     never true when a .modal__head is present) rather than a second class,
     so nothing has to remember to toggle a modifier. */
  .modal__box > .modal__close {
    position: absolute;
    inset-block-start: var(--s-4);
    inset-inline-end: var(--s-4);
  }

  /* THE DIALOG SCALES AND FADES IN, not just appears (§7.1). @starting-style
     is what gives a *newly shown* [open] dialog a "before" state to animate
     FROM — this benefits every .modal on the site (favourites, estimator,
     the submission-result modal below) from one rule, matching "one pattern,
     not two" (§7.1). No exit animation: closing stays instant, same as
     before — animating a native <dialog>'s close needs `transition-behavior:
     allow-discrete` on `display`/`overlay` too, which is more moving parts
     than a close deserves. Degrades to an instant show on any engine without
     @starting-style support (§8.17) — never a regression, only ever an
     enhancement. */
  @starting-style {
    .modal[open] { opacity: 0; scale: .96; }
  }
  .modal[open] {
    opacity: 1;
    scale: 1;
    transition: opacity var(--dur-base) var(--ease), scale var(--dur-base) var(--ease);
  }

  /* ---- Submission result (§7.1) ------------------------------------------ *
     Shown after a fetch-based submit succeeds: either morphed into an
     already-open field-collection dialog (favourites, estimator) or a fresh
     .modal layered over the page's own already-updated inline state
     (contact, newsletter, appointment) — forms.js decides which. Reuses
     .modal/.modal__box unchanged; this is the one new visual piece. */
  .result-check {
    display: grid;
    place-items: center;
    inline-size: var(--s-11);
    block-size: var(--s-11);
    margin-inline: auto;
    margin-block-end: var(--s-5);
    background-color: color-mix(in srgb, var(--success) 12%, var(--surface));
    border-radius: var(--r-full);
    color: var(--success);
    transform: scale(0);
    transition: transform var(--dur-slow) var(--ease);
  }
  .result-check.is-shown { transform: scale(1); }

  /* A SHORT VIEWPORT GETS A FULL-SCREEN SHEET, not a centred box taller than
     the screen — that is the unclosable-modal failure §8.8 exists to prevent
     (§8.13). */
  @media (max-height: 640px) {
    .modal {
      inset-block-start: 0;
      inset-inline-start: 0;
      translate: none;
      max-inline-size: 100%;
      inline-size: 100%;
      max-block-size: 100%;
      block-size: 100%;
      margin: 0;
    }
    .modal__box {
      block-size: 100%;
      overflow-y: auto;
      border-radius: 0;
    }
  }

  /* ---- Estimator (§5.10) ------------------------------------------------ */

  /* 20px of padding, not 24, and 20 above/below rather than 32. It is a
     calculator with three inputs, not a section — it was reading as the
     largest object in the summary column and pushing the action strip and the
     specs apart (§8.4). */
  /* THE ONE DARK SURFACE ON THE SITE, by request. --ink-900 as a fill rather
     than the recessed --bg-alt band every other panel uses — everything that
     sits directly against it below (heading, field labels, the result
     figures, the hairlines between rows, the focus ring) reads the
     --*-on-dark tokens in tokens.css instead of their normal light-surface
     values. Anything with its OWN opaque fill — a --surface input box, the
     solid-red "Book a free measure" button — needs no change: its contrast
     is against that fill, not against this one. Scoped to .estimator only;
     every other form on the site is untouched. */
  .estimator {
    display: flex;
    flex-direction: column;
    margin-block: var(--s-6);
    padding: var(--s-6);
    background-color: var(--ink-900);
    border-radius: var(--r-md);
    /* --focus-ring is --accent's outline everywhere else; --accent itself
       falls under the 3:1 non-text floor against this fill (§8.2, tokens.css
       on-dark note), so this is redefined here rather than on :root — the
       redefinition cascades to every var(--focus-ring) read inside this
       element (the global :focus-visible rule, .unit-toggle's own) without
       touching --accent itself, which the solid-red button still needs at
       full strength. */
    --focus-ring: 1px solid var(--accent-on-dark);
  }

  .estimator__heading { color: var(--ink-on-dark); }

  .estimator .field label { color: var(--ink-on-dark); }

  .estimator .form-error { color: var(--error-on-dark); }
  .estimator .form-error::before { border-color: var(--error-on-dark); }

  /* Title + unit toggle on one row. The segmented control sits at the
     inline end of the heading so the unit choice is a switch, not a
     second field below the title. */
  .estimator__head {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    column-gap: var(--s-4);
    row-gap: var(--s-3);
    margin-block-end: var(--s-5);
  }

  .estimator__heading {
    flex: 1 1 auto;
    margin: 0;
    font-size: var(--fs-h3);
  }

  .estimator__units {
    display: inline-flex;
    flex: 0 0 auto;
    margin-inline-start: auto;
    margin-block: 0;
    padding: var(--s-1);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    background-color: var(--surface);
    min-inline-size: 0;
  }

  .unit-toggle {
    position: relative;
    display: grid;
    place-items: center;
    /* A step shorter than --btn-h (32px), not the token itself — this is one
       segmented control, not every button on the site. Composed from tokens
       rather than a raw literal (§3.8). */
    min-block-size: calc(var(--btn-h) - var(--s-2));
    padding-inline: var(--s-4);
    background-color: transparent;
    border: 0;
    border-radius: var(--r-sm);
    font-size: var(--fs-small);
    color: var(--ink-500);
    cursor: pointer;
  }
  .unit-toggle input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
  .unit-toggle:has(input:checked) {
    background-color: var(--ink-900);
    color: var(--surface);
    font-weight: var(--fw-ui);
  }
  .unit-toggle:has(input:focus-visible) {
    outline: var(--focus-ring);
    outline-offset: var(--focus-offset);
  }

  /* RESET AND CALCULATE ON ONE ROW, aligned to the inline end — the same
     "quiet action first, primary action last" ordering as .estimator__actions
     below. Reset ships hidden and JS reveals it once a value is entered
     (§7.1); when it never appears, Calculate still sits at the inline end on
     its own, unchanged from before this row wrapper existed. */
  .estimator__step1-actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: var(--s-3);
    margin-block-start: var(--s-5);
  }

  /* GHOST BUTTONS ON THE RECESSED PANEL. .estimator's fill is --bg-alt and
     .btn--ghost's border is --line, which against that fill is about 1.1:1 —
     so Calculate and "Email me this" read as having a default, invisible
     browser border rather than a deliberate one. A --surface fill lifts them
     off the band and an --ink-500 hairline is a border a visitor can actually
     see; hover deepens both rather than only nudging the border a shade. */
  .estimator .btn--ghost {
    background-color: var(--surface);
    border-color: var(--ink-500);
    color: var(--ink-900);
  }
  .estimator .btn--ghost:hover {
    background-color: var(--bg-tint);
    border-color: var(--ink-900);
    color: var(--ink-900);
  }
  /* Pressed reads as pressed: the lift is dropped, not doubled. */
  .estimator .btn--ghost:active {
    background-color: var(--line);
    transform: none;
  }

  .estimator__inputs { display: grid; gap: var(--s-4); }

  .estimator__result {
    margin-block-start: var(--s-7);
    padding-block-start: var(--s-7);
    border-block-start: 1px solid var(--line-on-dark);
  }

  /* THE RESULT IS A TABLE OF LABELLED FIGURES (§5.10). Label inline-start,
     figure inline-end, one rule between rows — so "Space area", "Wastage",
     "Unit price" and "Total cost" are each findable at a glance instead of
     being read out of one sentence. */
  .estimator__breakdown {
    display: grid;
    gap: 0;
    margin: 0;
  }

  .estimator__row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    gap: var(--s-4);
    align-items: baseline;
    padding-block: var(--s-3);
    border-block-end: 1px solid var(--line-on-dark);
  }

  /* The last row's rule would double the disclaimer's own spacing. */
  .estimator__row:last-child { border-block-end: 0; }

  .estimator__row dt {
    margin: 0;
    font-size: var(--fs-small);
    font-weight: var(--fw-ui);
    color: var(--ink-on-dark-muted);
  }

  .estimator__row dd {
    margin: 0;
    font-size: var(--fs-small);
    font-weight: var(--fw-subhead);
    color: var(--ink-on-dark);
    font-variant-numeric: tabular-nums;
    text-align: end;
  }

  /* The total is the answer, so it is the one row at --fs-price — and its
     LABEL steps up with it, or the figure floats beside 12px text. */
  .estimator__row--total dt,
  .estimator__row--total .estimate-out {
    font-size: var(--fs-price);
    font-weight: var(--fw-subhead);
    color: var(--ink-on-dark);
  }

  .estimate-out {
    font-size: var(--fs-price);
    font-weight: var(--fw-subhead);
    color: var(--ink-on-dark);
  }

  .estimator__disclaimer {
    margin-block: var(--s-4);
    font-size: var(--fs-small);
    color: var(--ink-on-dark-muted);
  }

  /* BOTH ACTIONS ON ONE ROW, directly under the result (§5.10, §8.10 #1),
     aligned to the inline end with Calculate. Wraps rather than shrinking
     below 360px — two buttons squeezed onto one line is how a primary CTA
     ends up narrower than its own label. */
  .estimator__actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: var(--s-3);
    margin-block-start: var(--s-5);
  }

  /* Inside the dialog this needs no rule of its own; the modal head already
     separates it. The border only applies to the no-JS inline fallback, where
     it is still a panel appended to the result. */
  .estimator__keep { margin-block-start: var(--s-5); }
  .estimator__keep-note {
    margin-block-end: var(--s-4);
    font-size: var(--fs-small);
    color: var(--ink-500);
  }

  /* ---- Breadcrumbs (§9.4) ----------------------------------------------- */

  /* The trail INTRODUCES the page head — it is not a band of its own. It used
     to carry 16px above and below and then hand over to a page head with its
     own 48/80px top padding, which put ~112px of empty page between the header
     and the H1 and left the trail floating in the middle of it. It now sits
     tight to the heading it belongs to, and the page head absorbs the rest. */
  .breadcrumbs {
    font-size: var(--fs-small);
    line-height: var(--lh-small);
    color: var(--ink-500);
  }

  .breadcrumbs__list {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--s-2);          /* 4px — the row gap when the trail wraps      */
    padding-block: var(--s-5) 0;
    /* margin-BLOCK, never the `margin` shorthand. This element also carries
       `.container`, whose `margin-inline: auto` is what centres it — and the
       components layer beats the layout layer, so a blanket `margin: 0` here
       silently overrode it. Above --w-max the trail pinned to the viewport
       edge while every other block on the page stayed centred, which is the
       left-edge misalignment. Any element carrying `.container` must leave its
       inline margins alone. */
    margin-block: 0;
    list-style: none;
  }

  /* 8px each side of the separator, so a wrapped trail keeps an even rhythm
     instead of the 4px/4px pinch the shared list gap produced. */
  .breadcrumbs__item { display: inline-flex; align-items: center; gap: var(--s-3); }
  .breadcrumbs__item + .breadcrumbs__item { margin-inline-start: var(--s-1); }
  .breadcrumbs__link { text-decoration: none; }
  .breadcrumbs__link:hover { text-decoration: underline; }
  .breadcrumbs__current { color: var(--ink-700); font-weight: var(--fw-ui); }

  /* Was --line (1.24:1 on --bg) — effectively invisible, so the trail read as
     four unrelated words. --ink-500 mixed back toward the page keeps it
     subordinate to the labels without disappearing. */
  .breadcrumbs__sep {
    display: inline-grid;
    place-items: center;
    color: color-mix(in srgb, var(--ink-500) 60%, var(--bg-tint));
  }

  /* Nested in the category hero copy column: the hero already provides the
     padding, and the list must not also carry .container or it double-gutters. */
  .breadcrumbs--embedded .breadcrumbs__list {
    padding-block: 0;
  }

  /* ---- Post list -------------------------------------------------------- */

  .post-list {
    display: grid;
    gap: var(--s-8);
    /* The hero above (.page-hero) now paints its own bottom boundary and
       sits outside .container, so this supplies its own top breathing room
       rather than inheriting it from a .page-head that used to sit here. */
    margin-block-start: var(--section-y);
  }

  /* index.php's wrapper (the blog index, search results, and any other
     fallback archive): closes the page itself, the same reason
     .catalogue__main does — whether .post-list, the pagination, or the
     .empty-state branch renders last, .site-main no longer supplies a
     shared page-level bottom padding. */
  .post-index { padding-block-end: var(--section-y); }

  .post-card {
    display: grid;
    gap: var(--s-5);
    padding-block-end: var(--s-8);
    border-block-end: 1px solid var(--ink-900);
  }

  .post-card__img {
    inline-size: 100%;
    aspect-ratio: 3 / 2;
    object-fit: cover;
    border-radius: var(--r-md);
  }

  .post-card__title a { text-decoration: none; }
  .post-card__title a:hover { text-decoration: underline; }

  .post-card__meta {
    font-size: var(--fs-small);
    color: var(--ink-500);
  }

  .post-card__excerpt { margin-block-start: var(--s-3); }

  /* =========================================================================
     HOME (§6.1)
     ====================================================================== */

  .home-hero {
    position: relative;
    display: grid;
    grid-template-rows: minmax(0, 1fr) auto;
    /* FIXED, not min or max: the hero must always exactly cover one screen
       — the hero-film strip is meant to sit right at the bottom edge of the
       first viewport, not float somewhere above it with the next section
       peeking in below. A min-block-size undersells short content (this is
       what let the hero come up shorter than the viewport); a max-block-size
       fixes that but reopens the opposite problem: content taller than the
       cap on a short screen just gets clipped. Fixed height, plus enough
       real content (title now wrapping up to 3 lines, a bigger flag) that
       there's little slack left to manage either way, is the actual fix —
       align-content: start below decides where any slack that remains
       shows up. */
    block-size: calc(100svh - var(--chrome-h));
    overflow: hidden;
    /* Top: breathing room under the header, stepping up with the viewport
       like any section rhythm (§8.4) — 20px mobile, 40px from lg. Bottom
       clears the floating CTA (§9.8), a fixed 48px disc inset --s-5 (16px)
       from the true edge; the hero-film strip's own band sits above this
       padding too, so 48px is enough without stacking a second full
       clearance allowance on top of it. Flat, since the disc doesn't
       change size by breakpoint. */
    padding-block-start: var(--s-6);  /* 20px mobile */
    padding-block-end: var(--s-10);   /* 48px, flat */
    background-color: var(--bg);
  }

  .home-hero__upper {
    display: grid;
    /* CENTER, not start: the gap above the copy (to the header) and the gap
       below it (to the hero-film strip) read as one symmetric band, not two
       independently-sized ones — start-aligning left any leftover slack in
       the fixed-height hero (§ .home-hero comment) collect only below the
       content, which is what made the bottom gap balloon or the content
       crowd the film strip depending on how much slack a given viewport
       happened to have. Centering splits that slack evenly instead, and
       `padding-block` below is now the SAME value on both edges — the two
       together mean the visible top/bottom gap stays equal regardless of
       how much slack there is, rather than one edge being a fixed padding
       and the other whatever's left over. */
    align-content: center;
    gap: var(--s-8);
    inline-size: 100%;
    /* Lets the grid row shrink below its content's intrinsic height on a
       viewport short enough that content doesn't fit at all — a grid
       item's default min-block-size: auto refuses to go smaller, which
       would silently break the fixed-height layout above right when it
       matters most. */
    min-block-size: 0;
    /* Fluid, not a breakpoint step: a flat value trimmed too far at desktop
       widths last round, leaving the copy column reading as almost touching
       the hero-film strip right below it — the exact "close to the
       carousel" symptom of a hard breakpoint jump rather than a value that
       actually tracks the viewport. Scales continuously instead, 20px to
       40px, and matches top to bottom. */
    padding-block: clamp(1.25rem, 1rem + 1.9vw, 2.5rem);
  }

  .home-hero__copy { max-inline-size: 42rem; }

  .home-hero__eyebrow {
    margin-block-end: var(--s-4);
    color: var(--canada-red);
    font-size: var(--fs-micro);
    font-weight: var(--fw-ui);
    letter-spacing: var(--tracking-micro);
    text-transform: uppercase;
  }

  .home-hero__title {
    font-size: var(--fs-hero);
    line-height: 0.92;
    letter-spacing: -0.045em;
  }

  /* Three explicit lines, not natural wrapping: "Canada's First Shop From
     Home Store" needs an exact break — "Canada's First" / "Shop From Home" /
     "Store" — not whatever text-wrap: balance or a max-inline-size guess
     would pick at a given width, which produced 2 lines as often as 3.
     Each is its own markup line (front-page.php) so the split is deliberate
     content, not a CSS approximation of one. */
  .home-hero__title-line { display: block; }

  .home-hero__intro {
    max-inline-size: 46ch;
    margin-block-start: var(--s-5);
    color: var(--ink-700);
    /* fs-body, not fs-h3/fs-h4: this is a paragraph — the "lead copy under a
       hero heading" role shared with .destination-hero__lead,
       .page-hero__description, .category-hero__description and
       .local-proof__quote — not a heading, so it now tracks --fs-body
       (12 mobile / 14 from --bp-lg) rather than the flat, unchanged --fs-h4
       heading size it used to borrow purely for the value match. */
    font-size: var(--fs-body);
  }

  /* Leaf only — no flag bars and no card behind it any more; the leaf floats
     directly in the hero rather than sitting on a bordered white surface.
     Hidden below md, restored there (below): below that width
     .home-hero__upper is single-column, so the flag isn't a side column any
     more, it's a large image dropped under the CTAs — worse than not
     showing it at all. From md the two-column grid gives it a lane of its
     own, so it comes back. */
  .home-hero__flag {
    display: none;
    /* 6–15rem/22svh. Below md this is a plain block-level element with no
       margin-block (the margin: 0 auto further down is horizontal-only),
       so this value only ever extends the box DOWNWARD from wherever the
       gap after .home-hero__copy already put its top edge — never upward,
       never symmetric. */
    block-size: clamp(6rem, 22svh, 15rem);
    max-inline-size: 100%;
    aspect-ratio: 388 / 419;
    margin: 0 auto;
    overflow: hidden;
    transform: rotate(2deg);
  }

  .home-hero__flag-field {
    display: grid;
    place-items: center;
    inline-size: 100%;
    block-size: 100%;
    overflow: hidden;
  }

  /* Below `md` the field is a plain box (figure's own clamp() above sets its
     size) so the leaf just fills it. From `md` the figure switches to
     matching the copy column's height instead (below), at which point the
     field's own box no longer shares the leaf's 388:419 ratio — so the leaf
     needs to CONTAIN itself within it rather than simply filling it, which
     is what the container-query sizing on `.home-hero__leaf` does (below).

     NOT `aspect-ratio` + `max-inline-size`/`max-block-size: 100%` alone — an
     earlier version of this rule tried exactly that, reasoning it was the
     same technique `object-fit: contain` uses on a replaced element and
     would resolve in one layout pass instead of cqi/cqb's two. It doesn't
     work: `object-fit: contain` has the image's own intrinsic size to
     compute from, but this is a plain `<div>` whose only child
     (`.home-hero__leaf-image`) is `position: absolute` — out of flow, so it
     contributes nothing to the div's own size. With `place-items: center`
     above (not `stretch`), the div gets no definite size from its parent
     either, so `aspect-ratio` + auto sizing has nothing to compute FROM and
     the box collapses to 0×0 — confirmed live at 1366×768, deterministically,
     not the rare cqi/cqb timing flake this replaced (which never reproduced
     more than once in a row and looks like a single-shot-screenshot capture
     artifact rather than something a continuously-repainting browser tab
     would ever show a real visitor). cqi/cqb are the right tool once
     `.home-hero__flag` has a real width (above) to resolve against — the
     one bug worth fixing was the circular case, not container queries
     themselves. */
  /* Mask (and its clip) live HERE, not on .home-hero__leaf-image, so the
     image can be scaled up independently below: a transform paints AFTER
     masking, so a scale on the masked element would have grown the leaf
     OUTLINE itself. Scaling the image instead and clipping it to this
     box's own (unscaled) rect first — via overflow: hidden — then masking
     that clipped rect keeps the leaf silhouette fixed while the photo
     inside it zooms. */
  .home-hero__leaf {
    position: relative;
    inline-size: 100%;
    aspect-ratio: 388 / 419;
    overflow: hidden;
    -webkit-mask-image: url("../images/canada-maple-leaf.svg");
    -webkit-mask-position: center;
    -webkit-mask-repeat: no-repeat;
    -webkit-mask-size: contain;
    mask-image: url("../images/canada-maple-leaf.svg");
    mask-position: center;
    mask-repeat: no-repeat;
    mask-size: contain;
    mask-mode: alpha;
  }

  .home-hero__leaf-image {
    position: absolute;
    inset: 0;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
    /* The source photo is a wide panorama — most of its height is open sky
       and lake, with the skyline itself a thin, roughly vertically-centred
       band. A small zoom left that sky/water dominating the leaf shape, so
       this crops in harder to fill the leaf with the skyline instead,
       cropped by the parent's overflow: hidden above — not a bigger leaf,
       a tighter photo inside the same one. */
    transform: scale(1.85);
    filter: saturate(0.88) contrast(1.08);
  }

  .hero-film {
    display: grid;
    gap: var(--s-3);
    padding-block: var(--s-3);
    background-color: var(--bg-alt);
  }

  .hero-film__row {
    overflow: hidden;
    -webkit-mask-image: linear-gradient(to right, transparent, var(--ink-900) 5%, var(--ink-900) 95%, transparent);
    mask-image: linear-gradient(to right, transparent, var(--ink-900) 5%, var(--ink-900) 95%, transparent);
  }

  .hero-film__track {
    display: flex;
    inline-size: max-content;
    transform: translate3d(0, 0, 0);
    backface-visibility: hidden;
    will-change: transform;
  }

  .hero-film__set {
    display: flex;
    gap: var(--s-3);
    padding-inline-end: var(--s-3);
  }

  .hero-film__image {
    /* FIXED RATIO, ONE AXIS DRIVES THE OTHER — inline-size and block-size
       used to be two independent clamps (inline off vw, block off svh, see
       below), and while each was individually reasonable, having two
       unrelated viewport metrics size the two axes meant the RATIO between
       them drifted with the viewport's own shape: a wide-but-short window
       and a narrow-but-tall one could each hand this element a totally
       different width:height relationship, stretching or squashing every
       thumbnail (`object-fit: cover` hides the crop, not the box's own
       proportions). `aspect-ratio` here is what makes the box always read
       as the same shape — only its SIZE changes with the viewport, never
       its proportions. 2/1 matches the inline:block relationship the old
       vw-driven pair happened to share at every one of its clamp bounds
       (8.5rem:4.25rem, 16vw:8vw, 16rem:8rem — all exactly 2:1), so this
       keeps the shape that was actually shipped and approved, just makes it
       explicit and viewport-shape-proof instead of incidental. */
    aspect-ratio: 2 / 1;
    /* A tokenized fixed block size keeps the film geometry independent of
       viewport-unit recalculation and browser zoom. The previous svh clamp
       crossed the short-window height query at different zoom levels, so the
       same physical viewport could jump between two visibly different strip
       heights. Width follows from the 2:1 aspect ratio above. */
    block-size: var(--s-13);
    inline-size: auto;
    object-fit: cover;
    border-radius: var(--r-sm);
  }

  .has-js .hero-film__row--forward .hero-film__track {
    animation-name: hero-film-forward;
    animation-duration: var(--hero-film-duration);
    animation-timing-function: linear;
    animation-iteration-count: infinite;
    animation-play-state: paused;
  }

  .has-js .hero-film__row--reverse .hero-film__track {
    animation-name: hero-film-reverse;
    animation-duration: var(--hero-film-duration);
    animation-timing-function: linear;
    animation-iteration-count: infinite;
    animation-play-state: paused;
  }

  .has-js .hero-film.is-motion-ready .hero-film__track {
    animation-play-state: running;
  }

  @keyframes hero-film-forward {
    from { transform: translateX(0); }
    to { transform: translateX(-50%); }
  }

  @keyframes hero-film-reverse {
    from { transform: translateX(-50%); }
    to { transform: translateX(0); }
  }

  @media (prefers-reduced-motion: reduce) {
    .has-js .hero-film__row .hero-film__track {
      animation: none;
      transform: translateX(0);
    }
  }

  @media (min-width: 768px) {
    .home-hero__upper {
      /* 16rem, not 20rem: right at md the flag's lane was demanding more
         than a comfortable share of a viewport that's only just gone
         two-column, squeezing the copy column tighter than it needed to be
         at exactly the width where this layout is newest. */
      grid-template-columns: minmax(0, 1.08fr) minmax(16rem, 0.92fr);
      align-items: center;
      gap: var(--s-10);
    }

    /* MATCH THE COPY COLUMN'S HEIGHT, not the viewport. Side by side, "equal
       to the title column" means the leaf's box should be exactly as tall as
       whatever `.home-hero__copy` renders at — its title, intro and CTAs are
       the one thing on this page a fixed clamp() can't predict the height of.
       `align-self: stretch` does that: the row's auto height is set by the
       copy column (the flag itself carries no aspect-ratio here, so it makes
       no height claim of its own to fight that), and the flag then stretches
       to match.

       `inline-size: 100%` and cancelling the mobile `margin: 0 auto` are not
       cosmetic here — they are what makes `align-self: stretch` actually
       resolve. An auto inline margin makes a grid item size to fit-content
       instead of stretching to its column, and this item's "content" used to
       be a leaf that sized ITSELF off this box (previously via cqi/cqb,
       §8.14) — fit-content sizing a box from a child that measures itself
       against that same box is a dependency cycle with no defined answer,
       and browsers resolve it by collapsing the whole leaf to 0 (confirmed
       by rendering this exact markup/CSS and reading back
       getBoundingClientRect — the leaf disappeared at every desktop width
       tested, inconsistently with how it happened to render live, which
       tracks: a dependency cycle has no single defined resolution, so which
       way a given browser/pass falls is exactly the kind of thing that
       varies by size instead of failing the same way every time). Giving
       the box a definite width from the grid track removes the cycle at its
       source, instead of resolving it more carefully. */
    .home-hero__flag {
      display: grid;
      align-self: stretch;
      inline-size: 100%;
      margin-inline: 0;
      block-size: auto;
      aspect-ratio: auto;
    }

    /* The stretched box won't share the leaf's 388:419 ratio, so the leaf has
       to CONTAIN itself inside it rather than filling it (§8.14 container
       queries) — `size` containment is what lets cqi/cqb resolve against this
       box specifically, not the viewport. `min(100cqi, 100cqb × ratio)` is the
       same "fit inside, don't crop, don't distort" formula `object-fit:
       contain` uses on a replaced element, done in CSS for a div.

       NOT `aspect-ratio` + `max-inline-size`/`max-block-size: 100%` — see the
       long comment on `.home-hero__leaf`'s base rule above for why that
       looked equivalent but isn't: this box has no in-flow content of its
       own (the image inside is `position: absolute`) and its parent centers
       rather than stretches it, so aspect-ratio + auto sizing had nothing to
       compute from and collapsed to 0×0, deterministically, at ordinary
       desktop sizes — confirmed live. cqi/cqb resolving against
       `.home-hero__flag`'s now-definite width is well-defined and this is
       the version that's actually been verified working. */
    .home-hero__flag-field { container-type: size; }

    .home-hero__leaf {
      inline-size: min(100cqi, calc(100cqb * 388 / 419));
    }
  }

  /* SHORT WINDOW, two-column layout already active (min-width pairs with the
     768px flag switch above): the copy stack's own vertical rhythm — meant
     for a roomy viewport — is what starts eating into the fixed-height hero's
     limited budget once the window gets short, and `overflow: hidden` on
     `.home-hero` (§ .home-hero comment) means that loss is silent: the CTAs
     are the first thing to go, not a scrollbar or a visible cutoff. Tightens
     the gaps between the copy's own children so the whole stack compresses
     instead.

     The film strip also gets its own, smaller clamp here rather than relying
     on the general one's floor: 12svh (above) is what gives a normal-to-tall
     window the taller strip that was asked for, but that same coefficient
     applied at a short window height produces a strip taller than the
     general clamp's floor was ever meant to allow for — the floor only
     protects against going below it, it doesn't cap how much a short-but-
     not-floor-hitting svh value can still take from `.home-hero__upper`.
     Overriding the whole clamp here, instead of just lowering the general
     floor, is what actually shrinks the strip in exactly this range. */
  @media (min-width: 768px) and (max-height: 700px) {
    .home-hero__intro { margin-block-start: var(--s-3); }
    .proof-line { margin-block-start: var(--s-3); }
    .home-hero__ctas { margin-block-start: var(--s-5); }

  }

  @media (min-width: 1024px) {
    .home-hero { padding-block-start: var(--s-9); }  /* 40px */
    .home-hero__upper {
      /* Same value both edges (§ .home-hero__upper comment above) — steps to
         a flat 40px here instead of the fluid clamp, matching its ceiling. */
      padding-block: var(--s-9);
      /* .container's own --gutter-x-lg (32px) is the site-wide side margin,
         tuned for body copy — a display-scale hero reads as pinned to the
         edge at that width on anything wider than a laptop. This overrides
         it (same element, same property) with something that keeps growing
         past 1024px instead of staying flat: ~57px there, up to a 96px cap
         on a wide monitor, so the gutter stays proportional to the
         viewport rather than capping out at the value tuned for body copy. */
      padding-inline: clamp(2rem, 1rem + 4vw, 6rem);
    }
  }

  /* A NUMBER above the fold, not an adjective (§8.10 #7). */
  .proof-line {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-3) var(--s-5);
    margin-block-start: var(--s-6);
    font-size: var(--fs-small);
    color: var(--ink-700);
    font-variant-numeric: tabular-nums;
  }

  .proof-line__item { display: flex; align-items: center; gap: var(--s-3); }
  .proof-line__item + .proof-line__item::before {
    content: "";
    inline-size: 4px;
    block-size: 4px;
    border-radius: var(--r-full);
    background-color: var(--brass);
  }

  .home-hero__ctas {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-4);
    margin-block-start: var(--s-8);
  }

  /* ---- Guarantee strip (§6.1) ------------------------------------------- */

  /* A quiet trust ledger directly beneath the hero: one framed band, generous
     internal rhythm, and hairlines doing the grouping. No duplicate section
     headline competes with the hero, and the commitments remain the content. */
  .guarantees {
    margin-block-start: var(--section-y-compact);
    padding-block: var(--section-y-compact);
    background-color: var(--bg-cream);
  }

  .guarantees__list {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    margin: 0;
    padding: 0;
    list-style: none;
    border: 1px solid var(--mega-media-border);
  }

  .guarantees__row {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    align-items: center;
    gap: var(--s-5);
    min-block-size: 100%;
    padding: var(--s-6);
    background-color: var(--bg-cream);
  }

  .guarantees__row + .guarantees__row {
    border-block-start: 1px solid var(--mega-media-border);
  }

  .guarantees__icon {
    display: grid;
    place-items: center;
    inline-size: var(--s-8);
    block-size: var(--s-8);
    color: var(--ink-900);
    background-color: var(--accent-tint);
    border: 1px solid var(--brass);
    border-radius: var(--r-full);
  }

  .guarantees__text { display: grid; gap: var(--s-1); }
  .guarantees__label { font-weight: var(--fw-subhead); color: var(--ink-900); font-size: var(--fs-h4); }
  .guarantees__line { max-inline-size: var(--w-prose); font-size: var(--fs-small); line-height: var(--lh-body); color: var(--ink-500); }

  /* ---- Room tiles (§6.1, §1.3) ------------------------------------------ */

  /* Homepage category grid styles live exclusively in home-categories-grid.css.
     Keeping a second category-tile system here caused its grey card border,
     hover border-color, radius, shadow and lift to stack with the dark ruled
     grid. One component now has one border owner. */

  /* A FULL-BLEED CREAM BAND, on the guarantee strip's pattern (§6.1): the
     section paints edge to edge and `.room-tiles__inner.container` holds the
     content to the page column, rather than the section carrying `.container`
     itself and painting only the column's width.

     --bg-cream is the neutral scale's one documented exception and its own
     comment warns against reaching for it under photography — a warm ground
     tints every photo laid on it, which is why the rest of the scale stays
     cool. Used here anyway, by request. The eight room photos are what to
     watch: the effect is strongest on the bathroom and basement tiles, whose
     near-white walls have the least colour of their own to hold against it.
     The 1px --ink-900 tile border helps, since each photo now ends on a hard
     edge rather than fading into the ground. */
  .room-tiles {
    padding-block: var(--section-y);
    background-color: var(--bg-cream);
  }
  .room-tiles__heading { margin-block-end: var(--s-7); }

  .room-tiles__list {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--grid-gutter);
  }

  .room-tile {
    position: relative;
    display: block;
    text-decoration: none;
    border: 1px solid var(--ink-900);
    border-radius: var(--r-md);
    overflow: hidden;
    background-color: var(--bg-alt);
  }

  .room-tile__img { inline-size: 100%; aspect-ratio: 4 / 3; object-fit: cover; }

  /* Text over a photo requires the scrim (§8.2) — verified against the darkest
     and lightest image, which is why it runs to 72% at the base. */
  .room-tile__label {
    position: absolute;
    inset-block-end: 0;
    inset-inline: 0;
    padding: var(--s-7) var(--s-4) var(--s-4);
    background-image: linear-gradient(
      to top,
      color-mix(in srgb, var(--ink-900) 72%, transparent),
      transparent 60%
    );
    color: var(--surface);
    font-weight: var(--fw-ui);
    font-size: var(--fs-small);
  }

  /* ---- Process dial (§6.1) ----------------------------------------------
     "How the work happens" — three steps, on a dial that turns as the visitor
     scrolls.

     THE STICKY COLUMN IS THE WHOLE EFFECT, AND IT IS ONE DECLARATION. The dial
     holds its place while the steps pass it, which is the depth read the
     section is after; the page scrolls at exactly the rate it would with this
     section removed. That is what separates it from the parallax and
     scroll-jacking §8.14 declines — both of those are named for the same fault,
     that the page stops moving at the rate the visitor asked for.

     THERE IS NO SCROLL HANDLER AND NOTHING RECOMPUTED PER FRAME (§8.17). One
     IntersectionObserver sets --step and data-step on the section; every rule
     below is a transform, an opacity or a colour off those two. Nothing here
     transitions a property that costs layout, which is why the numeral changes
     size with `scale()` rather than with `font-size`.

     REDUCED MOTION NEEDS NO RULE HERE (§8.7). The global block in tokens.css
     collapses every transition duration site-wide, so the dial jumps to its
     position instead of turning, and the section reads exactly the same.
     -------------------------------------------------------------------- */

  .process {
    /* The page's rhythm break — the TALL rhythm of §8.18 #4, between a dense
       rail and a tile grid above and the FAQ/proof/ask cluster below. */
    padding-block: var(--section-y-tall);
    background-color: var(--bg-alt);
  }

  .process__head { max-inline-size: var(--w-prose); }
  .process__intro { margin-block-start: var(--s-4); color: var(--ink-700); }

  /* Base is block flow, and that is load-bearing rather than a default left
     alone: a sticky element's containing block is its parent's content box, so
     in flow the dial travels the whole height of the stage. Made a
     single-column GRID it would be trapped in its own row and never move. */
  .process__stage { margin-block-start: var(--s-8); }

  /* ---- The steps ---- */

  .process__steps {
    list-style: none;
    margin-block: 0;
    padding-inline-start: 0;
  }

  /* NO OPACITY ON THE INACTIVE STEPS, deliberately. Dimming the two a visitor
     is not on is the obvious way to say "this one" — and it drops --ink-700
     body copy below the AA floor this section's own buyer most needs (§8.3,
     §8.16). All three stay fully legible; the active one is marked with a
     hairline instead, which is a structural cue rather than a contrast cut.
     The border is on every step so activating one shifts nothing (§8.17). */
  .process__step {
    padding-block: var(--s-7);
    padding-inline-start: var(--s-5);
    border-inline-start: 2px solid transparent;
    transition: border-color var(--dur-slow) var(--ease);
  }

  .process__step + .process__step { border-block-start: 1px solid var(--line); }

  .process[data-step="0"] .process__step:nth-child(1),
  .process[data-step="1"] .process__step:nth-child(2),
  .process[data-step="2"] .process__step:nth-child(3) { border-inline-start-color: var(--brass); }

  .process__label { margin-block-start: var(--s-3); }
  .process__body { margin-block-start: var(--s-4); color: var(--ink-700); }

  .process__note {
    margin-block-start: var(--s-4);
    font-size: var(--fs-small);
    color: var(--ink-500);
  }

  @media (min-width: 768px) {
    /* ASYMMETRIC, 5/7 RATHER THAN 6/6 (§8.18 #5) — this is the hand-set half of
       the page, where an even split is the tell. */
    .process__stage {
      display: grid;
      grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
      gap: var(--s-9);
      margin-block-start: var(--s-9);
    }

    .process__step { padding-block: var(--s-9); }
    .process__step:first-child { padding-block-start: 0; }
  }

  @media (min-width: 1024px) {
    .process__stage { gap: var(--s-10); }
    .process__step { padding-block: var(--s-10); }
    .process__step:first-child { padding-block-start: 0; }
  }

  /* ---- "What happens after you reach out" — Contact page only (§7.3) ------
     Three hairline-and-shadow-sm cards, never an icon triad (§8.18) — the
     numeral carries the sequence, the same "Step 01 of 03" idiom as the
     process steps above, just without the dial. One column on mobile, two
     from md with the third card spanning both so an odd count never reads as
     a leftover. THREE, NEVER FOUR (§8.2 culture note). */

  .next-steps__head { max-inline-size: var(--w-prose); margin-block-end: var(--s-7); }
  .next-steps__heading { margin-block-end: var(--s-2); }

  .next-steps__intro {
    margin: 0;
    color: var(--ink-700);
    font-size: var(--fs-body);
    line-height: var(--lh-body);
  }

  .next-steps__grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-5);
    margin: var(--s-7) 0 0;
    list-style: none;
  }

  /* ol.next-steps__grid, NOT .next-steps__grid alone — the same fix as
     ul.contact-channels and ul.social-links: this sits inside .prose too
     (Contact page), and .prose ol's padding-inline-start is class+type
     specificity, so the bare-class padding: 0 above was silently losing
     to it and leaving the grid indented 20px. */
  ol.next-steps__grid { padding-inline-start: 0; }

  .next-steps__item {
    padding: var(--s-6);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-md);
    box-shadow: var(--shadow-sm);
  }

  .next-steps__count {
    font-size: var(--fs-micro);
    text-transform: uppercase;
    letter-spacing: var(--tracking-micro);
    font-variant-numeric: tabular-nums;
    color: var(--ink-500);
  }

  .next-steps__label { margin-block-start: var(--s-3); }
  .next-steps__body { margin-block-start: var(--s-3); color: var(--ink-700); }

  @media (min-width: 768px) {
    .next-steps__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .next-steps__item:last-child { grid-column: 1 / -1; }
  }

  /* ---- Contact page two-column CTA row (§7.1, §7.2, §7.3) -----------------
     Why this needs page-templates/contact.php rather than the default
     page.php: see that template's own header comment — page.php's body caps
     at --w-prose (720px), too narrow to hold the appointment widget beside
     the contact form.

     ONE COLUMN UNTIL xl (1280px) — see the template's header comment for
     why equal tracks need the widest step rather than lg. Stacked, the two
     sections are separated by a horizontal hairline and centered rather
     than left-aligned (both reasoned through in the template comment too);
     side by side from xl, the hairline turns vertical down the middle and
     alignment reverts to normal reading-left. */

  .contact-cta { padding-block: var(--section-y); }

  /* `minmax(0, 1fr)`, not a bare `1fr`: an `fr` track's implicit minimum is
     `auto` (its content's min-content size), not 0 — a bare `1fr` is not
     the same guard as `minmax(0, 1fr)`. The widget in the column below has
     content that can't shrink past a few hundred px (the calendar's 7
     fixed tap-min columns), so the bare-`1fr` track was refusing to shrink
     to the viewport on phones. */
  .contact-cta__grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-9);
  }

  .contact-cta__col:first-child {
    padding-block-end: var(--s-9);
    border-block-end: 1px solid var(--ink-900);
  }

  /* Heading and lead line only, not the whole column — text-align inherits,
     and the widget/form below have their own labels, buttons and calendar
     cells that need to stay left-set regardless of how this column aligns
     its own copy. */
  .contact-cta__heading,
  .contact-cta__intro { text-align: center; }

  .contact-cta__heading { margin-block-end: var(--s-2); }

  .contact-cta__intro {
    margin: 0 0 var(--s-6);
    color: var(--ink-700);
    font-size: var(--fs-body);
    line-height: var(--lh-body);
  }

  /* The widget and the form each carry their own max-inline-size already
     (§7.2, §7.1) — centred here rather than stretched, so a narrower box
     doesn't get pulled wider than its own design just because its column
     is full container width while stacked. */
  .contact-cta__col > .appointment,
  .contact-cta__col > .lead-form { margin-inline: auto; }

  @media (min-width: 1280px) {
    .contact-cta__grid {
      grid-template-columns: repeat(2, minmax(0, 1fr));
      gap: 0;
      align-items: start;
    }

    .contact-cta__heading,
    .contact-cta__intro { text-align: left; }

    .contact-cta__col { padding-inline: var(--s-9); }

    .contact-cta__col:first-child {
      padding-inline-start: 0;
      padding-block-end: 0;
      border-block-end: 0;
      border-inline-end: 1px solid var(--ink-900);
    }

    .contact-cta__col:last-child { padding-inline-end: 0; }

    .contact-cta__col > .appointment,
    .contact-cta__col > .lead-form { margin-inline: 0; }
  }

  /* The two reading-width sections below the CTA row (next steps, social) —
     each needs its own top padding since neither is a sibling of the hero
     any more, so .page-hero + *'s margin above doesn't reach them. First
     child's margin reset to 0 for the same reason .destination-section
     does it above: .prose h2 carries an unconditional margin-block-start
     that would otherwise stack on top of this padding when an h2 opens the
     section (the "Follow the work" block). */
  .contact-section { padding-block: var(--section-y); }
  .contact-section > *:first-child { margin-block-start: 0; }

  .home-body { padding-block: var(--section-y); }

  /* ---- Home FAQ — curated subset + a section photo (§6.1, §6.7) -----------
     TWO COLUMNS FROM --bp-lg: the accordion and its "See all FAQs" link in
     the first column, a photo (or its placeholder) in the second — one
     column below --bp-lg, where the questions need the full width more than
     the photo does, so the photo drops underneath rather than squeezing the
     answers narrower. */
  .home-faq { padding-block: var(--section-y); }

  /* `minmax(0, 1fr)`, not the grid default — see .home-appointment__grid's
     comment below. The FAQ column's text happens to shrink enough not to
     visibly blow out, but an unset track is still the same latent bug. */
  .home-faq__grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-7);
  }

  /* .home-faq__col's h2 sits OUTSIDE .faqs here, unlike every other
     placement of the component (below) which either supplies its
     own internal .faqs__heading or gets zeroed out for the same reason
     (.destination-faqs .faqs below): .faqs's own margin/padding/border-top
     was built for "I open a new topic", and the h2 above already does that
     job, so left alone the two stack into a ~72px gap plus a stray second
     hairline under a heading that's meant to sit close to its questions.
     Replaced with --s-5, the same heading-to-first-question gap
     .faqs__heading uses everywhere else. */
  .home-faq__col .faqs {
    margin-block-start: var(--s-5);
    padding-block-start: 0;
    border-block-start: 0;
  }

  /* The "See all FAQs" link had no gap above it at all — .faqs carries no
     margin-block-end and a <p> reset to 0 sits flush against the last
     question's own border. --s-7 matches the component-to-component gap
     used elsewhere on the page (§8.4). */
  .home-faq__col .faqs + p { margin-block-start: var(--s-7); }

  /* The photo column and its empty state — the SAME designed-placeholder
     recipe as the category tiles (§8.15, §10): a warm tinted field with a
     centred mark rather than a grey "missing image" box, so the section
     looks finished before the photography does. */
  .home-faq__media {
    display: grid;
    place-items: center;
    box-sizing: border-box;
    aspect-ratio: 4 / 3;
    overflow: hidden;
    border-radius: var(--r-md);
    background-color: var(--bg-tint);
  }

  .home-faq__img {
    display: block;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
  }

  .home-faq__media--placeholder {
    background-image: radial-gradient(
      120% 120% at 20% 15%,
      color-mix(in srgb, var(--brass) 18%, var(--bg-alt)),
      var(--bg-alt) 70%
    );
  }

  .home-faq__glyph { color: color-mix(in srgb, var(--ink-900) 40%, transparent); }

  @media (min-width: 1024px) {
    /* ASYMMETRIC, NOT 6/6 (§8.18 #5) — the questions carry the section, the
       photo is the accent, so the column split says so rather than an even
       half-and-half. */
    .home-faq__grid {
      grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
      gap: var(--s-9);
      align-items: start;
    }
  }

  /* ---- Home appointment — photo beside the heading, lead and §7.2 widget,
     heading and lead stacked above the widget (§6.1) ----------------------
     THE TALL RHYTHM (§8.18 #4), not the standard one the rest of Home runs
     at — the same call `destination-booking.php` makes for the identical
     section on room/city pages: this is the page's primary conversion
     point and the one moment it should breathe. */
  .home-appointment { padding-block: var(--section-y-tall); }

  /* `minmax(0, 1fr)`, not the grid default: an unspecified single-column
     track sizes to the AUTO/max-content width of its contents, and the
     widget below carries content that can't shrink past a few hundred px
     (the calendar's 7 fixed tap-min columns, the time-list's fixed-width
     chips) — so on a real phone the whole page grew to that max-content
     width instead of the viewport. */
  .home-appointment__grid {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--s-8);
  }

  /* Heading tight to lead, lead loose to the widget — same idiom as
     `.destination-section`'s heading/intro/content rhythm (§8.4): proximity
     says the first two belong together, the widget is a different kind of
     thing below them. */
  .home-appointment__content {
    display: flex;
    flex-direction: column;
  }

  .home-appointment__content > * { margin-block: 0; }
  .home-appointment__content > * + * { margin-block-start: var(--s-7); }

  .home-appointment__heading + .home-appointment__lead {
    margin-block-start: var(--s-3);
  }

  /* Same designed-placeholder recipe as the FAQ photo above and the category
     tiles (§8.15, §10) — one visual idiom for "no photo yet" site-wide. */
  .home-appointment__media {
    display: grid;
    place-items: center;
    box-sizing: border-box;
    aspect-ratio: 4 / 3;
    overflow: hidden;
    border-radius: var(--r-md);
    background-color: var(--bg-tint);
  }

  .home-appointment__img {
    display: block;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
  }

  .home-appointment__media--placeholder {
    background-image: radial-gradient(
      120% 120% at 20% 15%,
      color-mix(in srgb, var(--brass) 18%, var(--bg-alt)),
      var(--bg-alt) 70%
    );
  }

  .home-appointment__glyph { color: color-mix(in srgb, var(--ink-900) 40%, transparent); }

  .home-appointment__lead {
    max-inline-size: var(--w-prose);
    color: var(--ink-700);
    font-size: var(--fs-body);
    line-height: var(--lh-body);
  }

  @media (min-width: 1024px) {
    /* THE WIDGET KEEPS ITS OWN WIDTH, NEVER STRETCHED TO FILL A TRACK — it is
       sized to step 1's content, not to a column (§7.2), so the second track
       is `auto` rather than an `fr` and the media column takes what's left.
       This is also why the pair goes 2-up at --bp-lg rather than waiting for
       --bp-xl the way the contact page's widget-and-form row does: there
       BOTH tracks are forms that need real width; here only one side does.
       ALIGN-ITEMS: START, not center (same call as `.home-faq__grid`, §6.1):
       the content column is now taller than the photo (heading + lead sit
       above the widget), so centering would float the photo oddly instead
       of lining its top edge up with the heading. */
    .home-appointment__grid {
      grid-template-columns: minmax(0, 1fr) auto;
      gap: var(--s-10);
      align-items: start;
    }

    /* Narrower than the site's usual --w-prose measure on purpose: this lead
       sits in the AUTO track above the fixed-width widget (§7.2), not a full
       prose column, so it's capped near the widget's own width rather than
       the 720px prose measure — otherwise an unwrapped line longer than the
       widget would stretch the whole track past it. */
    .home-appointment__lead {
      max-inline-size: 40ch;
    }
  }

  /* =========================================================================
     DESTINATION PAGES (§6.3, §6.4)
     ONE LAYOUT, TWO NICHES. Every room page and every service-area page is
     this. The photograph and the words change; the chrome, the section order
     and the rhythm do not.

     Rhythm first, because it is the half of §8.18 #4 that keeps a page from
     reading as one template applied six times. THREE RHYTHMS, NOT ONE:
     standard is the default, compact carries the FAQ block, and tall carries
     the booking close where the page should breathe. All three step up at
     --bp-lg from the swap block at the end of tokens.css, so nothing here is
     per-breakpoint.

     PADDING IS BLOCK-START AND BLOCK-END, EACH SECTION ON ITS OWN. Used to be
     block-start only, with .site-main supplying a shared block-end so two
     adjacent sections' paddings wouldn't stack into a double gap — but that
     made the size of the gap between two sections depend on which one of
     them happened to be catching .site-main's compensating padding, rather
     than on the section itself. Every section now owns its complete rhythm,
     start and end, on whichever of the three values is its own — so the gap
     between two sections is deliberately the SUM of the first's end and the
     second's start (e.g. standard-into-compact is 64+32 at desktop, not a
     single 64), and whichever section renders last on a given page closes
     the page correctly on its own, with no wrapper-level fallback needed.
     ====================================================================== */

  .destination-section { padding-block: var(--section-y); }
  .destination-section--compact { padding-block: var(--section-y-compact); }
  .destination-section--tall { padding-block: var(--section-y-tall); }

  /* Same both-ends padding every other .destination-section already carries
     now — the only thing still unique to the band is the fill needing to
     hold its content off its own edges, and the two page types sharing this
     slot for their rhythm break. */
  .destination-section--band {
    padding-block: var(--section-y);
    background-color: var(--bg-alt);
  }

  /* Section internals: one rule, rather than a margin on every component that
     might land inside a section. Heading to intro is TIGHT and intro to content
     is not — proximity is what says the first two belong together (§8.4). */
  .destination-section > .container > * { margin-block: 0; }
  .destination-section > .container > * + * { margin-block-start: var(--s-7); }

  .destination-section__heading + .destination-section__intro {
    margin-block-start: var(--s-3);
  }

  .destination-section__intro {
    max-inline-size: var(--w-prose);
    font-size: var(--fs-small);
    color: var(--ink-500);
  }

  .page-body,
  .post-single__body,
  .post-single__cta { padding-block: var(--section-y); }

  /* ---- Reviews (§6.9) --------------------------------------------------- */

  .review {
    margin: 0;
    padding: var(--s-6);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-md);
    block-size: 100%;
  }

  .review__head { display: flex; align-items: center; gap: var(--s-3); margin-block-end: var(--s-4); }
  /* --r-full is for the floating CTA and AVATARS only (§8.5). */
  .review__avatar { inline-size: var(--s-9); block-size: var(--s-9); border-radius: var(--r-full); object-fit: cover; }
  .review__author { font-weight: var(--fw-subhead); color: var(--ink-900); font-size: var(--fs-small); }
  .review__time { font-size: var(--fs-micro); color: var(--ink-500); }
  .review__rating { color: var(--brass); margin-block-end: var(--s-3); }

  /* Full text, clamped generously rather than trimmed to the flattering half
     (§6.1). */
  .review__text {
    margin: 0;
    font-size: var(--fs-small);
    display: -webkit-box;
    -webkit-line-clamp: 8;
    -webkit-box-orient: vertical;
    overflow: hidden;
  }

  .reviews__source { margin-block-start: var(--s-5); font-size: var(--fs-small); }

  /* =========================================================================
     ROOM PAGE — the requirements table (§6.3, §8.13)
     ====================================================================== */

  /* ONE FRAME AROUND THE WHOLE TABLE, rows divided inside it — a table shape,
     not a stack of floating cards. It is the same treatment as the product
     spec table (§5.8): the wrap paints the frame and clips its children, so
     the striped rows inside stay square (§8.5's nesting rule).

     The wrap sits on the recessed band, so its --surface fill is what lifts
     the table off it. */
  .requirements-wrap {
    overflow: hidden;
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-md);
  }

  .requirements { inline-size: 100%; border-collapse: collapse; margin: 0; }

  /* BELOW --bp-md THIS COLLAPSES TO STACKED CARDS, one per row, each cell
     prefixed with its column header. NEVER a horizontal scroll container: the
     whole value of the table is reading "rules out" next to "what we'd install",
     and a scroll hides one of them (§8.13). */
  .requirements thead { display: none; }
  .requirements tr {
    display: block;
    padding: var(--s-5);
  }
  .requirements tr + tr { border-block-start: 1px solid var(--line); }
  /* Zebra from --bg-tint, never --bg-alt: --bg-alt is the band this table sits
     on and stripes hard enough to read as two different tables (§5.8). */
  .requirements tr:nth-child(even) { background-color: var(--bg-tint); }
  .requirements td { display: block; padding-block: var(--s-3); }
  .requirements td + td { border-block-start: 1px solid var(--line); }
  .requirements td::before {
    content: attr(data-label);
    display: block;
    margin-block-end: var(--s-2);
    font-size: var(--fs-micro);
    font-weight: var(--fw-subhead);
    text-transform: uppercase;
    letter-spacing: var(--tracking-micro);
    color: var(--ink-500);
  }

  /* =========================================================================
     DELIVERED PROJECTS (§6.5)
     ====================================================================== */

  .projects-section { padding-block: var(--section-y); }
  .projects-section__heading { margin-block-end: var(--s-7); }

  /* Also covers the final, unclassed booking section at the bottom of the
     template — every direct <section> child, category galleries and the
     closing appointment widget alike, owns its own full rhythm now. */
  .projects-page > section {
    padding-block: var(--section-y);
  }

  .projects-grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--grid-gutter);
  }

  /* ---- Before/after slider (§8.10 #10) --------------------------------- */

  .before-after { position: relative; border-radius: var(--r-md); overflow: hidden; }
  .before-after img { inline-size: 100%; aspect-ratio: 4 / 3; object-fit: cover; display: block; }
  .before-after__after { position: absolute; inset: 0; }

  /* A native range input, so it is keyboard-operable for free (§8.16). */
  .before-after__control {
    position: absolute;
    inset-block-end: var(--s-4);
    inset-inline: var(--s-4);
    inline-size: calc(100% - var(--s-8));
    accent-color: var(--accent);
  }

  /* ---- The destination hero (§6.3, §6.4) ---------------------------------
     THE SAME SHAPE AS THE CATEGORY HERO (§5.6), and deliberately so: a
     full-width photo at a fixed, "normal" height (--hero-media-h) with a
     white cutout card overlapping it — position: absolute AT EVERY WIDTH,
     so the card's content can never inflate the photo's height, but the
     card's own shape adapts per breakpoint:
       BASE     the card anchors to the photo's block-end and stretches
                across the inline axis (minus the page gutter) — a phone
                hero is naturally taller than it is wide, so there is room
                to overlap a card along the bottom even in portrait.
       --bp-md  the card moves to the inline-start edge and stretches the
                full block axis instead (inset from the photo's block-start
                and block-end by the same amount) — a landscape photo has
                the width to spare for a side column but not the height to
                spare along the bottom.
     One mechanism, two shapes — the same idea as --hero-media-h itself
     changing per breakpoint while never growing with its content.

     TEXT NEVER SITS ON THE PHOTO PIXELS. The card is fully opaque
     (--surface), so the only thing crossing onto the photo is its border —
     the §8.2 scrim is still not needed, and an admin-uploaded photo's
     unpredictable brightness still cannot break legibility. */

  .destination-hero {
    position: relative;
  }

  .destination-hero__media {
    /* Scopes __placeholder's inset: 0 to this block. The card is
       position: absolute at every width now, so .destination-hero's
       rendered height is always exactly this block's — nothing to scope
       AROUND any more, but still needed for the placeholder itself. */
    position: relative;
    display: block;
    /* FIXED, not min — the photo dictates this box's height and the
       absolutely-positioned card below can never inflate it. */
    block-size: var(--hero-media-h);
    overflow: hidden;
    background-color: var(--bg-tint);
  }

  .destination-hero__img {
    display: block;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
    object-position: center center;
  }

  /* Empty state: the block STAYS, so the layout cannot jump the day a photo
     is uploaded — the photography does not exist yet (§10) and these pages
     have to look finished without it. --bg-tint and a hairline, never a grey
     "missing image" slab that reads as a broken page (§8.15). */
  .destination-hero__media--empty { border-block: 1px solid var(--line); }

  .destination-hero__placeholder {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    color: var(--ink-500);
  }

  /* THE CARD, BASE WIDTH: anchored to the photo's block-end, stretched
     across the inline axis minus the page gutter. inset-block-end: 0 (no
     gap) runs the card flush to the photo's bottom edge, the same "touch
     the wall" treatment the --bp-md-up layout gives the inline-start edge —
     content-hugging height (no inset-block-start) keeps the card taller
     than it is wide on a narrow phone viewport, a vertical shape to match
     the portrait photo. max-block-size leaves at least --s-9 of the photo
     visible above the card even at its tallest; overflow-y: auto is the
     fallback for content that still doesn't fit. */
  .destination-hero__copy {
    position: absolute;
    /* .destination-hero__media is ALSO position: relative (for its own
       __placeholder's inset: 0), so both are "positioned" elements with an
       auto z-index — CSS paints those in DOM tree order relative to EACH
       OTHER, not "positioned always wins over static". Copy comes first in
       the markup (for reading order), which without this would put it
       BEHIND the media that follows it. z-raised settles it explicitly
       instead of relying on source order. */
    z-index: var(--z-raised);
    inset-inline: var(--gutter-x);
    inset-block-end: 0;
    max-block-size: calc(var(--hero-media-h) - var(--s-9));
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: var(--s-4);
    padding: calc(var(--s-5) + 5px);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: 0;
  }

  /* THE OFFSET BORDER, BASE WIDTH: same idea as the --bp-md-up ::before
     below — a second border inset from the card's edges, left open on
     whichever edge is already flush to a wall. Here that's the block-end
     (the card touches the photo's bottom edge at this width, not the
     inline-start), so the offset border insets from the top/left/right and
     runs flush + open along the bottom instead. Inset is --s-4 minus 2px,
     tighter than the card's own (bumped) padding so it doesn't crowd it. */
  .destination-hero__copy::before {
    content: "";
    position: absolute;
    inset-inline: calc(var(--s-4) - 2px);
    inset-block-start: calc(var(--s-4) - 2px);
    inset-block-end: 0;
    border: 1px solid var(--brass);
    border-block-end: 0;
    pointer-events: none;
  }

  .destination-hero__copy > * { margin-block: 0; }

  /* The eyebrow says WHICH KIND of destination this is — "Shop by room" or
     "Service area" — which is the one thing the title alone cannot: "Kitchen"
     and "Brampton" are both just nouns until something frames them. */
  .destination-hero__eyebrow {
    font-size: var(--fs-micro);
    font-weight: var(--fw-subhead);
    text-transform: uppercase;
    letter-spacing: var(--tracking-micro);
    color: var(--ink-500);
  }

  .destination-hero__title { max-inline-size: 34ch; }

  .destination-hero__lead {
    max-inline-size: var(--w-prose);
    /* Paragraph, not a heading — see .home-hero__intro's comment. */
    font-size: var(--fs-body);
    line-height: var(--lh-body);
    color: var(--ink-700);
  }

  /* The service note, on city pages: do you come here, and how soon. Unset
     renders nothing at all (§8.15) — no placeholder, and no hardcoded "we
     cover the GTA" standing in for a fact nobody has checked. */
  .destination-hero__note {
    display: flex;
    align-items: center;
    gap: var(--s-2);
    font-size: var(--fs-small);
    color: var(--ink-500);
  }

  .destination-hero__note .pinnacle-icon { flex: none; }

  /* TWO CTAs, NEVER THREE (§8.10 #8). Wraps rather than shrinking: at 360px
     two buttons on one row would put both under a comfortable label width. */
  .destination-hero__ctas {
    display: flex;
    flex-wrap: wrap;
    gap: var(--s-3);
    margin-block-start: var(--s-3);
  }

  /* ---- The generic page hero (template-parts/page-hero.php) --------------
     THE SAME SHAPE AS THE DESTINATION HERO ABOVE, and deliberately so — the
     card floats at every width, anchored to the photo's block-end and
     stretched full-inline at the base width, moved to the inline-start edge
     and stretched full-block from --bp-md. EVERY PAGE EXCEPT HOME CARRIES
     THIS SHAPE, whether or not it has a photo yet — a page with no featured
     image still renders the media block, as an empty placeholder, the same
     rule as the destination and category heroes. */

  .page-hero {
    position: relative;
  }

  .page-hero__media {
    /* Scopes __placeholder's inset: 0 to this block — same reason as
       .destination-hero__media above. */
    position: relative;
    display: block;
    block-size: var(--hero-media-h);
    overflow: hidden;
    background-color: var(--bg-tint);
  }

  .page-hero__img {
    display: block;
    inline-size: 100%;
    block-size: 100%;
    object-fit: cover;
    object-position: center center;
  }

  .page-hero__media--empty { border-block: 1px solid var(--line); }

  .page-hero__placeholder {
    position: absolute;
    inset: 0;
    display: grid;
    place-items: center;
    color: var(--ink-500);
  }

  .page-hero__copy {
    position: absolute;
    /* Same reason as .destination-hero__copy above: .page-hero__media is
       also position: relative, so paint order between two positioned,
       auto-z-index siblings follows DOM order — copy-first in the markup
       would otherwise paint BEHIND the media that follows it. inset-block-end:
       0 (no gap) runs the card flush to the photo's bottom edge, same "touch
       the wall" treatment as .destination-hero__copy above. */
    z-index: var(--z-raised);
    inset-inline: var(--gutter-x);
    inset-block-end: 0;
    max-block-size: calc(var(--hero-media-h) - var(--s-9));
    overflow-y: auto;
    display: flex;
    flex-direction: column;
    justify-content: center;
    gap: var(--s-4);
    padding: calc(var(--s-5) + 5px);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: 0;
  }

  /* THE OFFSET BORDER, BASE WIDTH: same idea as the --bp-md-up ::before
     below — a second border inset from the card's edges, left open on
     whichever edge is already flush to a wall. Here that's the block-end
     (the card touches the photo's bottom edge at this width, not the
     inline-start), so the offset border insets from the top/left/right and
     runs flush + open along the bottom instead. Inset is --s-4 minus 2px,
     tighter than the card's own (bumped) padding so it doesn't crowd it. */
  .page-hero__copy::before {
    content: "";
    position: absolute;
    inset-inline: calc(var(--s-4) - 2px);
    inset-block-start: calc(var(--s-4) - 2px);
    inset-block-end: 0;
    border: 1px solid var(--brass);
    border-block-end: 0;
    pointer-events: none;
  }

  .page-hero__copy > * { margin-block: 0; }

  /* The eyebrow says WHICH KIND of page this is — "Blog" or "Service area"
     — the one thing the title alone cannot. Optional: most page types (a
     legal page, About) have no natural eyebrow and skip it entirely. */
  .page-hero__eyebrow {
    font-size: var(--fs-micro);
    font-weight: var(--fw-subhead);
    text-transform: uppercase;
    letter-spacing: var(--tracking-micro);
    color: var(--ink-500);
  }

  .page-hero__title { max-inline-size: 34ch; }

  .page-hero__meta { color: var(--ink-500); }

  .page-hero__description {
    max-inline-size: var(--w-prose);
    /* Paragraph, not a heading — see .home-hero__intro's comment. */
    font-size: var(--fs-body);
    line-height: var(--lh-body);
    color: var(--ink-700);
  }

  /* ---- The four trades (§6.3 step 4, §6.4 step 2) ------------------------
     A HUB, NOT A RE-HOSTED GRID. Four cards linking OUT into the catalogue is
     the alternative to a per-room or per-city copy of it, which multiplies into
     dozens of near-identical doorway pages (§6.4).

     ONE COLUMN AT THE BASE WIDTH, not two. The product grid is 2-up on a phone
     because a catalogue is scanned and density reads as selection (§8.4, §5.7)
     — but these cards carry a sentence each, and a sentence in an ~160px column
     is four words a line. Different content, different answer.              */

  .trade-links {
    display: grid;
    gap: var(--grid-gutter);
    margin: 0;
    padding: 0;
    list-style: none;
  }

  .trade-link {
    display: flex;
    flex-direction: column;
    gap: var(--s-3);
    block-size: 100%;
    padding: var(--card-pad);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-md);
    color: var(--ink-900);
    text-decoration: none;
    transition:
      border-color var(--dur-fast) var(--ease),
      transform var(--dur-fast) var(--ease);
  }

  .trade-link:hover {
    border-color: var(--ink-500);
    transform: translateY(var(--lift));
  }

  .trade-link__head {
    display: flex;
    align-items: center;
    gap: var(--s-3);
  }

  /* A grid wrapper, not an inline span: .pinnacle-icon carries a baseline nudge
     so it sits correctly beside text, and inside a span that builds a line box
     the wrapper ends up taller than the glyph and the mark rides high (§9.2).
     The mark is decorative and never --accent (§8.2). */
  .trade-link__icon {
    display: grid;
    place-items: center;
    flex: none;
    color: var(--ink-700);
  }

  .trade-link__name {
    font-size: var(--fs-h3);
    font-weight: var(--fw-subhead);
  }

  /* The category's own "why this, here" line (§5.6, §1.3), read from term meta.
     A category with none renders name and mark alone — never a generic filler
     sentence, which is the §2.1 failure committed by omission. */
  .trade-link__blurb {
    font-size: var(--fs-small);
    line-height: var(--lh-body);
    color: var(--ink-700);
  }

  /* Pinned to the bottom by an auto margin, so the cues line up across a row
     however long the blurbs run — the same idiom the product card's CTA bar
     uses (§5.7), and for the same reason. */
  .trade-link__cue {
    display: flex;
    align-items: center;
    gap: var(--s-2);
    margin-block-start: auto;
    font-size: var(--fs-small);
    font-weight: var(--fw-ui);
    color: var(--ink-500);
  }

  .trade-link__cue .pinnacle-icon {
    flex: none;
    transition: transform var(--dur-fast) var(--ease);
  }

  .trade-link:hover .trade-link__cue { color: var(--ink-900); }
  .trade-link:hover .trade-link__cue .pinnacle-icon { transform: translateX(var(--s-1)); }

  /* ---- The local-proof panel (§6.4) --------------------------------------
     THE SECTION A CITY PAGE LIVES OR DIES ON, in the same band slot the room
     pages give the requirements table.

     §6.4's gate: each page needs at least one real, city-specific detail that
     could not be pasted onto another city's page unchanged. A general
     characterization of local housing stock is context, not proof. With neither
     half filled in the whole band renders nothing — and a city page with
     nothing here has not met that gate and belongs in draft, which is exactly
     what its emptiness is there to make obvious (§8.15, §11).               */

  .local-proof__layout { display: grid; gap: var(--s-7); }

  .local-proof__quote {
    margin: 0;
    padding: var(--s-6);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-md);
  }

  .local-proof__quote blockquote {
    margin: 0;
    /* Paragraph, not a heading — see .home-hero__intro's comment. */
    font-size: var(--fs-body);
    line-height: var(--lh-body);
    color: var(--ink-700);
  }

  .local-proof__quote blockquote p { margin: 0; }
  .local-proof__quote blockquote p + p { margin-block-start: var(--s-4); }

  .local-proof__source {
    margin-block-start: var(--s-4);
    font-size: var(--fs-small);
    color: var(--ink-500);
  }

  /* ---- FAQs (§6.7) -------------------------------------------------------
     HERE, NOT catalogue.css, because `.faqs`/`.faq` render on every simple
     content page too — the /faq/ page's own [pinnacle_faq] shortcode
     (pinnacle-core) included — and a plain page never loads catalogue.css
     (§8.17). Home, product, category, city and room pages all load this file
     as well, so nothing about moving here changes how any existing placement
     looks. */

  /* A HAIRLINE, AND LESS AIR. 64px of empty page was doing the separating that
     a 1px rule does better and in a twelfth of the space — and it left the FAQ
     heading floating with no relationship to what came before it. The rule
     says "new topic"; the 40px says "still the same page" (§8.4). */
  .faqs {
    margin-block-start: var(--s-9);
    padding-block-start: var(--s-8);
    border-block-start: 1px solid var(--line);
  }
  .faqs__heading { margin-block-end: var(--s-5); }

  /* RULED CELLS, ONE COLUMN. Every FAQ group on the site uses this same
     treatment — home, destination, product, category and the canonical /faq/
     page — so it lives here rather than in a per-placement stylesheet. */
  .faqs > .faq {
    min-inline-size: 0;
    border-inline: 1px solid var(--mega-media-border);
    border-block-end: 1px solid var(--mega-media-border);
  }

  .faqs > .faq:first-of-type { border-block-start: 1px solid var(--mega-media-border); }

  /* THE MARKER IS A DRAWN PLUS, NOT THE BROWSER'S OWN TRIANGLE — two absolutely
     positioned bars rather than a swapped icon or text glyph, so the SAME two
     elements animate open (rotating the vertical bar onto the horizontal one
     leaves a minus) instead of one glyph being replaced by another. Both bars
     are pseudo-elements of the trigger itself. */
  .faq__question {
    position: relative;
    display: block;
    inline-size: 100%;
    padding-block: var(--s-5);
    padding-inline: var(--s-6) var(--s-10);
    border: 0;
    background: transparent;
    font-family: inherit;
    font-size: var(--fs-h4);
    font-weight: var(--fw-subhead);
    line-height: inherit;
    color: var(--ink-900);
    text-align: start;
    cursor: pointer;
    --focus-ring: 1px solid currentColor;
  }

  /* This trigger keeps its own currentColor ring rather than the accent one.
     Only the COLOUR deviates: the width and offset are read from the tokens
     like everywhere else, via a local --focus-ring redefinition (the pattern
     .estimator uses), so a change to the ring in tokens.css reaches here too
     instead of leaving one accordion ringed wider than the rest of the site. */
  .faq__question:focus:not(:focus-visible) { outline: none; }
  .faq__question:focus-visible {
    outline: var(--focus-ring);
    outline-offset: var(--focus-offset);
  }

  .faq__question::before,
  .faq__question::after {
    content: '';
    position: absolute;
    inset-inline-end: var(--s-6);
    inset-block-start: 50%;
    inline-size: var(--s-4);
    block-size: 2px;
    background-color: var(--ink-700);
    transform: translateY(-50%);
    transition: transform var(--dur-base) var(--ease);
  }
  .faq__question::after { transform: translateY(-50%) rotate(90deg); }

  .faq.is-open > .faq__question::after { transform: translateY(-50%) rotate(0deg); }

  .faq__question:hover::before,
  .faq__question:hover::after { background-color: var(--ink-900); }

  /* HEIGHT-TO-AUTO ANIMATION FOR THE ANSWER, THE `0fr`/`1fr` GRID-ROW
     TECHNIQUE.
     A PREVIOUS VERSION USED `interpolate-size`/`calc-size()`, and was
     replaced: real content, in a real browser, showed a smooth open but a
     snap-instant close, and repeat opens grew snappier still after the
     first. Both point at the same root cause — that recipe leans on
     `content-visibility` finishing its own discrete flip (hidden ↔ visible)
     in lockstep with `calc-size(auto, size)` re-measuring the answer's
     natural height on every single toggle, and the two are not guaranteed to
     land in the same frame. The grid-row trick sidesteps the whole
     interaction: nothing here measures "auto" on each toggle, so there is
     nothing for the two to race against, and open/close are the exact same
     transition running in reverse — nothing measured, nothing gated.
     WHY THE INNER WRAPPER: a grid container's un-declared rows are
     IMPLICIT, so the answer's own paragraphs (wpautop can emit several)
     would each land in a separate auto-sized row of their own, and only row
     1 — the one row this rule actually declared — would be the one
     animating. Collecting them under one `.faq__answer-inner` grid ITEM
     makes them one row's content, so the whole answer collapses and expands
     as a single unit regardless of how many paragraphs it holds.
     WHY `.has-js` GATES THE COLLAPSE: the trigger is a <button>, so script is
     the only thing that can reopen a closed answer. Collapsing unconditionally
     would leave every answer permanently unreachable with JS off. Gating on the
     class header.php sets means a no-JS visitor gets all answers expanded and
     readable — the honest degradation §8.17 asks for — while everyone else gets
     the accordion. */
  .has-js .faq__answer {
    display: grid;
    grid-template-rows: 0fr;
    overflow: hidden;
    transition: grid-template-rows var(--dur-base) var(--ease);
  }

  .has-js .faq.is-open > .faq__answer {
    grid-template-rows: 1fr;
  }

  /* `min-block-size: 0` is what actually lets the row reach `0fr` — a grid
     item's automatic minimum size is otherwise its content's own min-content
     size (the same flex/grid gotcha `.filter-drawer__body` already notes
     elsewhere in this file), which is never 0 for a paragraph of text.
     The padding animates with the row rather than sitting inside it: a static
     block-end padding on a `0fr` row still paints, leaving a few pixels of
     dead space under every closed item. */
  .faq__answer-inner {
    min-block-size: 0;
    overflow: hidden;
    padding-inline: var(--s-6);
    padding-block-end: var(--s-6);
  }

  .has-js .faq__answer-inner {
    padding-block-end: 0;
    transition: padding-block-end var(--dur-base) var(--ease);
  }

  .has-js .faq.is-open > .faq__answer > .faq__answer-inner {
    padding-block-end: var(--s-6);
  }

  /* ---- The FAQ block, as a section rather than a block inside one --------
     .faqs carries its own hairline and its own air (above), because on a
     product or category page it sits INSIDE a page flow and has to separate
     itself from whatever precedes it. On a destination page it IS a section:
     the page's own boundary rule is already drawn above it and the compact
     rhythm has already spaced it, so its separator would put a second rule a
     few pixels under the first and double the gap (§8.4). */
  .destination-faqs .faqs {
    margin-block-start: 0;
    padding-block-start: 0;
    border-block-start: 0;
  }

  /* =========================================================================
     ENTRY POPUP (§9.7)
     ====================================================================== */

  .entry-popup {
    padding: 0;
    border: 0;
    max-inline-size: min(92vw, 480px);
    background-color: var(--surface);
    border-radius: var(--r-md);
  }

  .entry-popup::backdrop { background-color: color-mix(in srgb, var(--ink-900) 60%, transparent); }

  .entry-popup__panel { position: relative; padding: var(--s-9) var(--s-7) var(--s-7); }

  .entry-popup__close {
    position: absolute;
    inset-block-start: var(--s-3);
    inset-inline-end: var(--s-3);
    display: grid;
    place-items: center;
    inline-size: var(--tap-min);
    block-size: var(--tap-min);
    background: none;
    border: 0;
    color: var(--ink-700);
    cursor: pointer;
    border-radius: var(--r-sm);
  }

  .entry-popup__title { font-size: var(--fs-h2); margin-block-end: var(--s-5); }
  .entry-popup__body { margin-block-end: var(--s-7); }

  /* ---- Empty states (§8.15) --------------------------------------------- */

  .empty-state {
    padding-block: var(--section-y);
    max-inline-size: var(--w-prose);
  }
  .empty-state__body { margin-block: var(--s-5) var(--s-7); }

  /* ---- 404 (§6.11) ------------------------------------------------------ */

  /* The hero above (.page-hero) now paints its own bottom boundary, so this
     needs its own top breathing room rather than inheriting it from a
     .page-head that used to sit inside .container and supply it. Block-end
     matches: this is the only section on the page, so it also closes it —
     .site-main no longer supplies a page-level bottom padding. */
  .error-404 { padding-block: var(--section-y); }

  .error-404__search { margin-block-end: var(--s-10); max-inline-size: var(--w-prose); }
  .error-404__search label { display: block; margin-block-end: var(--s-3); font-weight: var(--fw-ui); }

  .field-row { display: flex; gap: var(--s-3); }
  .field-row input {
    flex: 1 1 auto;
    block-size: var(--field-h);
    padding-inline: var(--s-4);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
  }

  .error-404__heading {
    font-size: var(--fs-h3);
    margin-block-end: var(--s-5);
  }

  .link-list { display: grid; gap: var(--s-2); margin-block-end: var(--s-10); }
  .link-list a {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--s-4);
    min-block-size: var(--tap-min);
    padding: var(--s-3) var(--s-4);
    background-color: var(--surface);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    text-decoration: none;
    font-weight: var(--fw-ui);
  }
  .link-list a:hover { border-color: var(--ink-500); }

  .error-404__actions { display: flex; flex-wrap: wrap; gap: var(--s-4); }

  /* ---- Pagination ------------------------------------------------------- */

  .pagination { margin-block-start: var(--s-10); }
  .nav-links { display: flex; flex-wrap: wrap; gap: var(--s-2); }
  .page-numbers {
    display: grid;
    place-items: center;
    min-inline-size: var(--tap-min);
    min-block-size: var(--field-h);
    padding-inline: var(--s-3);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    text-decoration: none;
    font-size: var(--fs-small);
  }
  .page-numbers.current {
    background-color: var(--ink-900);
    color: var(--bg);
    border-color: var(--ink-900);
  }
  /* Non-current links only — .dots (WC's skipped-range span, catalogue.css)
     isn't a link and must not pick up a hover it can't act on. */
  a.page-numbers:hover { border-color: var(--ink-500); }

  /* ---- Footer (§9.5) ---------------------------------------------------- */

  /* ASYMMETRIC BLOCK PADDING, deliberately. The top keeps the section rhythm
     that separates the footer from the page; the bottom is closed up to the
     signature row's own 4px, because the space under a copyright line is not
     rhythm — it is the end of the document, and a section's worth of it reads
     as an unfinished page rather than a considered one (§8.4). */
  .site-footer {
    background-color: var(--bg-alt);
    border-block-start: 1px solid var(--line);
    padding-block: var(--section-y) 0;
    font-size: var(--fs-small);
  }

  .site-footer__grid {
    display: grid;
    gap: var(--s-9);
  }

  .site-footer__heading {
    font-family: var(--font-body);
    font-size: var(--fs-micro);
    font-weight: var(--fw-subhead);
    text-transform: uppercase;
    letter-spacing: var(--tracking-micro);
    color: var(--ink-500);
    margin-block-end: var(--s-5);
  }

  .site-footer__area { margin-block: var(--s-5); max-inline-size: 40ch; }

  .site-footer__list { display: grid; gap: var(--s-3); }
  .site-footer__list a { text-decoration: none; }
  .site-footer__list a:hover { text-decoration: underline; }

  .site-footer__list--areas { grid-template-columns: repeat(2, minmax(0, 1fr)); }

  /* THE LAST ROW OF THE BRAND COLUMN, not a band of its own (§9.5). The column
     already caps the width, so there is no prose cap to apply.

     IT DOES TAKE A HAIRLINE ABOVE IT. With the social marks gone this sits
     directly under the Call / Email / WhatsApp bar, and the two are different
     asks — one is "reach us now", the other is "hear from us later". Spacing
     alone read as one continuous stack of controls. --line is the lightest
     rule on the site (§8.2) and the same one the copyright row uses, so it
     separates without announcing itself. */
  /* THE HEADING IS GONE, SO ITS SPACE GOES WITH IT (§7.4). 20px of margin over
     20px of padding was sized for a block with a title in it; with one input
     left, that reads as a gap where something used to be. The hairline is what
     separates this from the channel bar — the air was only ever doubling up on
     the job the rule now does. */
  /* THE RULE SITS EXACTLY BETWEEN THE TWO BLOCKS IT SEPARATES.
     The space above it is a MARGIN and the space below it is PADDING — two
     different properties on the same element — so giving them two different
     tokens made the line sit off-centre in its own gap, which reads as the
     newsletter being pushed away from the channel bar rather than divided from
     it. One value, used twice: whatever this is set to, the rule stays
     centred. */
  .site-footer__forms {
    margin-block-start: var(--s-3);
    padding-block-start: var(--s-3);
    border-block-start: 1px solid var(--line);
  }

  /* THE ROW IS THE FIRST THING IN THE BLOCK, so nothing above it may add its
     own leading space. `.field` carries an 8px bottom margin for stacked forms
     and `.field label` a 2px one — both are cancelled here, because this row is
     a single input beside a button with a visually-hidden label (§7.4), and
     spacing meant for a stacked field set is what made the gap above the input
     read as leftover room from the deleted heading. */
  .lead-form--newsletter .lead-form__row { margin-block: 0; }
  .lead-form--newsletter .field { margin-block: 0; }

  /* A THIN SIGNATURE LINE, not a section. With the social marks moved up
     beside the contact channels this row carries only the copyright and the
     payment marks — one line of 12px text and a strip of small logos, so it
     takes the thinnest rhythm on the page: 4px of padding around a single
     line, not the 64/24px block it had when it was doing three jobs (§8.4).

     THE COPYRIGHT IS CENTRED ON THE PAGE, not on the space left over beside
     the payment marks. That is what the three-column grid at --bp-md buys:
     an empty first column mirrors the marks in the third, so the signature
     sits on the page's centre line. A flex row with space-between would push
     it off-centre by exactly the width of the card strip. Below --bp-md the
     two stack, both centred. */
  .site-footer__bar {
    position: relative;
    display: grid;
    justify-items: center;
    gap: var(--s-2);
    margin-block-start: var(--s-6);
    padding-block: var(--footer-bar-y);
    border-block-start: 1px solid var(--line);
  }

  .site-footer__copyright {
    margin: 0;
    font-size: var(--fs-small);
    line-height: var(--lh-small);
    color: var(--ink-500);
    text-align: center;
  }

  /* ---- Social + payment marks (§9.5) ------------------------------------ */

  /* BARE MARKS, NO BOX. Boxed social icons read as four more buttons competing
     with the Call / Email / WhatsApp bar directly above them — and those are
     the actions that matter (§9.3). Unboxed, they read as what they are: a
     signature row of profiles.

     THE TARGET IS STILL THE FULL SIZE. The box is gone, not the tap area: the
     link keeps --field-h and the mark simply sits smaller inside it, so
     nothing here drops below the floor in §8.4. Removing the border and
     shrinking the hit area would be two different changes, and only one of
     them was asked for. */
  .social-links {
    display: flex;
    align-items: center;
    gap: var(--s-2);
    margin-block-start: var(--s-4);
  }

  /* ul.social-links, NOT .social-links alone — .prose ul's
     padding-inline-start (list-marker indent, above) is class+type
     specificity, so a bare class here would lose to it and leave this row
     nudged 20px right of the heading/paragraph above it on the Contact page
     (§7.3). This isn't a bulleted list. */
  ul.social-links { padding-inline-start: 0; }

  .social-links__link {
    display: grid;
    place-items: center;
    inline-size: var(--field-h);
    block-size: var(--field-h);
    color: var(--ink-500);
  }

  .social-links__link .pinnacle-icon {
    inline-size: var(--s-6);
    block-size: var(--s-6);
  }

  .social-links__link:hover { color: var(--ink-900); }

  /* Centred in the stacked state; the --bp-md rule below puts it back at the
     inline end once there is a column for it to sit in. */
  .payment-marks {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--s-3);
  }
  .payment-marks__img { block-size: var(--s-7); inline-size: auto; }

  /* =========================================================================
     BREAKPOINT SWITCHES — the §8.13 table
     ====================================================================== */

  @media (min-width: 768px) {
    .post-list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .site-footer__grid { grid-template-columns: minmax(0, 2fr) repeat(3, minmax(0, 1fr));  /* brand + Explore + By room + Service areas */ }

    /* The signature row becomes one line: an empty first column mirrors the
       payment marks in the third, so the copyright lands on the page's centre
       line rather than in the gap beside them (§9.5). */
    .site-footer__bar { grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr); align-items: center; }
    .site-footer__copyright { grid-column: 2; }
    .site-footer__bar .payment-marks { grid-column: 3; justify-self: end; }
  }

  @media (min-width: 480px) {
    /* Estimator inputs go side-by-side from --bp-sm (§8.13). */
    .estimator__inputs { grid-template-columns: repeat(2, minmax(0, 1fr)); }

    /* Two trade cards a row from --bp-sm: the blurb gets a readable measure
       here, which it does not at the base width (§6.3, §6.4). */
    .trade-links { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  }

  @media (min-width: 768px) {

    /* ---- The two heroes' cards move from the photo's block-end to its
       inline-start edge (§6.3, §6.4) --------------------------------------
       The card stays position: absolute (set at the base width already) —
       only WHERE it anchors changes. inset-inline-start: 0 runs the card flush
       to the true viewport edge (not .container's left edge) — a landscape
       photo has the width to spare for a side column, and flush reads more
       deliberate than a floating gutter here. The card is auto-sized to its
       content and centred on the photo's block axis with inset-block-start:
       50% + transform: translateY(-50%) — NOT the more familiar inset-block:
       0 + margin-block: auto trick, which stretches rather than centres here:
       this card is display: flex, and an abspos flex container with BOTH
       opposing insets set resolves its auto block size to the stretch-fit
       size (fills the gap first, leaving nothing for the auto margins to
       split), not the shrink-to-fit size a plain block gets. Only ONE inset
       (inset-block-start) being non-auto avoids that path, so the card stays
       content height and the transform does the centring instead.
       inline-size is wider than the base width's full-bleed band, so
       padding-inline goes up to match rather than staying at padding-block's
       --s-5 — a wider card with the same padding on every side would read
       as slack, not generous; padding-block gets its own smaller bump (+15px
       over --s-5) for the same reason, just less of one. Identical
       arithmetic to the category hero (§5.6) — one shape, three page types.

       ::before DRAWS A SECOND, OFFSET BORDER just inside the card — inset
       from the top/right/bottom edges but flush with the card's own
       inline-start (0, matching the card) and with NO inline-start border
       segment of its own. Left open on the one edge that's already flush to
       the true viewport edge, it reads as a frame that keeps going past the
       wall rather than one that closes around the card. Inset is --s-4 minus
       2px, tighter than the card's own padding so it doesn't crowd wider
       padding-inline/padding-block. --brass, not --accent: a reddish note
       here is decorative, and §8.2's accent-scarcity rule reserves --accent
       for the one primary-action element per viewport — never
       borders/chrome. --brass exists specifically for this (hairline/icon
       detail, never text or CTAs). */
    .destination-hero__media--empty {
      border-block: 0;
    }

    .destination-hero__copy {
      inset-inline-start: 0;
      inset-inline-end: auto;
      inset-block-start: 50%;
      inset-block-end: auto;
      transform: translateY(-50%);
      inline-size: clamp(360px, 52%, 560px);
      padding-inline: var(--s-9);
      padding-block: calc(var(--s-5) + 15px);
    }

    .destination-hero__copy::before {
      content: "";
      position: absolute;
      inset-block: calc(var(--s-4) - 2px);
      inset-inline: 0 calc(var(--s-4) - 2px);
      border: 1px solid var(--brass);
      border-inline-start: 0;
      pointer-events: none;
    }

    .destination-hero__title { max-inline-size: 18ch; }

    /* ---- The generic page hero's card, same mechanics --------------------- */
    .page-hero__copy {
      inset-inline-start: 0;
      inset-inline-end: auto;
      inset-block-start: 50%;
      inset-block-end: auto;
      transform: translateY(-50%);
      inline-size: clamp(360px, 52%, 560px);
      padding-inline: var(--s-9);
      padding-block: calc(var(--s-5) + 15px);
    }

    .page-hero__copy::before {
      content: "";
      position: absolute;
      inset-block: calc(var(--s-4) - 2px);
      inset-inline: 0 calc(var(--s-4) - 2px);
      border: 1px solid var(--brass);
      border-inline-start: 0;
      pointer-events: none;
    }

    .page-hero__title { max-inline-size: 18ch; }

    /* ASYMMETRIC, NOT 6/6 (§8.18 #5) — this is the hand-set half of the site,
       and an even split is one of the tells. :has() so a page carrying only one
       of the two halves keeps the full width, rather than stranding it in a
       column with nothing beside it (§8.14). */
    .local-proof__layout:has(.local-proof__detail):has(.local-proof__quote) {
      grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
      gap: var(--s-9);
      align-items: start;
    }

    /* The requirements table becomes a real table — the three columns are
       readable side by side from here up (§8.13). */
    .requirements thead { display: table-header-group; }
    .requirements tr {
      display: table-row;
      margin: 0;
      padding: 0;
      background: none;
      border: 0;
      border-radius: 0;
    }
    .requirements th,
    .requirements td {
      display: table-cell;
      padding: var(--s-5);
      border-block-end: 1px solid var(--ink-900);
      vertical-align: top;
    }
    .requirements tr:last-child td { border-block-end: 0; }
    .requirements td + td { border-block-start: 0; }
    .requirements td::before { display: none; }
    .requirements th {
      text-align: start;
      font-size: var(--fs-small);
      font-weight: var(--fw-subhead);
      color: var(--ink-900);
      background-color: var(--bg-tint);
      border-block-end: 1px solid var(--ink-900);
    }

    .room-tiles__list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
    .projects-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
    .guarantees__list { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .guarantees__row:nth-child(even) { border-inline-start: 1px solid var(--mega-media-border); }
    .guarantees__row:nth-child(2) { border-block-start: 0; }
  }

  @media (min-width: 1024px) {
    /* All four trades on one row from --bp-lg, matching the product grid's
       column count at the same width (§8.13), so the page reads as one system. */
    .trade-links { grid-template-columns: repeat(4, minmax(0, 1fr)); }

    .room-tiles__list { grid-template-columns: repeat(4, minmax(0, 1fr)); }
    .projects-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--grid-gutter); }
    .guarantees__list { grid-template-columns: repeat(3, minmax(0, 1fr)); }
    .guarantees__row { padding: var(--s-7); }
    .guarantees__row:nth-child(even) { border-inline-start: 0; }
    .guarantees__row:nth-child(3n + 2),
    .guarantees__row:nth-child(3n + 3) { border-inline-start: 1px solid var(--mega-media-border); }
    .guarantees__row:nth-child(3) { border-block-start: 0; }
  }

  /* --bp-lg (1024px) is where "desktop" begins for every behavioural switch
     (§8.13). */
  @media (min-width: 1024px) {

    /* --header-h steps 56 — 64 in the tokens.css swap block, with the rest of
       the per-width pairs — a raw pixel value has no business in this file
       (§3.8). */

    /* Inline nav + the search toggle + CTA return; the mobile trigger and panel
       are not rendered at all, so the tab order stays single. The search panel
       needs nothing here — the browser's own popover rule paints it when it
       opens, and the toggle is what makes it reachable. */
    .primary-nav { display: block; }
    .search-toggle { display: grid; }
    .site-header__cta { display: inline-flex; }
    .nav-trigger,
    .mobile-nav { display: none; }

    /* THE NAV SITS ON THE TRUE CENTRE LINE OF THE PAGE, which takes a
       three-column grid with mirrored 1fr sides. Centring it with an auto
       inline margin inside the base flex row would instead centre it in the
       space LEFT OVER between the logo and the actions, landing it off-centre
       by half the difference between those two widths. Same failure, same fix,
       as the footer copyright row (§9.5).

       The actions keep the base rule auto inline-start margin, which now
       pushes them to the end of their own column rather than across a flex
       row. */
    .site-header__inner {
      display: grid;
      grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
      gap: var(--s-8);
    }

    /* The descender line is unconditional now (see the base rule), so there is
       nothing to turn on here. The SVG variants still swap: the committed
       assets are two separate lockups, and only one of them can be scaled to a
       64px header without the small line degrading (§9.9). */
    .site-logo__mark--compact { display: none; }
    .site-logo__mark--full { display: block; }

    .post-list { grid-template-columns: repeat(3, minmax(0, 1fr)); }

    .floating-cta {
      inset-block-end: var(--s-8);
      inset-inline-end: var(--s-8);
    }
    /* At --bp-lg+ the product CTA bar is hidden and the floating CTA returns. */
    .has-product-cta-bar .floating-cta { display: flex; }
  }

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

  .marquee-ticker {
    background-color: var(--bg-alt);
    padding-block: var(--s-3);
    overflow: hidden;
    user-select: none;
    position: relative;
    contain: content;
  }

  .marquee-ticker__container {
    display: flex;
    overflow: hidden;
    max-inline-size: var(--w-max);
    margin-inline: auto;
    padding-inline: var(--gutter-x);
    mask-image: linear-gradient(to right, transparent, var(--ink-900) var(--s-7), var(--ink-900) calc(100% - var(--s-7)), transparent);
    -webkit-mask-image: linear-gradient(to right, transparent, var(--ink-900) var(--s-7), var(--ink-900) calc(100% - var(--s-7)), transparent);
  }

  .marquee-ticker__track {
    display: flex;
    inline-size: max-content;
    animation: marquee-scroll 35s linear infinite;
    will-change: transform;
  }

  .marquee-ticker:hover .marquee-ticker__track,
  .marquee-ticker:focus-within .marquee-ticker__track {
    animation-play-state: paused;
  }

  .marquee-ticker__group {
    display: flex;
    align-items: center;
    gap: var(--s-6);
    padding-inline-end: var(--s-6);
    flex-shrink: 0;
  }

  .marquee-ticker__item {
    display: flex;
    align-items: center;
    gap: var(--s-3);
    white-space: nowrap;
    font-size: var(--fs-small);
    color: var(--ink-900);
  }

  .marquee-ticker__badge {
    display: inline-block;
    background-color: var(--surface);
    color: var(--accent);
    border: 1px solid var(--line);
    border-radius: var(--r-sm);
    padding-inline: var(--s-2);
    padding-block: var(--s-1);
    font-size: var(--fs-micro);
    font-weight: var(--fw-subhead);
    text-transform: uppercase;
    letter-spacing: var(--tracking-micro);
  }

  .marquee-ticker__text {
    font-weight: var(--fw-ui);
    color: var(--ink-700);
  }

  .marquee-ticker__sep {
    color: var(--brass);
    font-size: var(--fs-small);
    margin-inline-start: var(--s-3);
  }

  @keyframes marquee-scroll {
    0% { transform: translateX(0); }
    100% { transform: translateX(-50%); }
  }
}

/* =============================================================================
   UTILITIES
   ========================================================================== */
@layer utilities {

  /* THE `hidden` ATTRIBUTE MUST ACTUALLY HIDE THINGS.
   *
   * `[hidden] { display: none }` is a USER-AGENT rule, so ANY author
   * `display:` on the same element beats it. Every component that toggles
   * visibility with the attribute — the floating CTA's options, appointment
   * step 2, the compare tray, the estimator result, the favourites steps, the
   * rail's pause button, the filter count — also sets `display: flex` or
   * `grid` for its open state, and so was rendering PERMANENTLY OPEN. The
   * floating CTA is the one you notice: its channel list sat open on every
   * page, which reads as "it opens on hover" when in truth it never closed.
   *
   * Fixed here rather than per component, and in the LAST cascade layer rather
   * than by forcing priority — layer order beats specificity, which is the
   * whole reason §8.14 uses layers. A component that genuinely needs to
   * override this has to say so in a later layer, deliberately. */
  [hidden] { display: none; }

  /* …AND THE ONE DELIBERATE EXCEPTION TO IT (§8.17).
   *
   * A [data-modal-panel] is authored `hidden` because forms.js is going to
   * lift it into a <dialog> and open it on demand (§7.5, §5.10). With the
   * script absent there is nothing to open it, so the panel must be a plain
   * visible part of the form instead — otherwise the favourites and estimator
   * fields would exist in the markup and be unreachable, which is exactly the
   * "JavaScript is a progressive layer" rule failing silently.
   *
   * `.has-js` is set on <html> by the one inline script in header.php, before
   * first paint. Higher specificity than the bare `[hidden]` above and in the
   * same layer, so it wins on ordinary cascade rules rather than by forcing
   * priority (§3.8, §8.14). */
  html:not(.has-js) [data-modal-panel][hidden] { display: block; }

  .u-hidden { display: none; }

  .u-center { text-align: center; }

  /* Below-the-fold sections skip layout/paint until scrolled near (§8.14).
     contain-intrinsic-size must be set wherever this is used, or skipping
     causes the shift the fixed ratios exist to prevent. */
  .u-defer-render {
    content-visibility: auto;
  }
}

/* =============================================================================
   SHORT VIEWPORTS (§8.13)
   The only max-* queries in the stylesheet, and both are capability/height
   rules rather than width breakpoints. A centred modal taller than the
   viewport is the unclosable-modal failure §8.8 exists to prevent.
   ========================================================================== */
@media (max-height: 640px) {
  .mobile-nav__panel { inline-size: 100%; }
}
/* =============================================================================
   SCROLLBARS — unlayered on purpose.

   ::-webkit-scrollbar inside @layer is ignored or only half-applied, and
   Chromium DROPS the webkit painting entirely if scrollbar-width is also set,
   leaving the UA's thin bar. Firefox has no webkit pseudo, so it gets the
   standard properties behind @supports not selector(::-webkit-scrollbar).
   Chrome/Edge/Safari get the 8px track / 4px inset pill that thickens on
   hover. Arrow buttons are dropped. Never --accent (§8.2).
   width/height (not logical properties) are what this pseudo honours.
   ========================================================================== */
@supports not selector(::-webkit-scrollbar) {
  * {
    scrollbar-width: thin;
    scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
  }
}
*::-webkit-scrollbar {
  width: var(--scrollbar-size);
  height: var(--scrollbar-size);
}
*::-webkit-scrollbar-track {
  background: var(--scrollbar-track);
}
*::-webkit-scrollbar-thumb {
  background-color: var(--scrollbar-thumb);
  background-clip: content-box;
  border: var(--s-1) solid transparent;
  border-radius: var(--r-sm);
  transition:
    background-color var(--dur-fast) var(--ease),
    border-width var(--dur-fast) var(--ease);
}
*::-webkit-scrollbar-thumb:hover,
*::-webkit-scrollbar-thumb:active {
  background-color: var(--scrollbar-thumb-hover);
  border-width: 0;
}
*::-webkit-scrollbar-button {
  display: none;
}
*::-webkit-scrollbar-corner {
  background: var(--scrollbar-track);
}


  /* ---- Home service areas (§6.1, §6.4) -----------------------------------
     Text-only by design: the city names themselves are the navigation, while
     the shared hairlines keep this in the same grid language as the rest of
     the site without inventing photography for places that require real proof. */
  .home-cities {
    padding-block: var(--section-y);
    background-color: var(--bg);
  }

  .home-cities__head {
    display: grid;
    gap: var(--s-5);
    padding-block: var(--s-7);
    border-block-end: 1px solid var(--mega-media-border);
  }

  .home-cities__eyebrow {
    margin-block-end: var(--s-2);
    color: var(--canada-red);
    font-size: var(--fs-micro);
    font-weight: var(--fw-ui);
    letter-spacing: var(--tracking-micro);
    text-transform: uppercase;
  }

  .home-cities__intro {
    max-inline-size: var(--w-prose);
    margin: 0;
    color: var(--ink-700);
  }

  .home-cities__grid {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    margin: 0;
    padding: 0;
    border-block-start: 1px solid var(--mega-media-border);
    border-inline-start: 1px solid var(--mega-media-border);
    list-style: none;
  }

  .home-cities__item {
    border-inline-end: 1px solid var(--mega-media-border);
    border-block-end: 1px solid var(--mega-media-border);
  }

  .home-city {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr) auto;
    align-items: center;
    gap: var(--s-4);
    min-block-size: var(--tap-min);
    padding: var(--s-5);
    color: var(--mega-media-border);
    text-decoration: none;
    transition: background-color var(--dur-fast) var(--ease);
  }

  .home-city:hover,
  .home-city:focus-visible {
    background-color: var(--bg-tint);
  }

  .home-city__index {
    color: var(--ink-500);
    font-size: var(--fs-micro);
    font-variant-numeric: tabular-nums;
  }

  .home-city__name {
    font-family: var(--font-display);
    font-size: var(--fs-h4);
    font-weight: var(--fw-subhead);
    line-height: var(--lh-heading);
  }

  .home-city__cue {
    display: flex;
    color: var(--ink-500);
    transition: transform var(--dur-fast) var(--ease), color var(--dur-fast) var(--ease);
  }

  .home-city:hover .home-city__cue,
  .home-city:focus-visible .home-city__cue {
    color: var(--mega-media-border);
    transform: translateX(var(--s-1));
  }

  .home-cities__footer {
    padding-block: var(--s-5);
  }

  .home-cities__all {
    display: inline-flex;
    align-items: center;
    min-block-size: var(--tap-min);
    color: var(--mega-media-border);
    font-size: var(--fs-small);
    font-weight: var(--fw-ui);
    text-underline-offset: var(--s-1);
  }

  @media (min-width: 768px) {
    .home-cities__head {
      grid-template-columns: minmax(0, 5fr) minmax(0, 7fr);
      align-items: end;
      gap: var(--s-9);
    }

    .home-cities__grid {
      grid-template-columns: repeat(2, minmax(0, 1fr));
    }

  }

  @media (min-width: 1024px) {
    .home-cities__grid {
      grid-template-columns: repeat(3, minmax(0, 1fr));
    }

  }
