/* ---------------------------------------------------------------------------
   Sankartik — base layer: fonts, reset, dual-ground sections, typography, CTAs
--------------------------------------------------------------------------- */

/* Fonts are SELF-HOSTED. This is a binding rule, not a preference.
   The Google Fonts CDN failed silently during Phase 4 proofing and rendered a
   measurement document in a substitute face with no warning. There is no CDN
   link anywhere in this project, and none may be added as a "fallback" — a
   fallback that works is how a silent substitution stays invisible.

   WOFF2 only. Each file is a STATIC INSTANCE subset to latin, generated by
   "npm run fonts" from the TTFs in Jost/ and Source_Serif_4/. The variable
   axes are instanced away, so no unused weight axis ships.

   If a filename changes here, change it in scripts/check-fonts.js and in the
   preloads in layouts/base.njk as well — all three must agree.

   unicode-range matches what the subset actually contains, so a browser never
   downloads one of these faces to draw a character it does not carry.
   See src/assets/fonts/README.md. */

@font-face {
  font-family: 'Jost';
  src: url('/assets/fonts/jost-300.woff2') format('woff2');
  font-weight: 300;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0020-007E, U+00A0, U+00A9, U+00B7, U+00E1, U+00E4,
                 U+00E8-00E9, U+00ED, U+00F1, U+00F3, U+00F6, U+00FA, U+00FC,
                 U+20B9, U+2013-2014, U+2018-2019, U+201C-201D, U+2026, U+2192;
}
@font-face {
  font-family: 'Jost';
  src: url('/assets/fonts/jost-400.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0020-007E, U+00A0, U+00A9, U+00B7, U+00E1, U+00E4,
                 U+00E8-00E9, U+00ED, U+00F1, U+00F3, U+00F6, U+00FA, U+00FC,
                 U+20B9, U+2013-2014, U+2018-2019, U+201C-201D, U+2026, U+2192;
}
@font-face {
  font-family: 'Source Serif 4';
  src: url('/assets/fonts/source-serif-4-400.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0020-007E, U+00A0, U+00A9, U+00B7, U+00E1, U+00E4,
                 U+00E8-00E9, U+00ED, U+00F1, U+00F3, U+00F6, U+00FA, U+00FC,
                 U+20B9, U+2013-2014, U+2018-2019, U+201C-201D, U+2026, U+2192;
}
@font-face {
  font-family: 'Source Serif 4';
  src: url('/assets/fonts/source-serif-4-600.woff2') format('woff2');
  font-weight: 600;
  font-style: normal;
  font-display: swap;
  unicode-range: U+0020-007E, U+00A0, U+00A9, U+00B7, U+00E1, U+00E4,
                 U+00E8-00E9, U+00ED, U+00F1, U+00F3, U+00F6, U+00FA, U+00FC,
                 U+20B9, U+2013-2014, U+2018-2019, U+201C-201D, U+2026, U+2192;
}

/* Reset ------------------------------------------------------------------ */

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

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

/* Scroll padding. The header is position:sticky, so scrolling a focused
   element into the VIEWPORT can still place it under the pinned bar: the
   viewport is not the visible area. Measured before this rule: backward
   tabbing to "Explore interior design" on /what-we-do/ put the link at y 0-55
   inside a header occupying 0-73, with every hit-test sample returning the
   header. That is WCAG 2.2 SC 2.4.11, Focus Not Obscured.

   The offsets are the elements' OUTER heights plus the focus indicator's own
   reach, because an indicator hidden under the bar is as invisible as the
   element. The mobile bar is fixed at the bottom below 960px, which is the
   breakpoint it appears at in components.css. */
:root {
  scroll-padding-top: calc(var(--header-outer-h) + var(--focus-clearance));
}

@media (max-width: 960px) {
  :root {
    scroll-padding-bottom: calc(var(--mobilebar-outer-h) + var(--focus-clearance) + var(--consent-h, 0px));
  }
}

/* Bottom scroll padding ABOVE 960px — owner decision, 26 September 2026.
   -------------------------------------------------------------------------
   The rule above is scoped to 960px because the mobile bar was the only
   bottom overlay this site had, and it is display:none above that width
   (components.css). THE CONSENT BANNER IS THE FIRST BOTTOM OVERLAY THAT
   EXISTS AT DESKTOP WIDTHS, so desktop needs its own reservation.

   MEASURED, not declared: --consent-h is the open banner's rendered height,
   published by consent.js and removed when it closes. This resolves to
   scroll-padding-bottom: 0px whenever the banner is closed or absent, which
   is every page today — so no page changes until analytics ships.

   The 960px rule above is deliberately NOT widened: widening an existing
   rule's breakpoint would reserve --mobilebar-outer-h at widths where no
   mobile bar exists, which is 61px of padding for nothing. Two rules, each
   naming only the overlays that exist at its own width.

   --focus-clearance IS included, matching the mobile rule — owner decision,
   26 September 2026. A focused control stops the same distance above the
   banner as it does above the mobile bar, so the focus indicator is never
   flush against the overlay edge. SUPERSEDES the earlier position recorded
   here, that the banner's height alone was the reservation: that was written
   because the clearance had not been specified for desktop, and it now has
   been. The token is reused as it stands; no new value.

   THE CLEARANCE APPLIES ONLY WHILE THE BANNER IS OPEN, and the state signal
   is one consent.js already owns: it sets --consent-h on open and REMOVES it
   on close. No new attribute was added for this.

   WHY THE INDIRECTION BELOW, and not the obvious
     calc(var(--consent-h, 0px) + var(--focus-clearance)):
   with the banner closed --consent-h is removed, that fallback supplies 0px,
   and the result is 5px of scroll padding reserved for an overlay that is not
   on screen. The owner asked for ZERO when closed. So the sum lives inside
   --consent-reserve, where --consent-h is referenced WITH NO FALLBACK.

   WHAT THAT DOES, MEASURED — and NOT what I first wrote here. When --consent-h
   is absent, --consent-reserve is invalid at computed-value time, and such a
   declaration does NOT fall back to an earlier declaration in the same rule:
   the property resets to its INITIAL value, which for scroll-padding-bottom is
   auto. An isolated two-declaration test confirmed it (7px on a div was
   discarded too), so an earlier "scroll-padding-bottom: 0px" line in this rule
   never applied and has been removed rather than left to mislead.

   auto IS the zero the owner asked for: it is this site's own computed value
   above 960px today, and it reserves no space. Measured at 1440: 89px while
   open (84px banner + 5px clearance), auto once a choice is stored.

   SCOPED TO min-width: 961px, AND IT MUST BE. An unscoped :root rule has the
   same specificity as the 960px one above and comes later in the file, so it
   WINS at 390 and silently drops --mobilebar-outer-h and --focus-clearance
   from the mobile reservation. MEASURED, not reasoned about: unscoped, the
   obscured-focus guard went from 0 findings at 390 to 38 — 20 in 390-closed
   and 18 in 390-mobilenav — while fixing the 92 desktop ones. The two rules
   must not overlap, and 961px is the first width at which the mobile bar is
   gone. */
@media (min-width: 961px) {
  :root {
    --consent-reserve: calc(var(--consent-h) + var(--focus-clearance));
    scroll-padding-bottom: var(--consent-reserve);
  }
}

body {
  margin: 0;
  font-family: var(--font-text);
  font-size: var(--step-0);
  line-height: var(--leading-body);
  background: var(--ground-light);
  color: var(--on-light);
  -webkit-font-smoothing: antialiased;
  overflow-x: hidden;
}

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

h1, h2, h3, h4 {
  font-family: var(--font-text);
  font-weight: 600;
  line-height: var(--leading-head);
  letter-spacing: var(--tracking-head);
  margin: 0 0 var(--space-m);
  text-wrap: balance;
}

h1 { font-size: var(--step-4); line-height: var(--leading-tight); }
h2 { font-size: var(--step-3); }
h3 { font-size: var(--step-2); }
h4 { font-size: var(--step-1); }

p { margin: 0 0 var(--space-m); max-width: var(--measure); text-wrap: pretty; }

/* Links take the primary ink of their ground with an underline. The underline,
   not a colour change, is the affordance. No colour is reserved for links. */
a { color: inherit; text-underline-offset: 0.18em; }
a:hover { text-decoration-thickness: 2px; }

ul, ol { margin: 0 0 var(--space-m); padding-left: 1.25em; }
li { margin-bottom: var(--space-2xs); max-width: var(--measure); }

/* Focus. Visible on both grounds. */
:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 3px;
  border-radius: 2px;
}

/* Utilities -------------------------------------------------------------- */

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

.skip-link {
  position: absolute;
  left: var(--space-m);
  top: var(--space-m);
  z-index: 200;
  transform: translateY(-200%);
  background: var(--ground-light);
  color: var(--on-light);
  padding: var(--space-s) var(--space-m);
  border-radius: var(--radius-s);
  font-family: var(--font-ui);
  text-decoration: none;
}
.skip-link:focus { transform: translateY(0); }

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

.eyebrow {
  font-family: var(--font-ui);
  font-weight: 400;
  font-size: var(--step--1);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  margin: 0 0 var(--space-s);
}

/* Dual-ground sections ---------------------------------------------------
   The site alternates. It is NOT a dark site. An empty section on a light
   ground reads as restraint; on a dark ground it reads as a hole.
   Ground classes set their own text colours so a secondary can never cross. */

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

/* Split sections — PROTOTYPE, branch layout-prototype, 21 September 2026.

   From 1120px up, a prose section's heading (and the eyebrow above it) sits in
   a left column and everything else in a right column. Below 1120px nothing
   applies and the section is single-column, exactly as before.

   DOM ORDER IS UNCHANGED. The heading stays first in the markup; only the
   grid placement moves it. Reading order, focus order and the accessibility
   tree are therefore identical to the single-column build — CSS grid placement
   does not reorder the accessibility tree, and no `order` property is used.

   WHY 1120px, AND WHY 1fr 2fr. The right column must hold the full 68ch
   measure (~626px) without narrowing it, or the split would trade horizontal
   emptiness for shorter lines, which is the opposite of the point. At a 48px
   gap, 1fr 2fr puts the right column at:

       1120 (breakpoint)  654px    1440   709px    1920   704px

   all above 626. Below about 1100 it falls under the measure, which is what
   sets the breakpoint — it is measured, not a round number chosen for looks.

   Only DIRECT children are placed. A section whose content is itself a grid
   is not given this class: see the classification in STATE.md.

   HOW THE TWO COLUMNS ARE FORMED, WITHOUT TOUCHING THE MARKUP. The container
   becomes a two-column grid. The heading and any eyebrow are placed in column
   1; every other direct child is placed in column 2 and flows down it. This
   needs NO wrapper elements, so no template gains a div and the markup stays
   exactly as it ships on main — only a class is added to the <section>.

   `grid-auto-flow: row dense` is deliberately NOT used: it can reorder visual
   placement relative to DOM order, which is precisely what must not happen. */

@media (min-width: 1120px) {
  .section--split > .container {
    display: grid;
    grid-template-columns: 1fr 2fr;
    column-gap: var(--space-xl);
    align-items: start;
  }

  /* Column 1, row 1: the heading block. An eyebrow, where present, sits in
     the same cell above its heading — the two are one unit, so they share a
     grid area rather than occupying two rows the body would have to clear. */
  .section--split > .container > .eyebrow,
  .section--split > .container > :is(h2, h3) {
    grid-column: 1;
  }

  /* Every other direct child stacks in column 2, starting at row 1. */
  .section--split > .container > :not(.eyebrow, h2, h3) {
    grid-column: 2;
    grid-row: auto;
  }

  /* The heading's bottom margin is single-column spacing. In two columns the
     column gap separates them instead. */
  .section--split > .container > :is(h2, h3) { margin-bottom: 0; }

  /* The same rule, for the other side of the split. A block placed in column 2
     may carry a top margin meant to separate it from the heading ABOVE it when
     the section is one column — .services, .locations and .offer all do, at
     --space-xl or --space-l. Once the container is a grid that margin separates
     nothing: it offsets the whole right column against the heading, so the two
     columns share no top line. Measured on the homepage before this rule:
     .services sat 40px below its heading's box at 1280 and 1440.

     Scoped to DIRECT children of the split container, so a margin INSIDE either
     column still does its job. Column 1's own children are untouched. */
  .section--split > .container > :not(.eyebrow, h2, h3) { margin-top: 0; }

  /* Column 1 holds its position while column 2 scrolls past it. The heading is
     the label for everything in column 2, and column 2 is taller than it in
     every section on the site: measured across all 32 split sections at 1280
     and 1440, column 1 is 49-300px against a column 2 of 59-1587px, leaving
     9-1388px of empty space under the heading. The homepage's first split is
     the extreme case - a 196px heading beside a 1584px column.

     top is the header's own height plus one spacing step. Both are existing
     tokens: --header-outer-h already includes the header's hairline and is the
     same value base.css:81 uses for scroll-padding-top, and --space-xl is the
     split's own column-gap. No new value is introduced. Measured: the pinned
     heading sits at 113px with the header ending at 73px, a 40px clearance,
     and across every split section at both widths the heading NEVER sits under
     the header - smallest gap 40px, which is --space-xl exactly.

     Only the heading sticks, not the eyebrow. Both were tried: sharing one
     offset they overlap by 24px, because they are separate grid items that
     pin to the same line. The eyebrow scrolls away and the heading holds.

     Where column 1 is as tall as or taller than column 2 nothing changes:
     sticky has no travel inside a containing block no taller than the element,
     so it stays exactly where static layout put it. No section is in that
     state today; the rule is written so one could be added safely.

     position:sticky here is layout, not motion. There is no transition, no
     transform and nothing driven BY scroll position - the element simply stops
     at an offset. It is not the scroll-triggered animation CLAUDE.md prohibits.

     Checked for the classic failure: body carries overflow-x:hidden
     (base.css:98), which would break sticky if it made body the scrollport.
     It does not - the browser propagates body overflow to the viewport, so the
     document stays the scroll container. Verified by measurement, not assumed;
     the heading pins correctly. Do not move overflow-x to html, which would
     break this. */
  .section--split > .container > :is(h2, h3) {
    position: sticky;
    top: calc(var(--header-outer-h) + var(--space-xl));
  }
}

.ground-dark {
  background: var(--ground-dark);
  color: var(--on-dark);
}
.ground-dark .text-secondary { color: var(--on-dark-2); }

/* Where two dark-ground sections meet ------------------------------------
   Both grounds are --ground-dark, so an adjacent pair reads as one block
   with two headings and no boundary. A 1px --hairline rule marks the seam.

   This is a DECORATIVE separator between sections, not a control boundary,
   so the §6 dark-ground control rule (1px --on-dark-2, forced by the 3:1
   non-text threshold) does not apply here. Nothing is being distinguished
   from its background as an interactive target. Owner decision,
   25 September 2026.

   Scoped to sections inside <main>: not the header, not the footer, both of
   which meet a dark section on some pages and are not section seams.
   .segment-hero does NOT carry .ground-dark, so both classes are named.

   Surveyed across all 16 pages at 1280 and 390 before this was written:
   /projects/ is the only page where it triggers, one seam, where the five
   empty project sections remove themselves per §7 and leave the hero
   against the enquiry CTA. */

main > :is(.ground-dark, .segment-hero) + :is(.ground-dark, .segment-hero) {
  border-top: 1px solid var(--hairline);
}

.ground-light {
  background: var(--ground-light);
  color: var(--on-light);
}
.ground-light .text-secondary { color: var(--on-light-2); }

.ground-panel {
  background: var(--panel);
  color: var(--on-dark);
}
/* On panel, the secondary is CAPTIONS ONLY, never body (5.72:1). */
.ground-panel .caption { color: var(--on-dark-2); }

/* CTAs -------------------------------------------------------------------
   Primary is a light pill with dark text on dark grounds, and solid panel
   green with light text on light grounds. Secondary is outlined on both. */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-xs);
  font-family: var(--font-ui);
  font-weight: 400;
  font-size: var(--step-0);
  letter-spacing: 0.01em;
  padding: var(--space-s) var(--space-l);
  border-radius: var(--radius-pill);
  border: var(--border-w) solid transparent;
  text-decoration: none;
  cursor: pointer;
  transition: background-color var(--dur-enter) var(--ease-out),
              border-color var(--dur-enter) var(--ease-out),
              color var(--dur-enter) var(--ease-out);
}

