@layer components {

/* shared section shell */
.services, .work, .finishes, .trust, .area, .faq, .quote {
  padding: var(--s-10) var(--gutter);
}
.eyebrow + h2 { margin-top: var(--s-3); }
:is(.services, .work, .finishes, .area, .faq, .quote) > * + * { margin-top: var(--s-5); }

/* ── SERVICES: the lit list ────────────────────────────────────────────
   The active row is solid, everything else falls back to a whisper, and the
   photograph beside it changes to the real job for that line. One thing is
   read at a time, and every claim carries its own evidence. */
.svcx { background: var(--ink-000); padding: var(--s-9) var(--gutter) var(--s-10); }
.svcx__stage {
  display: grid;
  grid-template-columns: minmax(0, 1.72fr) minmax(0, 0.8fr);
  grid-template-areas: "head card" "list card" "cta card";
  gap: var(--s-7) clamp(2rem, 5vw, 6rem);
  align-items: start;
}
.svcx__head { grid-area: head; }
.svcx__cta { grid-area: cta; margin-top: var(--s-2); }
.svcx__head h2 { max-width: 9em; }
.svcx__head h2 { font-size: var(--step-h2); line-height: var(--lh-heading); }
.svcx__head p { margin-top: var(--s-4); color: var(--fog-700); max-width: 34ch; }

/* -0.4em cancels the left sidebearing of the display glyphs so the INK aligns
   to the grid, not the glyph box. ESR does this at 92px; at our size it is
   about a 25px correction, and it is the difference between "designed" and
   "dropped in". */
.svcx__list { grid-area: list; list-style: none; padding: 0; margin: 0 0 0 -0.045em; }
.svcx__row { border-top: 1px solid var(--ink-200); }
.svcx__row:last-child { border-bottom: 1px solid var(--ink-200); }
.svcx__btn {
  width: 100%; background: none; border: 0; cursor: pointer;
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--s-5); padding: clamp(0.5rem, 1.1vw, 1rem) 0;
  text-align: left;
}
.svcx__name {
  font-family: var(--font-display);
  font-variation-settings: 'wdth' 122;
  font-weight: 800;
  font-size: clamp(1.9rem, 0.9rem + 3.6vw, 4.4rem);
  line-height: 1.0;
  letter-spacing: -0.028em;
  /* the whisper state. Not opacity: a real colour step keeps the text crisp */
  color: var(--ink-300);
  transition: color 420ms var(--ease-out-quart), translate 420ms var(--ease-out-quart);
}
.svcx__spec {
  flex: none; display: grid; gap: 0.15rem; text-align: right;
  font-family: var(--font-mono); font-size: var(--step-mono);
  letter-spacing: var(--ls-mono);
  opacity: 0; translate: 0 6px;
  transition: opacity 420ms var(--ease-out-quart), translate 420ms var(--ease-out-quart);
}
.svcx__spec b { color: var(--cure-500); font-weight: 600; }
.svcx__spec i { font-style: normal; color: var(--fog-500); }

.svcx__row.is-on .svcx__name { color: var(--fog-max); translate: 10px 0; }
.svcx__row.is-on .svcx__spec { opacity: 1; translate: 0 0; }
.svcx__btn:hover .svcx__name,
.svcx__btn:focus-visible .svcx__name { color: var(--fog-900); }

.svcx__card {
  grid-area: card; position: sticky; top: 14vh;
  aspect-ratio: 1; border-radius: var(--r-card); overflow: clip;
  background: var(--ink-100);
}
.svcx__img {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
  opacity: 0; scale: 1.06;
  transition: opacity 560ms var(--ease-out-quart), scale 900ms var(--ease-out-quart);
}
.svcx__img.is-on { opacity: 1; scale: 1; }
/* --dur-* collapse to 1ms under reduced motion, but these two durations are
   written as literals, so nothing was reaching them. */
@media (prefers-reduced-motion: reduce) {
  .svcx__img { transition-duration: 1ms; }
}

@media (max-width: 900px) {
  /* `card` and `list` now share ONE grid area instead of each owning a row.
     A grid area auto-sizes to its TALLEST occupant, so once list carries its
     own reserved space (via the padding-top below) the shared area is exactly
     as tall as image + list combined, and THAT full height is card's sticky
     containing block. Giving card its own dedicated row, as before, made that
     containing block exactly one image tall: sticky had no room to move and
     behaved like `relative`, which is why the photo scrolled away instead of
     following the list the way it does on desktop. */
  .svcx__stage { grid-template-columns: 1fr; grid-template-areas: "head" "combo" "cta"; }
  .svcx__card, .svcx__list { grid-area: combo; }
  .svcx__card {
    position: sticky; top: 88px;               /* clears the fixed mobile nav */
    align-self: start; z-index: 1;
    aspect-ratio: unset; height: 34vh; max-width: none; width: 100%;
    /* The list scrolls UNDER this card, and a hard bottom edge cut whichever
       row happened to sit at 88px + 34vh clean in half: top of the glyphs
       hidden, bottom of them visible, which reads as a rendering fault rather
       than as depth. The card now dissolves over its last 12%, so a row
       passing beneath it fades out instead of being sliced. */
    -webkit-mask-image: linear-gradient(to bottom, #000 88%, transparent 100%);
    mask-image: linear-gradient(to bottom, #000 88%, transparent 100%);
  }
  /* Reserves the image's own height at the top of the shared area so the list
     starts right below it at rest, instead of both landing on row one. */
  .svcx__list { padding-top: calc(34vh + var(--s-6)); }
  .svcx__spec { display: none; }
  .svcx__row.is-on .svcx__name { translate: 0 0; }
}

/* ── WORK: head. The jobs themselves live in .deck, below. ──────────── */
/* This header had no rules at all, so the lede sat flush against the heading
   with a 0px gap and ran the full 1296px column while every other lede on the
   page is capped at 48ch. Same rhythm as .finishes__head now: air under the
   heading, a measured lede, and a considered gap before the carousel starts. */
/* Air where the hierarchy needs it, and none where it was accidental.
   16px under a 76px display heading is proportionally nothing, so the lede
   read as stuck to it; 32px lets the two separate. Against that, the section
   was also carrying a full 128px of bottom padding AFTER the 420vh scroll
   track, on top of the next section's own 128px top padding: a quarter of a
   screen of nothing between the last job and the finishes. The track supplies
   all the room anyone needs. */
.work__head { margin-bottom: var(--s-7); }
.work__lede {
  margin-top: var(--s-5);
  max-width: 48ch;
  color: var(--fog-700);
}
.work { padding-block-end: 0; }

/* ── FINISHES ── the interactive one ─────────────────────────────────── */
.finishes { background: var(--bone-100); color: var(--bone-ink); }
.finishes h2, .finishes h3 { color: var(--bone-ink); }
.finishes .eyebrow { color: var(--bone-mute); }
.finishes__head { margin-bottom: var(--s-7); }
.finishes__lede { margin-top: var(--s-4); max-width: 48ch; color: color-mix(in oklch, var(--bone-ink) 78%, transparent); }

.finishes__tabs { display: flex; flex-wrap: wrap; gap: var(--s-2); margin-bottom: var(--s-5); }
.ftab {
  min-height: 44px; padding: 0 var(--s-4);
  background: transparent; color: var(--bone-ink);
  border: 1px solid color-mix(in oklch, var(--bone-ink) 24%, transparent);
  border-radius: var(--r-input);
  font-size: var(--step-sm); font-weight: 600; cursor: pointer;
  transition: background-color var(--dur-hover) var(--ease-out-quart),
              border-color var(--dur-hover) var(--ease-out-quart),
              color var(--dur-hover) var(--ease-out-quart);
}
.ftab:hover { border-color: var(--bone-ink); }
.ftab[aria-selected='true'] {
  background: var(--bone-ink); color: var(--bone-100); border-color: var(--bone-ink);
}

@media (max-width: 760px) {
  /* Four finish names cannot fit one phone line at a legible size. Free
     wrapping left three tabs against one; a scrolling row fixed that but put
     the fourth off the right edge, which reads as the page being cut off
     rather than as something to swipe - a choice you cannot see is a choice
     you do not make. A 2x2 grid shows all four at once, evenly, with no
     scroll. .roll clips to one line, so the cell has to hold its label on one
     line: at 360px the widest ("Exposed aggregate") has room to spare.

     Placed AFTER the base .ftab: a media query adds no specificity, so a base
     rule further down the file still wins on any property they share - the
     padding shorthand below would have eaten padding-inline here. */
  .finishes__tabs {
    display: grid; grid-template-columns: 1fr 1fr;
  }
  .ftab {
    min-width: 0; padding-inline: var(--s-3);
    display: grid; place-items: center; text-align: center;
    white-space: nowrap;
  }
}
.finishes__stage { position: relative; }
.fpanel { display: grid; grid-template-columns: minmax(0, 2fr) minmax(0, 1fr); gap: var(--s-6); align-items: start; }
.fpanel[hidden] { display: none; }
.fpanel img {
  width: 100%; height: auto;
  aspect-ratio: 3 / 2; object-fit: cover;
  border-radius: var(--r-card); background: var(--bone-200);
}
.fmeta { display: grid; gap: var(--s-4); }
.fmeta div { display: grid; gap: 0.25rem; padding-bottom: var(--s-3);
  border-bottom: 1px solid color-mix(in oklch, var(--bone-ink) 16%, transparent); }
.fmeta dt {
  font-family: var(--font-mono); font-size: var(--step-mono);
  letter-spacing: var(--ls-mono); text-transform: uppercase;
  color: var(--bone-mute);   /* measured: the 58% mix was 4.00:1 and failed AA */
}
.fmeta dd { font-size: var(--step-body); font-weight: 600; }
@media (max-width: 760px) { .fpanel { grid-template-columns: 1fr; } }

/* ── TRUST ──────────────────────────────────────────────────────────── */
.trust { background: var(--ink-000); }
.trust__inner {
  display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
  gap: var(--s-8) var(--s-9); align-items: start;
}
.trust h2 { font-size: var(--step-h3); line-height: 1.05; max-width: 20ch; }
.trust__body { margin-top: var(--s-5); max-width: 52ch; }
.trust__quote {
  margin-top: var(--s-6); padding-top: var(--s-5);
  border-top: 1px solid var(--ink-200);
  font-family: var(--font-display); font-variation-settings: 'wdth' 112;
  font-size: var(--step-lead); font-weight: 600; color: var(--fog-900);
  max-width: 26ch;
}
.trust__facts { display: grid; gap: var(--s-5); }
.trust__facts > div { display: grid; gap: 0.3rem; padding-bottom: var(--s-4);
  border-bottom: 1px solid var(--ink-200); }
.trust__facts dt {
  font-family: var(--font-mono); font-size: var(--step-mono);
  letter-spacing: var(--ls-mono); text-transform: uppercase; color: var(--fog-500);
}
.trust__facts dd {
  font-family: var(--font-mono); font-size: var(--step-lead); font-weight: 600;
  color: var(--fog-900); font-variant-numeric: tabular-nums;
}
.trust__facts dd a { color: var(--cure-500); text-decoration: underline; text-underline-offset: 3px; }
.trust__sub { font-size: var(--step-mono) !important; font-weight: 400 !important; color: var(--fog-500) !important; }
@media (max-width: 860px) { .trust__inner { grid-template-columns: 1fr; } }

/* ── SERVICE AREA ───────────────────────────────────────────────────────
 * Two tracks. As one column this ran down the left with two thirds of a
 * near-black screen empty beside it, which reads as a page that failed to
 * load rather than a page with room to breathe.
 */
.area {
  display: grid;
  grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
  column-gap: clamp(2rem, 5vw, 5rem);
  align-items: start;
}
.area h2 { grid-column: 1; grid-row: 1 / span 2; font-size: var(--step-h3); line-height: 1.05; max-width: 18ch; }
.area__list {
  grid-column: 2; grid-row: 1;
  list-style: none; padding: 0;
  display: flex; flex-wrap: wrap; gap: var(--s-3);
  margin-top: 0;
}
.area__list li {
  padding: var(--s-2) var(--s-4);
  border: 1px solid var(--ink-300); border-radius: var(--r-input);
  font-family: var(--font-mono); font-size: var(--step-mono);
  letter-spacing: var(--ls-mono); text-transform: uppercase; color: var(--fog-900);
}
.area__note { grid-column: 2; grid-row: 2; margin-top: var(--s-6); color: var(--fog-500); }

/* ── FAQ ── height 0 to auto is correct here, and only here ─────────── */
.faq {
  background: var(--ink-000);
  display: grid;
  grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
  column-gap: clamp(2rem, 5vw, 5rem);
  align-items: start;
}
.faq h2 { grid-column: 1; font-size: var(--step-h3); line-height: 1.05; max-width: 18ch;
          position: sticky; top: calc(var(--s-8) + 44px); }
.faq__list { grid-column: 2; margin-top: 0; max-width: none; }
.qa { border-bottom: 1px solid var(--ink-200); }
.qa summary {
  display: flex; align-items: center; justify-content: space-between; gap: var(--s-4);
  min-height: 56px; padding: var(--s-3) 0; cursor: pointer;
  font-size: var(--step-lead); font-weight: 600; color: var(--fog-900);
  list-style: none;
}
.qa summary::-webkit-details-marker { display: none; }
.qa summary::after {
  content: ''; flex: none; width: 12px; height: 12px;
  border-right: 1.5px solid var(--fog-500); border-bottom: 1.5px solid var(--fog-500);
  rotate: 45deg; translate: 0 -3px;
  transition: rotate var(--dur-hover) var(--ease-out-quart);
}
.qa[open] summary::after { rotate: -135deg; translate: 0 3px; }
.qa p { padding-bottom: var(--s-5); max-width: 68ch; }

/* the wrapper JS puts the answer in, so one height can be animated */
.qa__body { overflow: hidden; }
/* the question, kept as ONE flex child so space-between still sees two */
.qa__q { min-width: 0; }
/* turn the chevron back at the START of the close, not the end. Needs to
   outrank .qa[open] summary::after above, hence both classes. */
.qa[open].qa--closing summary::after { rotate: 45deg; translate: 0 -3px; }

/* ── QUOTE FORM ─────────────────────────────────────────────────────── */
.quote {
  display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.35fr);
  gap: var(--s-8) var(--s-9); align-items: start;
}
.quote__intro h2 { font-size: var(--step-h2); line-height: var(--lh-heading); }
.quote__intro p { margin-top: var(--s-4); max-width: 40ch; }
.quote__call { font-size: var(--step-lead); color: var(--fog-900); }
.quote__call a { color: var(--cure-500); text-decoration: underline; text-underline-offset: 4px; }

.qform { display: grid; grid-template-columns: 1fr 1fr; gap: var(--s-5) var(--s-4); }
.qform__row { display: grid; gap: var(--s-2); }
.qform__row--wide, .qform__actions { grid-column: 1 / -1; }
.qform label { font-size: var(--step-sm); font-weight: 600; color: var(--fog-900); }
.qform input, .qform select, .qform textarea {
  min-height: 48px; padding: var(--s-3) var(--s-4);
  background: var(--ink-100); color: var(--fog-900);
  border: 1px solid var(--ink-300); border-radius: var(--r-input);
  font-size: var(--step-body);
  transition: border-color var(--dur-hover) var(--ease-out-quart);
}
.qform textarea { min-height: auto; resize: vertical; }
.qform input:hover, .qform select:hover, .qform textarea:hover { border-color: var(--fog-500); }
.qform input:focus-visible, .qform select:focus-visible, .qform textarea:focus-visible { border-color: var(--cure-500); }
.qform input[type='file'] { padding: var(--s-3); color: var(--fog-700); }
.qform__hint { font-size: var(--step-sm); color: var(--fog-550); }
.qform__actions { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-4); margin-top: var(--s-2); }
.qform__note { font-size: var(--step-sm); color: var(--fog-500); }
/* At 1024 a 2-col quote holding a 2-col form is four columns of content in
   one viewport and the inputs overflow by 26px. Collapse the form first, then
   the section. Measured, not guessed. */