.ground-dark .btn--primary,
.ground-panel .btn--primary {
  background: var(--ground-light);
  color: var(--ground-dark);
  border-color: var(--ground-light);
}
.ground-dark .btn--primary:hover,
.ground-panel .btn--primary:hover { background: var(--pill-hover); border-color: var(--pill-hover); }

.ground-light .btn--primary,
.btn--primary {
  background: var(--panel);
  color: var(--ground-light);
  border-color: var(--panel);
}
.ground-light .btn--primary:hover { background: var(--ground-dark); border-color: var(--ground-dark); }

/* Control boundary on dark ground — owner decision, 16 September 2026.
   -------------------------------------------------------------------------
   A --panel fill on a dark backdrop is 1.26:1 against --ground-dark and
   1.00:1 against --panel itself: below the 3.0 non-text threshold, so the
   control is not distinguished from what it sits on. The rule above sets
   border-color to the SAME token as the fill, so the boundary CLAUDE.md §6
   requires exists in the box model and paints invisibly.

   --hairline was measured and REJECTED as the remedy: 1.55:1 against
   --ground-dark and 1.23:1 against --panel. Both still fail. §6 scopes it to
   "non-text panel boundaries only", and its controls sentence does not hold on
   dark ground with the current token set. Do not re-derive this.

   --on-dark-2 #A8B5AE measures 7.19:1 against --ground-dark and 5.72:1 against
   --panel. An approved palette token in a new position, not a new colour. It is
   1.99:1 on light ground and §6 forbids it there, so these selectors name the
   dark-ground cases only and must never be widened to bare .btn--primary.

   Fill, text colour, border width, radius and padding are unchanged.

   The :hover selectors are NOT redundant. Case C sits inside .ground-light, so
   `.ground-light .btn--primary:hover` (0,3,0) matches it and outranks the rest
   selector here (0,2,0), reverting border-color to --ground-dark — 1.26:1
   against the --panel backdrop, invisible again. Measured with a real mouse
   event, not assumed: without these, the boundary vanishes on hover. Do not
   remove them.

   The hero selectors are TWO components, not one. The homepage uses .hero; the
   five segment pages use .segment-hero. Both are dark-ground sections carrying
   no ground class, so neither the base rule nor the scope rules reach them.
   `.hero .btn--primary` alone would fix 1 of the 6 failing buttons — verified by
   counting matches on every page, not by reading the markup. Both are named.

   The hero ratios are measured against PAINTED PIXELS, not a token: the button
   sits over an image, and the scrim varies across it. Replacing the hero image,
   changing the scrim or moving the layout CHANGES these numbers, and
   check-contrast-rendered.js cannot see image paint, so it will not catch a
   regression here. Re-sample by hand after any such change. */

.site-header .btn--primary,
.site-header .btn--primary:hover,
.service--secondary .btn--primary,
.service--secondary .btn--primary:hover,
.offer .btn--primary,
.offer .btn--primary:hover,
.hero .btn--primary,
.hero .btn--primary:hover,
.segment-hero .btn--primary,
.segment-hero .btn--primary:hover,

/* Consent banner — owner decision, 26 September 2026. Accept and Decline are
   two controls on a dark ground, so they take the 16 September remedy above.
   THE RULE IS UNCHANGED; this applies it to two new controls.

   They are .btn--secondary, not --primary: the spec requires Accept and
   Decline to be equal in size and weight, and the same class is how that is
   guaranteed rather than asserted. .btn--secondary sets border-color to
   currentColor, which on dark ground is --on-dark at 14.33:1; naming them here
   sets it to --on-dark-2 at 7.19:1 instead. BOTH PASS the 3.0 non-text
   threshold, and the decision is to use the settled remedy rather than to keep
   whichever value happens to be higher. */
.consent .btn--secondary,
.consent .btn--secondary:hover {
  border-color: var(--on-dark-2);
}

.btn--secondary { background: transparent; border-color: currentColor; color: inherit; }
.ground-dark .btn--secondary:hover,
.ground-panel .btn--secondary:hover { background: rgba(247, 248, 246, 0.08); }
.ground-light .btn--secondary:hover { background: rgba(30, 59, 50, 0.06); }

/* Reduced motion ---------------------------------------------------------
   Implemented ONCE, as a media query on motion attributes, not as per-element
   declarations — so elements added later cannot silently miss it.
   Transforms are removed; opacity and instant state remain. */

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