@media (max-width: 1180px) { .qform { grid-template-columns: 1fr; } }
@media (max-width: 1024px) { .quote { grid-template-columns: 1fr; } }
/* inputs must never define their own intrinsic width in a grid track */
.qform__row { min-width: 0; }
.qform input, .qform select, .qform textarea { width: 100%; min-width: 0; max-width: 100%; }

/* ── FOOTER ─────────────────────────────────────────────────────────── */
.foot {
  background: var(--ink-000);
  border-top: 1px solid var(--ink-200);
  padding: var(--s-9) var(--gutter) calc(var(--s-9) + env(safe-area-inset-bottom));
  display: grid; grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.5fr) minmax(0, 0.9fr);
  gap: var(--s-8) var(--s-9);
}
.foot__logo { width: 52px; height: auto; display: block; opacity: 0.85; }
.foot__tag { margin-top: var(--s-4); max-width: 26ch; color: var(--fog-500); }
.foot__nav { display: grid; grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); gap: var(--s-6); }
.foot__nav h2 {
  font-family: var(--font-mono); font-size: var(--step-mono);
  letter-spacing: var(--ls-mono); text-transform: uppercase;
  color: var(--fog-500); font-weight: 500;
}
.foot__nav ul { list-style: none; padding: 0; margin-top: var(--s-3); display: grid; gap: var(--s-2); }
/* Nobody clicks a 14px footer link. basement.studio sets footer nav at 38px /
   0.89 leading / -0.04em and it becomes a second navigation rather than
   administrative small print. */
.foot__nav a {
  color: var(--fog-max);
  font-family: var(--font-display);
  font-variation-settings: 'wdth' 112;
  font-weight: 600;
  font-size: clamp(1.3rem, 0.95rem + 1.05vw, 1.85rem);
  line-height: 0.92;
  letter-spacing: -0.035em;
  /* .roll clips to a single line box. A wrapped label would lose its second
     line silently, so labels must never wrap. */
  white-space: nowrap;
  transition: color var(--dur-hover) var(--ease-out-quart);
}
.foot__nav a:hover { color: var(--cure-500); }
.foot__nav ul { gap: var(--s-3); }
.foot__legal {
  grid-column: 1 / -1; display: grid; justify-items: center; gap: var(--s-3);
  text-align: center;
  padding-top: var(--s-6); border-top: 1px solid var(--ink-200);
  font-family: var(--font-mono); font-size: var(--step-mono);
  letter-spacing: var(--ls-mono); color: var(--fog-500);
}
.foot__legal-links {
  list-style: none; padding: 0; margin: 0;
  display: flex; flex-wrap: wrap; justify-content: center; gap: 0 var(--s-5);
  margin-top: calc(var(--s-3) * -1);
}
.foot__legal-links a {
  /* the type stays small print; the hit area does not */
  display: inline-flex; align-items: center; min-height: 44px; padding-inline: 2px;
  color: var(--fog-500); text-decoration: none;
  border-bottom: 1px solid transparent;
  transition: color var(--dur-hover) var(--ease-out-quart),
              border-color var(--dur-hover) var(--ease-out-quart);
}
.foot__legal-links a:hover { color: var(--cure-500); border-bottom-color: currentColor; }

/* Brand marks, not text links. 44px hit area with the icon optically centred;
   the negative inline start pulls the first icon's box back to the logo edge. */
.foot__social {
  list-style: none; padding: 0; margin: var(--s-5) 0 0 -10px;
  display: flex; gap: var(--s-2);
}
.foot__social a {
  display: grid; place-items: center; width: 44px; height: 44px;
  border-radius: var(--r-input); color: var(--fog-500);
  transition: color var(--dur-hover) var(--ease-out-quart),
              background-color var(--dur-hover) var(--ease-out-quart);
}
.foot__social a:hover { color: var(--cure-500); background-color: var(--cure-dim); }
.foot__social svg { width: 21px; height: 21px; display: block; }
.foot__nav--contact { grid-template-columns: minmax(0, 1fr); }
@media (max-width: 1100px) {
  .foot { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
  .foot__nav--contact { grid-column: 2; }
}
@media (max-width: 860px) { .foot { grid-template-columns: 1fr; } .foot__nav--contact { grid-column: auto; } }

/* ── legal pages (privacy, terms) ───────────────────────────────────
   Same ground and type as the site. A reading column, not a section stack. */
.legal { padding: var(--s-9) var(--gutter) var(--s-10); background: var(--ink-000); }
.legal__inner { max-width: 68ch; margin: 0 auto; }
.legal__back {
  display: inline-flex; align-items: center; gap: var(--s-2);
  font-family: var(--font-mono); font-size: var(--step-mono);
  letter-spacing: var(--ls-mono); text-transform: uppercase;
  color: var(--fog-500); text-decoration: none;
  transition: color var(--dur-hover) var(--ease-out-quart);
}
.legal__back:hover { color: var(--cure-500); }
.legal h1 {
  margin-top: var(--s-6);
  font-size: clamp(2.1rem, 1.4rem + 2.6vw, 3.6rem);
  line-height: 0.98; letter-spacing: var(--ls-display); color: var(--fog-max);
}
.legal__meta {
  margin-top: var(--s-4);
  font-family: var(--font-mono); font-size: var(--step-mono);
  letter-spacing: var(--ls-mono); color: var(--fog-500);
}
.legal h2 {
  margin-top: var(--s-8); margin-bottom: var(--s-3);
  font-size: var(--step-h3); letter-spacing: -0.02em; color: var(--fog-max);
}
.legal p, .legal li { color: var(--fog-700); max-width: 64ch; }
.legal p { margin-top: var(--s-3); }
.legal ul { margin-top: var(--s-3); padding-left: 1.1rem; display: grid; gap: var(--s-2); }
.legal a:not(.legal__back) {
  color: var(--fog-max); text-decoration: underline;
  text-decoration-color: var(--cure-500); text-underline-offset: 3px;
  transition: color var(--dur-hover) var(--ease-out-quart);
}
.legal a:not(.legal__back):hover { color: var(--cure-500); }

/* ── sticky mobile call bar ─────────────────────────────────────────── */
/* The number rides in the header on a phone. It used to be a bar pinned to
   the bottom of the viewport, which sat on top of whatever the reader was
   actually looking at and put the primary action as far from the thumb's
   reading position as the screen allows. */
.callbar { display: none; }
@media (max-width: 760px) {
  /* in the header flow, not floating over it, so it can never land on the
     Menu button at a width nobody tested */
  /* the same action and the same words as the desktop nav CTA, centred in
     the bar between the mark and the menu button */
  .callbar {
    display: inline-flex; align-items: center; justify-content: center;
    justify-self: center;
    min-height: 44px; padding-inline: var(--s-4);   /* touch minimum; measured 40px shipped */
    border-radius: var(--r-pill);
    background: var(--cure-500); color: var(--cure-ink);
    font-size: var(--step-sm); font-weight: 600; white-space: nowrap;
  }
  .callbar .roll { --roll-h: 1.2em; }
}
@media (max-width: 400px) {
  .callbar { padding-inline: var(--s-3); font-size: 0.82rem; }
}

}

@layer components {
  /* the extruded mark. The SVG under it is the truth; the canvas is a bonus
     that only appears on capable, non-reduced-motion, non-mobile clients. */
  .trust__col { display: grid; gap: var(--s-6); }
  .trust__mark { position: relative; aspect-ratio: 1; width: min(17rem, 60%);
    margin: 0 0 var(--s-2) auto; }
  @media (max-width: 860px) { .trust__mark { margin-inline: 0; width: 11rem; } }
  .trust__mark canvas { position: absolute; inset: 0; width: 100%; height: 100%; opacity: 0;
    transition: opacity 1.2s var(--ease-out-quart); }
  .trust__mark[data-ready='true'] canvas { opacity: 1; }
  .trust__mark[data-ready='true'] .trust__mark-fallback { opacity: 0; }
  .trust__mark-fallback { width: 100%; height: 100%; color: var(--ink-300);
    transition: opacity 1.2s var(--ease-out-quart); }
  @media (prefers-reduced-motion: reduce) {
    .trust__mark canvas, .trust__mark-fallback { transition-duration: 1ms; }
  }
}

@layer components {
  /* Constrain display headings in their OWN em. A `ch` cap on the parent is
     measured at body size and clips the display face, the same bug that put
     "Brir / visi / to / life" in the first hero. */
  /* `.services__head` was in this list and matches nothing: the section's
     head is `.svcx__head`. */
  /* The top tier of section heading, and the ONLY place its size is set.
     The size used to be declared separately on .finishes__head h2 and on
     .svcx__head h2, and .work__head h2 was in this list for its measure but
     never got the size from anywhere: it rendered at the bare h2 default,
     28px against its siblings' 76px, so "Poured, finished, walked away from."
     did not read as a section heading at all. Second tier (.trust, .area,
     .faq at 42px) is deliberately smaller and is set below. */
  .svcx__head h2, .work__head h2, .finishes__head h2, .quote__intro h2 {
    font-size: var(--step-h2);
    line-height: var(--lh-heading);
    max-width: 13em;
  }
  .trust h2, .area h2, .faq h2 { max-width: 15em; }
}

@layer components {
  /* Form states. Never colour alone: every error carries text, an icon-free
     message tied by aria-describedby, and a border change. */
  .qform__summary {
    grid-column: 1 / -1;
    padding: var(--s-4) var(--s-5);
    background: color-mix(in oklch, oklch(0.55 0.20 25) 12%, transparent);
    border: 1px solid color-mix(in oklch, oklch(0.65 0.20 25) 55%, transparent);
    border-radius: var(--r-card);
  }
  .qform__summary[hidden] { display: none; }
  .qform__summary h3 { font-size: var(--step-lead); color: var(--fog-900); }
  .qform__summary p { margin-top: var(--s-2); font-size: var(--step-sm); }
  .qform__summary ul { margin: var(--s-3) 0 0; padding-left: 1.1rem; display: grid; gap: var(--s-2); }
  .qform__summary a { color: var(--fog-900); text-decoration: underline; text-underline-offset: 3px; font-size: var(--step-sm); }
  .qform__summary:focus-visible { outline: 2px solid var(--cure-500); outline-offset: 3px; }

  .qform__err {
    font-size: var(--step-sm);
    color: oklch(0.80 0.13 25);
  }
  .qform__err[hidden] { display: none; }
  .qform .is-invalid { border-color: oklch(0.65 0.18 25); }

  .qform__status { grid-column: 1 / -1; font-size: var(--step-sm); color: var(--fog-550); }
  .qform__status:empty { display: none; }
  .qform__status[data-state='pending'] {
    padding: var(--s-4) var(--s-5);
    background: var(--cure-dim);
    border: 1px solid color-mix(in oklch, var(--cure-500) 40%, transparent);
    border-radius: var(--r-card);
    color: var(--fog-900);
  }
}

@layer components {
/* ══ THE LOADER ═══════════════════════════════════════════════════════
   The mark draws itself, fills, then becomes a hole you are pulled through. */
.loader {
  position: fixed; inset: 0; z-index: 9999;
  display: grid; place-items: center;
  background: transparent;
  pointer-events: none;
}
.loader__veil {
  position: absolute; inset: 0;
  z-index: -1;
  background: var(--ink-000);      /* until the masked SVG replaces it */
}
.loader__veil svg { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
/* once the hole exists the flat background must go, or it covers the hole */
.loader__veil.is-cut { background: transparent; }
.loader__mark { width: min(26vw, 190px); color: var(--fog-max); will-change: transform, opacity; }
.loader__mark svg { width: 100%; height: auto; overflow: visible; }
.loader__meta {
  position: absolute; left: 0; right: 0; bottom: 8vh;
  display: flex; justify-content: space-between;
  padding-inline: var(--gutter);
  font-family: var(--font-mono); font-size: var(--step-mono);
  letter-spacing: var(--ls-mono); color: var(--fog-500);
}
.loader__pct { font-variant-numeric: tabular-nums; color: var(--cure-500); }
html.is-loading, html.is-loading body { overflow: hidden; }
/* The mark is punched out of the veil as a real hole, so whatever sits behind
   it is visible. Returning from a legal page restores the previous scroll
   position, which put mid-page sections under that hole. Hide the document
   itself while the loader runs and the hole shows only the ground.
   `is-loading` is set ONLY by a loader that actually runs: the reduced-motion
   and no-GSAP paths return before adding it, and with JS off it is never
   added, so this can never strand the page hidden. visibility, not opacity,
   because initTextFade owns inline opacity on the faded blocks. */
html.is-loading #nav,
html.is-loading #main,
html.is-loading .foot,
html.is-loading .callbar { visibility: hidden; }
.loader[hidden] { display: none; }
/* The veil is opaque and covers everything. It is removed by loader.js, so
   with scripting off nothing ever removes it and the site is a black screen.
   The document is complete without any of this; hide it outright. */
.no-js .loader { display: none; }

}

@layer components {
  /* A scaled child pushes document width unless its box clips. This cost 11px
     of horizontal overflow at 360 and 12px at 390. */
  .svcx__card, .deck__fig, .hero__media { overflow: clip; }
  .svcx__img, .deck__fig img { max-width: 100%; }

  /* A scaled element reports its VISUAL bounds, and a sticky ancestor does not
     reliably clip them, so scrollWidth still grew. Do not upscale at all:
     animate opacity and a translate that stays inside the box. */
  .svcx__img { scale: 1 !important; }
  .svcx__img:not(.is-on) { translate: 0 10px; }
  .svcx__img.is-on { translate: 0 0; }

  @media (max-width: 1024px) {
    .deck__fig img { transform: none !important; }
  }

  /* last-resort guard: nothing may make the document wider than the viewport */
  html, body { max-width: 100%; overflow-x: clip; }
}


@layer components {
  /* Each section owns an opaque ground. Cheap insurance against exactly the
     bleed-through that the sticky hero caused.
     override: these deliberately re-declare backgrounds set earlier. */
  .claim, .descent, .svcx, .work, .trust, .area, .faq, .quote, .foot { background-color: var(--ink-050); }
  .claim, .descent, .svcx, .trust, .faq, .foot { background-color: var(--ink-000); }
  .finishes { background-color: var(--bone-100); }
}

@layer components {
/* ── THE TRANSFORMATION ─────────────────────────────────────────────────
 * A sticky track. The section holds still while the scroll drives the
 * video's currentTime, so the patio genuinely builds itself under the
 * reader's thumb. One animated property, which is what the award builds do.
 */
.tf-track { position: relative; height: 240vh; background: var(--ink-000); }
.tf {
  position: sticky; top: 0;
  min-height: 100svh;
  display: grid;
  grid-template-columns: minmax(0, 1.85fr) minmax(0, 1fr);
  gap: clamp(1.5rem, 3.5vw, 4rem);
  align-items: center;
  padding: var(--s-8) var(--gutter);
  background: var(--ink-000);
}
.tf__media {
  position: relative; margin: 0;
  border-radius: var(--r-card); overflow: clip;
  background: var(--ink-100);
  /* the stage takes the screen rather than floating in a black field */
  block-size: min(78svh, 700px);
}
/* Three registered photographs stacked in one frame. With JavaScript off the
   finished slab is what you see, which is the honest still of this job. */
.tf__stage {
  position: absolute; inset: 0;
  inline-size: 100%; block-size: 100%; object-fit: cover;
  opacity: 0;
}
.tf__stage[data-stage="2"] { opacity: 1; }
.tf__video {
  position: absolute; inset: 0;
  inline-size: 100%; block-size: 100%; object-fit: cover;
  opacity: 0; transition: opacity var(--dur-base) var(--ease-out-quart);
}
/* once the canvas is driving, the stacked photographs step aside */
.tf-track.is-live .tf__video { opacity: 1; }
.tf-track.is-live .tf__stage { opacity: 0; }
.tf__scrim {
  position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(120% 80% at 50% 118%,
    oklch(0.10 0.012 250 / 0.72) 0%, transparent 62%);
}
/* the tick rail sits on the footage, so it carries its own ground */
.tf__ticks {
  position: absolute; left: 0; right: 0; bottom: 0; margin: 0;
  background: linear-gradient(to top,
    oklch(0.09 0.012 250 / 0.94) 0%,
    oklch(0.09 0.012 250 / 0.72) 55%,
    transparent 100%);
  display: grid; grid-template-columns: repeat(3, 1fr);
  list-style: none; padding: var(--s-4) var(--s-5) calc(var(--s-4) + 6px);
  gap: var(--s-3);
}
.tf__ticks li {
  display: grid; gap: 0.15rem;
  border-top: 1px solid color-mix(in oklch, var(--fog-max) 22%, transparent);
  padding-top: var(--s-2);
  opacity: 0.42;
  transition: opacity var(--dur-fast) var(--ease-out-quart),
              border-color var(--dur-fast) var(--ease-out-quart);
}
.tf__ticks li.is-on { opacity: 1; border-top-color: var(--cure-500); }
.tf__ticks b { font-family: var(--font-mono); font-size: var(--step-mono); letter-spacing: var(--ls-mono); color: var(--cure-500); font-weight: 600; }
.tf__ticks span { font-family: var(--font-mono); font-size: var(--step-mono); letter-spacing: var(--ls-mono); color: var(--fog-max); }
/* the scrub read-out: one hairline that fills as the slab lands */
.tf__rail {
  position: absolute; left: 0; right: 0; bottom: 0; height: 3px;
  background: color-mix(in oklch, var(--fog-max) 16%, transparent);
}
.tf__rail i {
  display: block; height: 100%; width: 100%;
  background: var(--cure-500);
  transform: scaleX(var(--p, 0)); transform-origin: 0 50%;
}
.tf__copy h2 { font-size: var(--step-h2); line-height: var(--lh-heading); }
.tf__copy p { margin-top: var(--s-5); max-width: 42ch; }
.tf__note {
  font-family: var(--font-mono); font-size: var(--step-mono);
  letter-spacing: var(--ls-mono); line-height: 1.6; color: var(--fog-500);
  padding: var(--s-3) var(--s-4); border: 1px solid var(--ink-200);
  border-radius: var(--r-input);
}
.tf__copy .btn { margin-top: var(--s-6); }
@media (max-width: 900px) {
  .tf { grid-template-columns: 1fr; align-content: center; }
  .tf__media { block-size: min(46svh, 420px); }
  .tf-track { height: 220vh; }
}
@media (prefers-reduced-motion: reduce) {
  .tf-track { height: auto; }
  .tf { position: static; min-height: 0; }
  .tf__ticks li { opacity: 1; }
  /* no wipe: the finished slab is the content */
  .tf-track.is-live .tf__video { opacity: 0; }
  .tf-track.is-live .tf__stage[data-stage="2"] { opacity: 1; }
}
}

@layer components {
/* ── THE DECK ───────────────────────────────────────────────────────────
 * A carousel built on native scroll-snap, so touch, trackpad, keyboard and
 * a no-JavaScript page all work before a line of script runs. The pointer
 * drag, the counter and the rail are the enhancement on top.
 * The slide is sized so the NEXT job is always visible at the frame edge:
 * that parked chip is the single best idea on metriccivil.ca, and here it
 * is a real job rather than a decorative thumbnail.
 */
/* The section pins and vertical scroll drives the jobs sideways, which is
   the move the client asked for: keep scrolling, the next job arrives and
   the last one leaves. The track underneath is still a real scroll
   container, so drag, snap, arrow keys and a no-JavaScript page all keep
   working; the pin only drives its scrollLeft. */
/* Six jobs need room to be looked at. At 260vh each job got about four
   tenths of a screen of scroll, which is faster than anyone can read a
   caption. This gives each one roughly half a screen plus the pin. */
.deck-track { position: relative; height: 420vh; }
.deck { position: sticky; top: 0; min-block-size: 100svh; display: grid; align-content: center; }
.deck.is-driven .deck__track { scroll-snap-type: none; }

.deck__bar {
  display: flex; align-items: center; gap: var(--s-5);
  padding: 0 var(--gutter) var(--s-4);
}
.deck__count {
  font-family: var(--font-mono); font-size: var(--step-mono);
  letter-spacing: var(--ls-mono); color: var(--fog-500);
}
.deck__count b { color: var(--cure-500); font-weight: 600; font-variant-numeric: tabular-nums; }
.deck__hint {
  font-family: var(--font-mono); font-size: var(--step-mono);
  /* fog-550, not fog-500: this line carries text over a photo, and 500 is the
     decorative step. Measured 4.5:1 on 500 - exactly on the AA body line and
     drifting under it as the slide behind changes. */
  letter-spacing: var(--ls-mono); color: var(--fog-550);
  margin-inline-start: auto;
}
.deck__nav { display: flex; gap: var(--s-2); }
.deck__btn {
  inline-size: 44px; block-size: 44px;
  display: grid; place-items: center;
  border: 1px solid var(--ink-200); border-radius: 50%;
  background: transparent; color: var(--fog-max); cursor: pointer;
  transition: border-color var(--dur-fast) var(--ease-out-quart),
              background-color var(--dur-fast) var(--ease-out-quart),
              color var(--dur-fast) var(--ease-out-quart);
}
.deck__btn:hover:not(:disabled) { border-color: var(--cure-500); color: var(--cure-500); }
.deck__btn:disabled { opacity: 0.3; cursor: default; }

.deck__track {
  display: flex; gap: var(--s-4);
  list-style: none; margin: 0;
  padding-inline: var(--gutter);
  overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  /* An overflow-x scroller clips vertically too, which would slice the halo
     off flat above and below each job. Padding lives INSIDE the scrollport,
     so this buys the light room to spill; the negative margin hands the
     surrounding layout back exactly as it was. */
  padding-block: 58px calc(var(--s-5) + 58px);
  margin-block: -58px;
}
.deck__track::-webkit-scrollbar { display: none; }
.deck__track.is-dragging { scroll-snap-type: none; cursor: grabbing; }
.deck__track.is-dragging .deck__fig { pointer-events: none; }

.deck__slide {
  flex: 0 0 clamp(260px, 46vw, 720px);
  scroll-snap-align: center;
}
.deck__fig {
  position: relative; margin: 0;
  border-radius: var(--r-card); overflow: clip;
  background: var(--ink-100);
  /* one fixed stage height for every slide, so the deck reads as a machine
     and not a ragged masonry wall. The crops differ; the frame does not. */
  block-size: min(62svh, 620px);
}
.deck__fig img {
  display: block; inline-size: 100%; block-size: 100%;
  object-fit: cover;
  transform: scale(1.03);
  /* transform only. This used to transition `filter: brightness(0.9)
     saturate(0.94)` alongside it, which repaints a 620px image every frame of
     a 1.2s transition, six of them, inside a scroller the pin is driving.
     Measured p95 33.3ms -> 17.3ms across the deck with the filter gone, and
     22 of 55 frames over budget down to 7. */
  transition: transform 1.2s var(--ease-out-quart);
}
/* the active slide comes forward; the parked neighbours sit back.
   The dim is a composited layer now, not a filter: opacity is one of the two
   properties the compositor animates without touching paint. */
.deck__fig::after {
  content: ''; position: absolute; inset: 0; z-index: 1;
  pointer-events: none;
  background: oklch(0.09 0.012 250);
  opacity: 0.12;
  transition: opacity var(--dur-base) var(--ease-out-quart);
}
.deck__slide.is-on .deck__fig img { transform: scale(1); }
.deck__slide.is-on .deck__fig::after { opacity: 0; }

/* ── light behind the photograph ──────────────────────────────────────
 * The six jobs sat completely inert until clicked, which gave a reader no
 * reason to believe the frame did anything.
 *
 * The light source sits BEHIND the picture, not on it. It is a soft radial
 * centred on the slide, and the photograph is opaque, so the middle of it is
 * occluded and all you ever see is what spills out around the four edges.
 * That is the difference between a wash over the image (which just dirties
 * the photograph) and something trapped back there pushing to get out.
 *
 * Opacity ONLY. The gradient is painted once into its own layer and every
 * state after that is composited, which is why it can breathe continuously
 * without costing a frame. No blur, no filter. Pointer devices only, or a
 * phone tap leaves the light stuck on.
 */
@media (hover: hover) {
  .deck__slide { position: relative; }
  .deck__slide::before {
    /* Exactly the footprint of the picture, sitting directly behind it, so
       the panel itself never shows: what you see is the two shadows thrown
       off its edges. A radial centred on the slide was the first attempt and
       it reads as nothing, because its bright core is the part the photograph
       is covering and only the last, faintest few percent of the falloff ever
       clears the frame. Light has to be brightest AT the seam. */
    content: ''; position: absolute; inset: 0; z-index: 0;
    pointer-events: none;
    border-radius: var(--r-card);
    background: oklch(0.86 0.18 158);
    /* Restrained on purpose. At 0.55/0.30 it read as a neon sign: it washed
       the neighbouring job and sat on the rail underneath. This is enough to
       say the frame is holding something and to ask for the click, and no
       more than that. */
    box-shadow:
      0 0 44px 2px oklch(0.87 0.19 158 / 0.26),   /* the spill at the seam */
      0 0 88px 0   oklch(0.84 0.16 158 / 0.12);   /* the room it throws light into */
    opacity: 0;
    transition: opacity 460ms var(--ease-out-quart),
                transform 520ms var(--ease-out-quart);
  }
  /* the picture is the thing doing the trapping, so it sits on top of it */
  .deck__fig {
    z-index: 1;
    transition: transform 520ms var(--ease-out-quart);
  }
  /* Steady, not breathing. It used to pulse on a 2.6s alternate, and because
     the transition arrives at full strength while the animation's own first
     keyframe is half that, the light dropped into the dark the moment it
     finished coming up: hover, bright, then visibly dimmer. A light source
     behind a picture does not do that. It comes up once and it stays. */
  .deck__slide:hover::before,
  .deck__slide:focus-within::before { opacity: 1; }

  /* The job lifts off the row toward the reader, and the light lifts with it
     so the two never separate. Translate only, no scale: the track is a real
     horizontal scroll container and the pin reads its scrollWidth every
     frame, so widening a slide by even a percent makes the carousel nudge
     under its own hover. A lift says "press me" without touching that. */
  .deck__slide:hover .deck__fig,
  .deck__slide:focus-within .deck__fig,
  .deck__slide:hover::before,
  .deck__slide:focus-within::before {
    transform: translate3d(0, -8px, 0);
  }
}
@media (prefers-reduced-motion: reduce) {
  /* the light still answers the pointer, it just arrives at once and does
     not move */
  .deck__slide::before, .deck__fig { transition-duration: 1ms; }
  .deck__slide:hover .deck__fig,
  .deck__slide:focus-within .deck__fig,
  .deck__slide:hover::before,
  .deck__slide:focus-within::before { transform: none; }
}

.deck__meta {
  position: absolute; inset-inline: 0; inset-block-end: 0;
  z-index: 3;                      /* above the dim and the light */
  padding: var(--s-6) var(--s-5) var(--s-5);
  background: linear-gradient(to top,
    oklch(0.09 0.012 250 / 0.94) 0%,
    oklch(0.09 0.012 250 / 0.74) 48%,
    transparent 100%);
}
.deck__idx {
  font-family: var(--font-mono); font-size: var(--step-mono);
  letter-spacing: var(--ls-mono); color: var(--cure-500);
  font-variant-numeric: tabular-nums;
}
.deck__meta h3 {
  margin-block-start: var(--s-2);
  font-size: var(--step-h3); line-height: var(--lh-heading);
  color: var(--fog-max);
}
.deck__meta dl {
  margin-block-start: var(--s-4);
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--s-3);
}
.deck__meta dt {
  font-family: var(--font-mono); font-size: var(--step-mono);
  letter-spacing: var(--ls-mono); color: var(--fog-500);
}
.deck__meta dd { margin: 0; color: var(--fog-max); }

.deck__rail {
  block-size: 2px; margin: 0 var(--gutter);
  background: color-mix(in oklch, var(--fog-max) 14%, transparent);
}
.deck__rail i {
  display: block; block-size: 100%; inline-size: 100%;
  background: var(--cure-500);
  transform: scaleX(var(--p, 0.16)); transform-origin: 0 50%;
  transition: transform var(--dur-fast) var(--ease-out-quart);
}

@media (max-width: 640px) {
  .deck-track { height: 330vh; }
  .deck__slide { flex-basis: 82vw; }
  .deck__fig { block-size: min(58svh, 440px); }
  .deck__hint { display: none; }
  .deck__meta dl { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
  .deck-track { height: auto; }
  .deck { position: static; min-block-size: 0; }
  .deck__fig img { transition: none; transform: none; filter: none; }
  .deck__rail i { transition: none; }
}
}

@layer components {
  /* one column again once there is no room for two */
  @media (max-width: 860px) {
    .area, .faq { grid-template-columns: 1fr; }
    .area h2 { grid-row: auto; max-width: 22ch; }
    .area__list, .area__note, .faq__list { grid-column: 1; }
    .area__list { margin-top: var(--s-6); }
    .faq h2 { position: static; max-width: 22ch; }
    .faq__list { margin-top: var(--s-7); }
  }
}

@layer components {
/* ── THE DECK: pick a job to sit behind the section ─────────────────────
 * Clicking a slide lifts that photograph out and holds it behind the whole
 * section, dimmed and blurred so the deck stays readable on top of it. It
 * is a toggle: clicking the picked job again puts it back.
 */
/* NO position rule here. .deck is already `position: sticky` above, and a
   sticky element is its own containing block, so .deck__bg positions against
   it for free. Re-declaring `position: relative` here silently overrode the
   sticky and the whole pinned horizontal scroll stopped holding. */
.deck__bg {
  position: absolute;
  inset-block: 0;
  /* full bleed: the picked job runs to the viewport edges, not the gutter */
  inset-inline: calc(50% - 50vw);
  z-index: 0; pointer-events: none;
  overflow: clip;
  opacity: 0;
  transition: opacity 900ms var(--ease-out-quart);
}
.deck__bg img {
  inline-size: 100%; block-size: 100%; object-fit: cover;
  /* far enough back that white type survives on top of any of the six.
     NO blur(). It is a convolution over a full-bleed image and it cost a
     383ms frame the first time each job was picked, against 50ms without;
     the softness now comes from deliberately requesting a small source and
     letting it upscale, which is free. At 30% brightness behind the content
     the difference is not visible anyway. */
  filter: brightness(0.30) saturate(0.55);
  transform: scale(1.08);
  transition: transform 1600ms var(--ease-out-quart);
}
.deck.has-bg .deck__bg { opacity: 1; }
.deck.has-bg .deck__bg img { transform: scale(1.02); }
/* everything else rides above it */
.deck__bar, .deck__track, .deck__rail { position: relative; z-index: 1; }
/* The bar and the rail sit ABOVE the track, not level with it. The track
   carries 58px of block padding so the hover light has somewhere to spill
   before an overflow-x scroller clips it, and that padding extends its box 48px
   up over the previous/next buttons. At equal z-index the track paints later,
   so it was swallowing every click on them: elementFromPoint at the Next
   button's own centre returned .deck__track and the arrows did nothing. */
.deck__bar, .deck__rail { z-index: 2; }

.deck__pick {
  position: absolute; inset: 0; z-index: 4;   /* above the dim and the light */
  inline-size: 100%; block-size: 100%;
  border: 0; background: transparent; padding: 0;
  cursor: pointer;
  display: grid; align-items: start; justify-items: end;
}
/* No visible chip. The photograph is the affordance: the cursor turns to a
   pointer, and the picked job is marked by its outline. A label sitting on
   the corner of every frame is furniture. */
.deck__pick:focus-visible { outline: 2px solid var(--cure-500); outline-offset: -4px; }
.deck__slide.is-picked .deck__fig { outline: 2px solid var(--cure-500); outline-offset: 3px; }

@media (prefers-reduced-motion: reduce) {
  .deck__bg, .deck__bg img { transition: none; }
  .deck__bg img { transform: none; }
}
}
