/* Component styles. One .c-<component> block per component, added by its task. */

/* Nav */
.c-nav {
  position: fixed; inset: 0 0 auto 0; z-index: 50;
  display: flex; justify-content: space-between; align-items: center;
  padding: var(--space-s) var(--space-m);
}

/* Theme comes from src/scripts/nav-theme.ts, which watches [data-band]
   sections with an IntersectionObserver and keeps data-nav-theme on this
   element in sync with whatever band is actually behind the bar. The
   markup defaults to dark (Nav.astro) so the bar reads correctly over the
   hero before that script runs, or if it never runs at all.

   This replaces an earlier mix-blend-mode: difference approach. Blending
   against a photograph inverts against whatever pixels happen to sit
   underneath, so its contrast is a property of the image crop rather than
   of the design; measured as low as 1.34:1 at some viewport widths, where
   the hero photo's object-fit: cover crop put a bright, textured window
   under the mark. Deliberate, token-driven colour per theme is reliable at
   every width instead, independent of what the photo happens to be doing. */
.c-nav__mark, .c-nav__link { text-decoration: none; transition: color var(--dur-fast) var(--ease-out); }
.c-nav[data-nav-theme='dark'] .c-nav__mark,
.c-nav[data-nav-theme='dark'] .c-nav__link,
.c-nav[data-nav-theme='dark'] .c-nav__toggle { color: var(--bone); }
.c-nav[data-nav-theme='light'] .c-nav__mark,
.c-nav[data-nav-theme='light'] .c-nav__link,
.c-nav[data-nav-theme='light'] .c-nav__toggle { color: var(--ink); }

/* A scrim under the bar in both themes, so legibility never depends on
   which part of whatever is behind it happens to be there, the exact
   failure mode the blend had. Originally this was dark-theme only, on the
   assumption that light bands are plain enough not to need one; measuring
   actual pixel contrast (see task-13-report.md) disproved that for two
   light bands that are not plain at all: Selected work and the gallery
   strip both carry full-bleed project photography that scrolls directly
   under the bar, the same way the hero does, and one such photo measured
   as low as 4.23:1 there. The two scrims are colour-inverted mirrors of
   each other, not a special case for the hero.

   The gradient holds its peak strength for the first ~45% of its height,
   covering the bar's own content, and only fades out below that, rather
   than fading from the very top edge: a fade starting at y=0 was already
   measurably weaker by the time it reached the text (peak 0.45 measured
   down to an effective ~0.30 at the mark's own position), which is what
   let the hero measurements fail at some widths in the first place.
   Negative z-index keeps each scrim behind the mark/links/cta within
   .c-nav's own stacking context (it is position:fixed, so it already
   establishes one) without needing z-index on everything else;
   pointer-events:none keeps it from intercepting clicks in the gaps
   between nav items. */
.c-nav[data-nav-theme='dark']::before {
  content: ''; position: absolute; inset: 0 0 auto 0; height: 140px;
  background: linear-gradient(
    to bottom, rgb(0 0 0 / 0.65) 0%, rgb(0 0 0 / 0.65) 45%, transparent 100%
  );
  z-index: -1; pointer-events: none;
}
.c-nav[data-nav-theme='light']::before {
  content: ''; position: absolute; inset: 0 0 auto 0; height: 140px;
  background: linear-gradient(
    to bottom, rgb(255 255 255 / 0.65) 0%, rgb(255 255 255 / 0.65) 45%, transparent 100%
  );
  z-index: -1; pointer-events: none;
}

/* nowrap so "The818" never breaks into two lines and collides with the
   links beside it; the mark is two short words, so this never causes its
   own overflow. */
/* Same size as the footer's wordmark (--step-1), so the mark reads the
   same at both ends of the page. This sets the bar's height, which the
   fragment-scroll offset depends on; anchor-scroll.ts measures it at
   runtime and --nav-h in tokens.css carries the no-JavaScript default. */
.c-nav__mark {
  font-size: var(--step-1); font-weight: 800; letter-spacing: -0.02em;
  white-space: nowrap;
  display: inline-flex; align-items: center; gap: 0.4em;
}

/* The mark (Logo.astro). Sized in em so it tracks whatever wordmark it
   sits beside, and stroked in --ditra in both nav themes: it is
   decorative, the wordmark next to it carries the name, so it is free to
   stay the brand colour over a light band as well as a dark one. Stroke
   weight is a touch heavier than the service icons' 1.25, because the mark
   renders at roughly half their size. Round caps and joins, unlike the
   service icons, which soften the roof's peak; at 1.2em it sits a little
   taller than the wordmark's capitals, so the house reads as its own shape
   rather than as one more letter. */
.c-logo {
  width: 1.2em; height: 1.2em; flex: none;
  fill: none; stroke: var(--ditra); stroke-width: 1.5;
  stroke-linecap: round; stroke-linejoin: round;
}
.c-nav__links { display: flex; gap: var(--space-m); align-items: center; }
.c-nav__link, .c-nav__cta {
  font-family: var(--font-mono); font-size: 0.82rem;
  letter-spacing: 0.14em; text-transform: uppercase; text-decoration: none;
  position: relative;
}
.c-nav__link::after {
  content: ''; position: absolute; left: 0; right: 0; bottom: -4px; height: 1px;
  background: currentColor; transform: scaleX(0); transform-origin: left;
  transition: transform var(--dur-fast) var(--ease-out);
}
.c-nav__link:hover::after, .c-nav__link:focus-visible::after { transform: scaleX(1); }

/* A solid chip, outside the theme switching above: 15.85:1 against --bone
   at all times, so it reads the same regardless of what band or photo sits
   behind the bar. */
.c-nav__cta {
  background: var(--ink); color: var(--bone);
  padding: var(--space-2xs) var(--space-s);
  transition: background-color var(--dur-fast) var(--ease-out);
}
.c-nav__cta:hover, .c-nav__cta:focus-visible { background: var(--ditra-deep); }

/* Below this width the wordmark, three section links and CTA no longer fit
   on one row without crowding or wrapping. The three links are same-page
   anchors on a single-page site, a convenience rather than navigation, so
   they are dropped here rather than folded into a hamburger menu (which
   would need its own focus trap and Escape handling to be worth building)
   and added to the footer instead (see .c-footer__links), which has the
   room. The wordmark and CTA stay, so the header keeps working as identity
   plus the one action that matters. */
@media (max-width: 40rem) {
  .c-nav__link { display: none; }
}

/* The full-screen menu button (see .c-menu below for the dialog it opens).
   Ships `hidden` in the markup (Nav.astro) so a visitor without JavaScript
   never sees a dead control; src/scripts/mobile-menu.ts removes the
   attribute once it has confirmed the dialog is wired up.

   The rule that actually shows it is scoped to :not([hidden]), the same
   discipline .c-lightbox[open] below applies to display: author CSS always
   wins over the UA stylesheet's own `[hidden] { display: none; }`
   regardless of specificity, so an unconditional `display: inline-flex`
   here would render the button before any script has run, defeating the
   whole no-JS fallback the same way an unscoped display rule once left a
   closed lightbox dialog rendering (see that comment for the fuller
   story). Kept a plain, unbroken `display: none` outside the media query
   too, so nothing shows above the breakpoint even once JS has revealed it. */
.c-nav__toggle {
  display: none;
  border: 0; background: none; padding: var(--space-2xs); cursor: pointer;
  color: inherit; line-height: 0;
}
@media (max-width: 40rem) {
  .c-nav__toggle:not([hidden]) { display: inline-flex; align-items: center; justify-content: center; }
}
.c-nav__toggle-icon { width: 1.6rem; height: 1.6rem; }
.c-nav__toggle-icon path {
  fill: none; stroke: currentColor; stroke-width: 1.75;
  stroke-linecap: square; stroke-linejoin: miter;
}

/* Mobile menu */
/* Same two bugs the lightbox above already forced fixes for (see its own,
   fuller comments), repeated here rather than risked a second time:
   (1) the project's `* { margin: 0; }` reset (global.css) zeroes the UA
   stylesheet's `dialog:modal { margin: auto; }`, the thing that would
   otherwise centre a dialog shrunk to its content size, collapsing it to a
   content-sized box pinned at the top-left corner. The dialog is sized to
   the viewport outright instead, so nothing depends on that centring
   mechanism at all.
   (2) `display` lives only on `.c-menu[open]`, never on the bare `.c-menu`
   selector: author CSS beats the UA stylesheet's own
   `dialog:not([open]) { display: none; }` regardless of specificity, so an
   unconditional display rule would leave a CLOSED dialog occupying an
   100vw by 100dvh block in normal page flow, exactly the lightbox
   regression. Test: "the closed menu dialog is display: none..." in
   tests/e2e/mobile-menu.spec.ts. */
.c-menu {
  border: 0; padding: 0; margin: 0;
  width: 100vw; height: 100dvh; max-width: none; max-height: none;
  background: var(--pitch); color: var(--bone);
  transform: scale(0.97);
}
.c-menu[open] { display: flex; }
.c-menu::backdrop { background: var(--pitch); }
/* Open transition only; closing is instant in both motion states (see
   mobile-menu.ts), so there is nothing to gate on the close path. Scale
   only, not opacity, for the same reason the lightbox uses scale: fading
   opacity would fade this dialog's own text against what's behind it
   (itself, via ::backdrop, briefly at less than full strength) for the
   duration of the transition. A transform has no such moment. */
@media (prefers-reduced-motion: no-preference) {
  .c-menu { transition: transform var(--dur-base) var(--ease-out); }
}
.c-menu.is-visible { transform: scale(1); }

.c-menu__inner {
  display: flex; flex-direction: column; width: 100%; height: 100%;
  padding: var(--space-s) var(--space-m) var(--space-xl);
  overflow-y: auto;
  /* Without this, a wheel or touch scroll that runs out of room inside
     this element (its content usually fits, so that is most of the time)
     chains past it to the document behind, even though html[data-menu-open]
     sets overflow: hidden there: that CSS property blocks the document's
     own scrollbar/keyboard scrolling, but not scroll-chaining from a
     nested scroll container that hits its own limit, which is what
     "contain" stops. Caught by a Playwright wheel-scroll assertion in
     tests/e2e/mobile-menu.spec.ts. */
  overscroll-behavior: contain;
}
.c-menu__bar { display: flex; align-items: center; justify-content: space-between; }
.c-menu__mark {
  font-weight: 800; letter-spacing: -0.02em; white-space: nowrap;
  text-decoration: none; color: var(--bone);
}
.c-menu__close {
  border: 0; border-radius: 50%; cursor: pointer;
  width: 2.75rem; height: 2.75rem; display: grid; place-items: center;
  background: color-mix(in srgb, var(--pitch), white 15%); color: var(--bone);
  font-size: 1.4rem; line-height: 1; padding: 0;
}
.c-menu__close:hover { background: var(--ditra-deep); }

/* Generous, large type: a full-screen menu is the one place on this page
   that can give three links this much room, rather than the small
   mono-cased nav items they are above the breakpoint. */
.c-menu__links {
  display: flex; flex-direction: column; gap: var(--space-m);
  margin-block: auto; padding-block: var(--space-l);
}
.c-menu__link {
  font-size: var(--step-3); font-weight: 800; letter-spacing: -0.03em;
  text-decoration: none; color: var(--bone);
}
.c-menu__link:hover, .c-menu__link:focus-visible { color: var(--ditra); }

.c-menu__cta {
  display: inline-block; align-self: flex-start;
  background: var(--bone); color: var(--pitch);
  font-family: var(--font-mono); font-size: var(--step--1);
  letter-spacing: 0.14em; text-transform: uppercase; text-decoration: none;
  padding: var(--space-s) var(--space-m);
}
.c-menu__cta:hover, .c-menu__cta:focus-visible { background: var(--ditra); }

/* ditra is 5.0:1 on pitch (see tests/unit/contrast.test.ts), which is why
   it can be this link's text colour directly rather than needing a chip
   like the CTA above. */
.c-menu__phone {
  display: block; margin-top: var(--space-l);
  font-size: var(--step-1); font-weight: 800;
  color: var(--ditra); text-decoration: none;
}
.c-menu__phone:hover, .c-menu__phone:focus-visible { text-decoration: underline; }

/* Page scroll lock while the menu is open (see mobile-menu.ts), set on the
   root element rather than a class so it reads alongside the other
   state attributes already driven from scripts (data-nav-theme,
   data-motion). */
html[data-menu-open] { overflow: hidden; }

/* Hero */
.c-hero { background: var(--plaster); }
.c-hero__stage {
  --cut: 0%;
  position: relative; height: 100svh; min-height: 34rem; overflow: hidden;
  background: var(--pitch);
}
.c-hero__layer { position: absolute; inset: 0; }
.c-hero__layer img { width: 100%; height: 100%; object-fit: cover; }
/* The surface layer is trimmed away from the bottom up as --cut grows. */
.c-hero__layer--surface { clip-path: inset(0 0 var(--cut) 0); }

.c-hero__rule {
  position: absolute; left: 0; right: 0; bottom: var(--cut);
  height: 2px; background: var(--ditra); z-index: 3;
}

/* Each tag now carries two labels, its cut-line role and its build stage.
   Stacked and right-aligned rather than run together on one line, so the
   pair reads as two facts rather than one long word at this size. */
.c-hero__tag {
  position: absolute; right: var(--space-m); z-index: 4;
  display: flex; flex-direction: column; align-items: flex-end;
  gap: var(--space-2xs);
}
.c-hero__tag .u-label { color: #fff; }
.c-hero__tag--surface { top: var(--space-xl); }
.c-hero__tag--structure { bottom: var(--space-xl); }

/* No max-width here any more. It used to carry one (22ch), sized against
   the ~1rem this element inherits, and because .c-hero__heading and
   .c-hero__lede are normal-flow block children with width:auto, that made
   THIS box's shrink-to-fit width the containing block both children wrap
   against, capping both to ~220px regardless of either child's own
   max-width. Each child now declares its own ch-based max-width, correctly
   scaled to its own font-size; leaving this element unconstrained lets its
   box grow to whichever child actually needs the most room, so each
   child's own max-width is what finally binds it. */
.c-hero__copy {
  position: absolute; left: var(--space-m); bottom: var(--space-l);
  z-index: 4; color: #fff;
  text-shadow: 0 2px 26px rgb(0 0 0 / 0.55);
}
/* max-width is in ch on this element deliberately, not on .c-hero__copy:
   ch resolves against the font-size of the element that declares it, and
   this heading's font-size is --step-4, not the ~1rem .c-hero__copy used
   to inherit. Sizing the constraint against the same clamp() it wraps
   means the ratio holds at every viewport width, so a line's box is always
   wide enough to contain the single long word it wraps, and SplitText's
   overflow:hidden line mask (.c-line) never has anything to crop. */
.c-hero__heading { font-size: var(--step-4); font-stretch: 112%; max-width: 7ch; }
.c-hero__lede {
  margin-top: var(--space-s); font-size: var(--step-0);
  max-width: 34ch; text-shadow: 0 2px 18px rgb(0 0 0 / 0.65);
}

/* Reduced motion resolves the cut to fully open, so the structure layer
   is what you see and both labels stay readable. */
@media (prefers-reduced-motion: reduce) {
  .c-hero__stage { --cut: 100%; }
  .c-hero__rule { display: none; }
}

/* Below this width the SURFACE/STRUCTURE tags run into the lede (confirmed
   at 390px: STRUCTURE crossed straight through "houses, and the small jobs
   in between."). They annotate a device that reads far less clearly on a
   narrow screen regardless, and the cut itself, plus the two rows of
   plain-language copy, still carries the meaning without them, so they are
   hidden rather than repositioned. */
@media (max-width: 40rem) {
  .c-hero__tag { display: none; }
}

/* Services */
.c-services { padding-block: var(--space-2xl); }
.c-services__heading { font-size: var(--step-3); margin-bottom: var(--space-xl); }
.c-services__list {
  list-style: none; padding: 0; display: grid; gap: var(--space-l);
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
}
/* --ditra here is a rule (a border), not text, so the 3.3:1-on-plaster
   restriction that forbids it as light-band body copy does not apply. */
.c-services__figure {
  margin: 0 0 var(--space-s); overflow: hidden; aspect-ratio: 4 / 3;
  border-top: 2px solid var(--ditra);
}
.c-services__figure img { width: 100%; height: 100%; object-fit: cover; }
.c-services__title { font-size: var(--step-2); margin-bottom: var(--space-xs); }
.c-services__blurb { color: var(--on-ground-dim); font-size: var(--step--1); }
/* Four recognisable marks, one per service (see the lineMarks map in
   Services.astro for which Tabler icon each one is). Each sits above
   its title, its own element, so it is not mistaken for a trailing
   glyph on the heading text. font-size is fixed to --step-2 on this
   element (not inherited) so its own em units size it consistently
   regardless of where it sits in the markup. Square, matching the
   icons' own 24x24 viewBox, rather than the 2:1 box the earlier
   section drawings used. Square line caps and miter joins in place of
   Tabler's round default, and a stroke lighter than Tabler's own 2
   units, tuned to sit with the surrounding type instead of reading as
   a stock icon dropped in unmodified. */
.c-services__linemark {
  display: block; font-size: var(--step-2);
  width: 2.4em; height: 2.4em;
  margin-bottom: var(--space-2xs);
}
.c-services__linemark path {
  fill: none; stroke: var(--ditra); stroke-width: 1.25;
  stroke-linecap: square; stroke-linejoin: miter;
}

/* Inside the wall */
/* Padding-block used to carry an extra --space-xl on the bottom, paired
   with a matching extra --space-xl on .c-process's top (below), because
   the two bands shared the same --pitch ground and read as one continuous
   dark area; a hairline on .c-process's top edge marked the seam on top of
   that extra space. Process now sits on its own --char ground (see
   tokens.css), so the colour change itself marks the boundary, the
   hairline was removed as redundant, and this band's own extra padding
   removed with it (design-feedback-5): it was only ever propping up a seam
   that colour now marks on its own, and the standard --space-2xl band
   rhythm below matches every other band's padding-block (see Services,
   Selected work and the quote form). */
.c-inside { padding-block: var(--space-2xl); }
.c-inside__grid {
  display: grid; gap: var(--space-xl); align-items: center;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 22rem), 1fr));
}
.c-inside__figure { margin: 0; overflow: hidden; aspect-ratio: 4 / 5; }
.c-inside__figure img { width: 100%; height: 100%; object-fit: cover; }
.c-inside__heading {
  font-size: var(--step-3); font-weight: 600; margin-block: var(--space-s);
}
.c-inside__body { color: var(--on-ground-dim); max-width: 44ch; }
.c-inside__materials {
  list-style: none; padding: 0; display: flex; flex-wrap: wrap;
  gap: var(--space-xs); margin-top: var(--space-m);
}
/* The border is decorative, same as the services figure rule above, so it
   carries no contrast obligation either way. The text color is the one
   place on the page --ditra is used as text: permitted here because it is
   5.0:1 on --pitch, unlike the 3.3:1 it gets on --plaster. */
.c-inside__material {
  font-family: var(--font-mono); font-size: 0.68rem; letter-spacing: 0.08em;
  text-transform: uppercase; border: 1px solid var(--ditra);
  color: var(--ditra); padding: 0.45rem 0.6rem; border-radius: 2px;
}

/* Process */
/* Top padding used to carry an extra --space-xl, matching the extra bottom
   padding .c-inside used to add above, so the two dark bands separated
   symmetrically around their shared boundary. Both extras are gone
   (design-feedback-5, see .c-inside above): the boundary is now marked by
   the ground colour change itself (--char below, against Inside the
   wall's --pitch), not by a hairline plus a padding cushion on either side
   of it, so the cushion is no longer earning its keep. Top padding below
   is the same --space-2xl every other band uses.
   Bottom padding is --space-m, not --space-2xl (design-feedback-6): the
   rail's native scrollbar always renders flush with its own scroll
   container's bottom edge, and the rest of --space-2xl now lives on
   .c-rail__track's own bottom padding instead (see below), so the
   scrollbar sits near the band's true base with room between it and the
   cards, rather than tight under the cards with the rest of the band's
   height sitting empty below it. The two numbers still add up to the same
   --space-2xl + --space-m total as before, so the band's own height is
   unchanged. */
/* --ground is overridden here, locally, to --char: a lifted dark distinct
   from Inside the wall's --pitch, so two adjacent dark bands no longer
   share a background (see docs/superpowers/specs/2026-09-14-the818-home-
   the-cut-design.md and the design-feedback-4 report for the ratios).
   Still data-band="dark" in the markup (Process.astro), so the nav theme
   observer still treats it as a dark band; only the ground itself moves,
   the same way --shell and --grout below move --ground for Selected work
   and the quote form without touching their data-band value either.
   --accent-text is also overridden here: on the base --pitch ground
   --ditra clears 5.0:1, comfortably past the 4.5:1 this page requires for
   text, but --char cannot be lifted far enough to keep --ditra above
   4.5:1 and still read as "visibly lifted" (the two pull in opposite
   directions: the further --char lifts, the closer its own luminance
   gets to --ditra's, and the ratio between them falls). --bone is used
   for the stage numerals instead, the same colour the heading already
   uses, since it clears --char by a wide margin at any lift. --ditra
   stays in use elsewhere on this band (the card's top border, the rail
   scrollbar thumb): those are decorative or graphical, not text, so the
   3:1 non-text threshold applies, not 4.5:1, and --ditra clears that on
   --char with room to spare. */
.c-process {
  padding-block: var(--space-2xl) 0;
  --ground: var(--char);
  --accent-text: var(--bone);
}
/* margin-bottom matches the label-to-heading-to-content rhythm every other
   band uses (see .c-services__heading, .c-work__heading): --space-xl from
   heading to whatever sits below it. The rail track underneath adds its
   own small --space-xs top padding on top of that (see .c-rail__track
   below), the same as it does under Gallery's heading, so the cards land a
   touch further in than a plain grid would; that's the rail's own inset,
   not a second heading gap. */
.c-process__heading { font-size: var(--step-3); margin-block: var(--space-xs) var(--space-xl); }

.c-rail { position: relative; }
/* Bottom padding is --space-2xl, not the --space-m it used to be
   (design-feedback-6). A native scrollbar always renders flush with the
   bottom edge of its own scroll container's box, so growing the track's
   own box down by the amount taken off .c-process / .c-gallery's bottom
   padding (see those rules) carries the scrollbar down with it. Those
   sections now have no bottom padding at all, so the track's bottom edge
   IS the band's bottom edge and the scrollbar sits flush against it with
   nothing underneath. The track's own box grew by exactly what the
   sections gave up, so the band's total height is unchanged. */
.c-rail__track {
  --rail-gutter: max(1rem, calc((100vw - 84rem) / 2));
  display: flex; gap: var(--space-m);
  overflow-x: auto; overscroll-behavior-x: contain;
  scroll-snap-type: x proximity;
  padding-inline: var(--rail-gutter);
  scroll-padding-inline: var(--rail-gutter);
  padding-block: var(--space-xs) calc(var(--space-2xl) + var(--space-m));
  cursor: grab;
}
.c-rail__track.is-dragging { cursor: grabbing; scroll-snap-type: none; }
.c-rail__track > * { scroll-snap-align: start; flex: 0 0 auto; }

/* Themed scrollbar rather than the browser default. The track is a subtle
   tint mixed from the band's own --ground and --on-ground, so it reads as
   part of the design and automatically matches whichever band the rail
   sits in (dark #process, light #gallery) instead of two hardcoded colors.
   The thumb is --ditra, the bar itself, a graphic rather than text, so the
   3.3:1-on-plaster restriction on --ditra as text does not apply here. The
   scrollbar stays visible (not hidden) so it keeps working as the
   affordance that tells people the rail scrolls, including under reduced
   motion; see the "rail keeps a visible scrollbar" test in process.spec.ts. */
.c-rail__track {
  scrollbar-width: thin;
  scrollbar-color: var(--ditra) color-mix(in srgb, var(--ground), var(--on-ground) 15%);
}
.c-rail__track::-webkit-scrollbar { height: 8px; }
.c-rail__track::-webkit-scrollbar-track {
  background: color-mix(in srgb, var(--ground), var(--on-ground) 15%);
  border-radius: 4px;
}
.c-rail__track::-webkit-scrollbar-thumb {
  background: var(--ditra);
  border-radius: 4px;
}

/* The border is decorative, same as the services figure rule and the inside
   materials border above, so it carries no contrast obligation either way. */
.c-process__card {
  width: min(20rem, 78vw);
  border-top: 2px solid var(--ditra);
  padding-top: var(--space-s);
  /* ew-resize is the system left-right double arrow, signalling that this
     card belongs to a sideways-scrolling rail (design-feedback-6). It sits
     on the card itself, not the track, so it wins over the track's own
     cursor: grab at rest, where the pointer is normally over a card; the
     track's plain grab only shows in the gaps and padding between cards.
     Not applied to .c-gallery__item: those cards are buttons that open the
     lightbox on click, so they keep cursor: pointer instead, matching
     their primary action. */
  cursor: ew-resize;
}
/* While an actual drag is in flight (.is-dragging is added to the track by
   rails.ts on pointerdown), grabbing should win over the card's own
   ew-resize: the user is mid-grab, not hovering to discover the
   affordance. This selector is more specific than the plain
   .c-process__card rule above, so it overrides it regardless of source
   order. */
.c-rail__track.is-dragging .c-process__card { cursor: grabbing; }
/* --accent-text is --bone on this band (see the .c-process override
   above), not --ditra: --char can't be lifted far enough off --pitch to
   keep --ditra above 4.5:1 as text and still read as a distinct ground. */
.c-process__number {
  font-family: var(--font-mono); font-size: var(--step-2);
  color: var(--accent-text); display: block; margin-bottom: var(--space-xs);
}
.c-process__title { font-size: var(--step-1); margin-bottom: var(--space-2xs); }
.c-process__body { color: var(--on-ground-dim); font-size: var(--step--1); }

/* Material marquee */
/* A thin full-bleed interstitial strip, deliberately unlike every other
   band: no label, no heading, one line that scrolls. overflow: hidden on
   the outer section is what keeps the doubled track from ever widening the
   page itself, so this cannot introduce horizontal scroll no matter how
   long the material list gets. */
.c-marquee { overflow: hidden; padding-block: var(--space-m); }
.c-marquee__viewport { overflow: hidden; }
/* width: max-content lets the track size itself to its real content (two
   equal groups back to back) rather than the viewport, which is what makes
   translateX(-50%) land exactly on the seam between the two groups instead
   of some arbitrary fraction of the screen. */
.c-marquee__track {
  display: flex; width: max-content;
  animation: c-marquee-scroll 42s linear infinite;
}
.c-marquee__group {
  display: flex; align-items: center; list-style: none; margin: 0; padding: 0;
}
.c-marquee__item {
  display: flex; align-items: center; gap: var(--space-m);
  padding-inline-end: var(--space-m);
  font-family: var(--font-mono); font-size: var(--step--1);
  letter-spacing: 0.08em; text-transform: uppercase; white-space: nowrap;
  color: var(--on-ground);
}
.c-marquee__mark { color: var(--accent-text); }

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

/* Pauses on hover or keyboard focus within, so a reader can actually stop
   the ticker to read a name, matching the pause the spec asks for; it does
   not need JavaScript since animation-play-state is a plain CSS property. */
.c-marquee:hover .c-marquee__track,
.c-marquee:focus-within .c-marquee__track {
  animation-play-state: paused;
}

/* Stops completely rather than merely slowing down, per the spec: no
   animation at all, transform reset, and the loop's duplicate copy is
   removed from layout entirely so what's left reads as one static row,
   not a scroller frozen mid-loop. This does not depend on the page-wide
   reduced-motion override in global.css (which only shortens durations to
   near-zero); it is its own explicit rule so the marquee's stopped state
   is guaranteed rather than incidental. */
@media (prefers-reduced-motion: reduce) {
  .c-marquee__track { animation: none; transform: none; }
  .c-marquee__group[aria-hidden='true'] { display: none; }
}

/* Selected work */
/* --ground overridden locally to --shell, a warm off-white slightly deeper
   than --plaster, so this band reads distinct from both Services above
   (separated by the two dark bands between them, but still worth varying)
   and the gallery strip below, which returns to --plaster. Still
   data-band="light" in the markup, so the nav theme is unaffected. */
.c-work { padding-block: var(--space-2xl); --ground: var(--shell); }
.c-work__heading { font-size: var(--step-3); margin-block: var(--space-xs) var(--space-xl); }
/* Explicit steps rather than auto-fit: with exactly four cards, auto-fit's
   per-row wrapping produced an orphan row (3 across, 1 alone) at wide
   viewports. Four divides evenly by 4, 2 and 1, so every step below is a
   full, even grid and no width can ever leave a single card on its own
   row: one column below 45rem, two from there to 75rem (pairing 2 by 2),
   four from 75rem up, where the band gets its widest, most gallery-like
   read. */
.c-work__list {
  list-style: none; padding: 0; display: grid; gap: var(--space-xl);
  grid-template-columns: 1fr;
}
@media (min-width: 45rem) {
  .c-work__list { grid-template-columns: repeat(2, 1fr); }
}
@media (min-width: 75rem) {
  .c-work__list { grid-template-columns: repeat(4, 1fr); }
}
.c-work__stage {
  --cut: 38%;
  position: relative; aspect-ratio: 4 / 3; overflow: hidden; background: var(--pitch);
}
.c-work__layer { position: absolute; inset: 0; }
.c-work__layer img { width: 100%; height: 100%; object-fit: cover; }
.c-work__layer--surface { clip-path: inset(0 0 var(--cut) 0); }
/* basement-tub-kerdi-mid is a portrait (1200x1600) photo cover-fit into this
   4/3 stage. Centered object-position crops the orange Kerdi board almost
   entirely out of the visible band below the cut line. Pull the crop toward
   the top of the source photo so the board stays visible. */
.c-work__layer--structure img { object-position: 50% 0%; }
/* basement-tub-kerdi-mid's Kerdi board sits in source y 0%-35%. With the
   default --cut: 38% and object-position: 50% 0% above, the revealed
   structure band only reaches source y 34.9%-56.3%, which starts right at
   the board/tub boundary and shows almost none of the board. This is the
   only project card with a stacked cut today (data-work-cut is only set
   when a project has a midBuildImageId), so raising --cut here is scoped to
   this one card and does not touch the shared 38% default other cards
   would otherwise use if a second stacked card is ever added. At 60% the
   revealed band is source y 22.5%-56.3%, which shows the board's grid and
   screw pattern clearly before it transitions into the tub. */
.c-work__stage[data-work-cut] { --cut: 60%; }
/* --ditra here is a 2px rule, not text, so the 3.3:1-on-plaster restriction
   that forbids it as light-band body copy does not apply. */
.c-work__rule {
  position: absolute; left: 0; right: 0; bottom: var(--cut);
  height: 2px; background: var(--ditra);
}
/* The margin-top is the gap that used to sit above the removed number
   label. Keeping it on the title preserves the original breathing room
   between the project stage image and the card's text. */
.c-work__title { font-size: var(--step-2); margin-block: var(--space-s) var(--space-xs); }

/* Statement */
/* The one band that breaks the label/heading/content rhythm completely:
   no label, no heading, no cards, just one large line given real room to
   breathe. Sized as the midpoint between --step-3 (every other band's
   heading size) and --step-4 (the hero h1) rather than a new magic
   number, so it reads unmistakably larger than a section heading while
   staying clearly under the hero, at every viewport the clamp()s inside
   --step-3 and --step-4 already cover. */
.c-statement {
  padding-block: var(--space-xl);
  display: flex; align-items: center; justify-content: center;
  text-align: center; min-height: 32vh;
}
.c-statement__line {
  font-family: var(--font-display); font-weight: 300;
  font-size: var(--step-3);
  line-height: 1.15; letter-spacing: -0.01em;
  max-width: 20ch; margin-inline: auto;
}
.c-work__summary { color: var(--on-ground-dim); font-size: var(--step--1); max-width: 46ch; }

/* Hover: the photograph zooms slightly within its frame, the frame itself
   stays put. Restrained (1.04) and roughly half a second on the page's own
   ease-out, not a punchy or bouncy move. Applies to the Services figures,
   the Inside the wall figure, and the Work card stage(s); deliberately not
   the hero, a scroll-driven device a hover scale would fight, and not the
   gallery strip, which the next pass makes clickable and will give its own
   hover state alongside that affordance. All three frames above already
   carry overflow: hidden, so the zoomed image has nowhere to spill.

   The transition lives inside "no-preference" only, so a reduced-motion
   visitor still gets the scaled state on hover, just without the animated
   approach to it, matching the rest of the page's reduced-motion-is-a-
   separate-path-not-a-disabled-one contract.

   .c-work__layer img is targeted per layer, not the shared .c-work__stage,
   deliberately: the one stacked card (basement in-law suite) renders two
   layer divs at the same size and position, so scaling both img elements
   by the identical factor around the same centre keeps the two halves
   moving together and the cut line never drifts apart on hover. */
@media (prefers-reduced-motion: no-preference) {
  .c-services__figure img,
  .c-inside__figure img,
  .c-work__layer img {
    transition: transform var(--dur-base) var(--ease-out);
  }
}
.c-services__figure:hover img,
.c-inside__figure:hover img,
.c-work__stage:hover .c-work__layer img {
  transform: scale(1.04);
}

/* Gallery strip */
/* Padding-block used to be asymmetric (--space-xl top, --space-2xl bottom)
   with no stated reason; harmonised to the standard --space-2xl every
   other band uses (design-feedback-5).
   Bottom padding is --space-m again now, for the same reason given on
   .c-process above (design-feedback-6): the rest of --space-2xl moved to
   .c-rail__track's own bottom padding, which shares Rail.astro with
   #process, so the gallery rail's scrollbar sits near the band's true
   base too, and the band's total height is unchanged. */
.c-gallery { padding-block: var(--space-2xl) 0; }
/* margin-bottom matches Process's own heading-to-content gap (see
   .c-process__heading above): --space-xl, same as the grid-based bands. */
.c-gallery__heading { font-size: var(--step-3); margin-block: var(--space-xs) var(--space-xl); }
/* Each plate is now wrapped in a real button (see GalleryStrip.astro), so
   sizing moves here, to the button, while the figure inside just fills it.
   The button itself is reset to plain, unstyled box: no default button
   border, background or padding, so it stays visually identical to the
   plain figure it replaces until hovered or focused. */
.c-gallery__item {
  display: block; width: 22rem; max-width: 80vw; margin: 0; padding: 0;
  border: 0; background: none; font: inherit; color: inherit; text-align: left;
  cursor: pointer;
}
.c-gallery__plate { margin: 0; aspect-ratio: 3 / 4; overflow: hidden; }
.c-gallery__plate img { width: 100%; height: 100%; object-fit: cover; }
/* The gallery strip's own hover state. Deliberately not folded into the
   shared restrained-scale block above (.c-services__figure img and
   friends): that block's own comment explains it excludes the gallery
   because this pass is what makes the plates clickable. Same figure, 1.04
   over roughly half a second, no-preference only, so it matches the rest
   of the page; applied to the button now wrapping each plate so hover and
   click share one target. The focus-visible ring is the global default
   (see global.css), same as every other interactive element on the page. */
@media (prefers-reduced-motion: no-preference) {
  .c-gallery__plate img { transition: transform var(--dur-base) var(--ease-out); }
}
.c-gallery__item:hover .c-gallery__plate img { transform: scale(1.04); }

/* Lightbox */
/* A native <dialog> rather than a hand-rolled role="dialog" overlay: opened
   with showModal(), current Chromium, Firefox and Safari trap keyboard
   focus inside it and make the rest of the page inert automatically, which
   is what "focus is trapped inside the dialog" needs. It is not inside any
   .u-band section, so it carries its own colours directly rather than
   reading --ground/--on-ground, which would otherwise resolve to the
   light-band root values here. */
/* The project's own global reset (`* { margin: 0; }` in global.css) zeroes
   out the UA stylesheet's `dialog:modal { margin: auto; }`, which is what
   normally centres a modal dialog that has shrunk to its content size. Left
   alone, that collapses this dialog to a content-sized box pinned at the
   top-left corner (inset: 0 with margin: 0). Rather than fight the reset
   with a margin override that would be one specificity war away from
   breaking again, the dialog is sized to the viewport outright and made the
   centring container itself: it always fills the screen, and it centres
   `.c-lightbox__inner` inside it with flexbox instead of relying on the
   UA's shrink-to-fit-then-centre behaviour at all. */
.c-lightbox {
  border: 0; padding: 0; margin: 0;
  width: 100vw; height: 100dvh; max-width: none; max-height: none;
  background: var(--pitch); color: var(--bone);
  transform: scale(0.97);
}
/* display: flex used to live on the bare .c-lightbox selector above,
   unconditionally. Author CSS always wins over the UA stylesheet
   regardless of specificity, so that overrode the UA's own
   `dialog:not([open]) { display: none; }` and a CLOSED dialog rendered as
   a 100vw by 100dvh flex box sitting in normal page flow: its buttons
   floated over whatever came after the gallery and everything below it
   was pushed down by the dialog's own height. Scoping display, and the
   centring it depends on, to `[open]` means the closed state has no
   author rule touching display at all, so the UA default applies and the
   dialog is display: none, zero-size, and out of flow, exactly as a
   closed dialog must be. Regression test: "the closed dialog is display:
   none..." in tests/e2e/lightbox.spec.ts, confirmed failing against the
   unscoped rule before this fix. */
.c-lightbox[open] {
  display: flex; align-items: center; justify-content: center;
}
/* Scale only, deliberately not opacity: fading the dialog's own opacity
   would fade its text against whatever sits behind it too, for the
   duration of the transition, which briefly drops the caption's contrast
   below 4.5:1 (caught by axe running the instant the dialog opens, which
   is a real, if short-lived, state, not a false positive). A transform
   does not have that problem: the text is fully opaque throughout. */
.c-lightbox::backdrop { background: rgb(19 17 16 / 0.85); }
/* Open transition only; closing is instant in both motion states (see
   lightbox.ts), so there is nothing to gate on the close path. */
@media (prefers-reduced-motion: no-preference) {
  .c-lightbox { transition: transform var(--dur-base) var(--ease-out); }
}
.c-lightbox.is-visible { transform: scale(1); }

.c-lightbox__inner {
  /* Horizontal padding is deliberately tied to the control button's own
     size (--space-xs inset, --lightbox-control wide, --space-xs clear on
     the far side) rather than a fixed length: it is what keeps Previous
     and Next inside their own gutter instead of sitting on top of the
     image's edge at narrow widths, where the image would otherwise butt
     right up against the inner's padding edge. Works at every viewport
     width without a breakpoint, since it is the same fixed gutter either
     way, just a larger fraction of a narrow screen. */
  --lightbox-control: 2.75rem;
  position: relative; display: flex; align-items: center; justify-content: center;
  padding-block: var(--space-xl);
  padding-inline: calc(var(--lightbox-control) + var(--space-s));
  max-width: 100%; max-height: 100%;
}
.c-lightbox__figure {
  margin: 0; display: flex; flex-direction: column; align-items: center;
  gap: var(--space-s); min-width: 0;
}
/* The image, not the dialog or the inner wrapper, is what's capped: a
   portrait photograph is exactly as tall as a landscape one is wide, so
   only a constraint on the image itself, both axes, keeps either
   orientation fully visible without cropping or overflowing the viewport.
   The width cap is figured directly from the same control-button gutter
   .c-lightbox__inner reserves above (100vw minus that padding on both
   sides), not a flat 90vw: at narrow widths a plain 90vw cap is wider than
   the space actually left after the Previous/Next gutter, which is exactly
   what put the buttons on top of the image's edge before this. */
.c-lightbox__figure img {
  display: block;
  max-width: min(70rem, calc(100vw - 2 * (var(--lightbox-control) + var(--space-s))));
  max-height: 80dvh;
  width: auto; height: auto; object-fit: contain;
}
.c-lightbox__caption {
  font-size: var(--step--1); color: var(--bone); text-align: center; max-width: 52rem;
}
.c-lightbox__button {
  position: absolute; border: 0; border-radius: 50%; cursor: pointer;
  width: var(--lightbox-control); height: var(--lightbox-control); display: grid; place-items: center;
  background: color-mix(in srgb, var(--pitch), white 15%); color: var(--bone);
  font-size: 1.4rem; line-height: 1; padding: 0;
}
.c-lightbox__button:hover { background: var(--ditra-deep); }
.c-lightbox__close { top: var(--space-xs); right: var(--space-xs); }
.c-lightbox__prev { left: var(--space-xs); top: 50%; transform: translateY(-50%); }
.c-lightbox__next { right: var(--space-xs); top: 50%; transform: translateY(-50%); }

/* Quote form */
/* --ground overridden locally to --grout, a cooler, greyer light tint,
   distinct from --plaster on the gallery strip above by hue rather than
   by depth alone (both --shell and --grout sit close to --plaster in
   luminance; see tokens.css). Still data-band="light" in the markup, so
   the nav theme is unaffected. */
.c-quote { padding-block: var(--space-2xl); --ground: var(--grout); }
/* The left column now carries the intro plus the call/email/office
   details that used to be their own #contact band below this one, so it
   has real substance next to the form rather than a label and one line
   of note. That is also why the split moved back close to even (6fr/7fr,
   not 2fr/3fr): a column this full does not need to be starved for the
   form to read as generous. align-items: start keeps the column anchored
   against the form's first field instead of floating loose against it.
   Below 48rem there is not enough width for two columns to hold, so the
   grid collapses to one and the intro simply sits above the form. */
.c-quote__grid {
  display: grid; gap: var(--space-xl);
  grid-template-columns: 6fr 7fr;
  align-items: start;
}
@media (max-width: 48rem) {
  .c-quote__grid { grid-template-columns: 1fr; }
}
.c-quote__heading { font-size: var(--step-3); margin-block: var(--space-xs); }
/* --on-ground-dim resolves to --concrete on this light band. It clears
   body copy on --plaster at 5.0:1, and still clears it on this band's own
   --grout ground at 4.6:1 (see tests/unit/contrast.test.ts). */
.c-quote__note { color: var(--on-ground-dim); }
/* The call/email/office blocks, folded in from the former #contact band.
   Stacked in one column (unlike the old .c-contact__list, which spread
   across several columns when it had a full band's width to itself)
   since this now shares a much narrower column with the rest of the
   intro. */
.c-quote__contact {
  list-style: none; padding: 0; margin: var(--space-l) 0 0;
  display: grid; gap: var(--space-m);
}
.c-quote__form { display: grid; gap: var(--space-s); grid-template-columns: 1fr 1fr; }
.c-field { display: flex; flex-direction: column; gap: var(--space-2xs); }
.c-field--wide { grid-column: 1 / -1; }
/* "What needs doing" is the field people actually need room in, so it is
   allowed to grow past the other fields' single-line height and to be
   dragged taller still, rather than being clipped to a fixed row count. */
.c-field--wide textarea.c-field__input { min-height: 10rem; resize: vertical; }
.c-field__label {
  font-family: var(--font-mono); font-size: 0.68rem;
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--on-ground-dim);
}
.c-field__input {
  font: inherit; color: inherit; background: transparent;
  border: 0; border-bottom: 1px solid var(--concrete);
  padding: 0.6rem 0.1rem; border-radius: 0;
}
.c-field__input:focus-visible { border-bottom-color: var(--ditra); }
/* --ditra-deep clears 5.6:1 on --plaster and 5.1:1 on this band's own
   --grout ground (see contrast.test.ts), well past the 4.5:1 body copy
   floor, unlike plain --ditra at 3.3:1 on --plaster, which is rules and
   large display only. */
.c-field__error { color: var(--ditra-deep); font-size: var(--step--1); min-height: 1.2em; }
/* Reserved for the Turnstile widget at the WordPress stage. Sized to the real
   managed widget so dropping it in later reflows nothing. */
.c-quote__turnstile {
  grid-column: 1 / -1;
  max-width: 100%;
}
.c-quote__submit {
  grid-column: 1 / -1; justify-self: start;
  font-family: var(--font-mono); font-size: 0.74rem;
  letter-spacing: 0.16em; text-transform: uppercase;
  background: var(--ink); color: var(--bone);
  border: 0; padding: 1rem 1.6rem; cursor: pointer;
}
.c-quote__status { grid-column: 1 / -1; color: var(--on-ground-dim); font-size: var(--step--1); }
@media (max-width: 40rem) { .c-quote__form { grid-template-columns: 1fr; } }

/* Contact blocks, now rendered inside .c-quote__contact above rather than
   as their own band; kept as separate classes since .c-contact__line's
   underline rule is generic enough to stay put. */
.c-contact__line { font-size: var(--step-1); }
/* The border is decorative underline, not the link text colour, so it
   carries no contrast obligation of its own (the text itself inherits
   --on-ground at full contrast). */
.c-contact__line a { text-decoration: none; border-bottom: 1px solid var(--ditra); }

/* Footer */
/* 1.4rem rather than a token: the scale has no step between --space-s
   (1rem) and --space-m (1.75rem), and the band wants the half-step. */
.c-footer { background: var(--ink); color: var(--bone); padding-block: 1.4rem; }
/* align-items: center, not the default stretch: the wordmark sets a taller
   line box than the mono labels, so top-aligned rows leave the right-hand
   group sitting visibly high against it. */
.c-footer__inner {
  display: flex; flex-wrap: wrap; gap: var(--space-m);
  justify-content: space-between; align-items: center;
}
/* Larger than the header's wordmark: the footer has no width pressure and
   nothing else in the row carries the brand, so it anchors the band. The
   meta group keeps its own smaller size. */
.c-footer__mark {
  font-size: var(--step-1); font-weight: 800; letter-spacing: -0.02em;
  display: inline-flex; align-items: center; gap: 0.4em;
}
/* The copyright belongs to the mark it names, so it sits under it rather
   than out at the end of the row with the rest of the small print. */
.c-footer__brand { display: flex; flex-direction: column; gap: var(--space-2xs); }
/* Smaller than .u-label's own size and tighter tracking: this is the
   quietest line in the footer, and it sits directly under a --step-1
   wordmark, where label-sized small print competes with the mark instead
   of sitting beneath it. */
.c-footer__copyright { font-size: 0.6rem; letter-spacing: 0.12em; }
.c-footer__credit a {
  color: var(--bone); text-decoration: none;
  border-bottom: 1px solid var(--ditra);
  transition: color var(--dur-fast) var(--ease-out);
}
.c-footer__credit a:hover, .c-footer__credit a:focus-visible { color: var(--ditra); }
/* WORK / SERVICES / PROCESS, same three links as .c-nav__link (shared from
   Nav.astro's navLinks export), landing here because the header hides them
   below 40rem. The footer has no width pressure, so a plain wrapping row is
   enough; no breakpoint of its own is needed. */
.c-footer__links ul {
  display: flex; flex-wrap: wrap; gap: var(--space-m);
  list-style: none; margin: 0; padding: 0;
}
.c-footer__links a {
  font-family: var(--font-mono); font-size: 0.9rem;
  letter-spacing: 0.14em; text-transform: uppercase; text-decoration: none;
  color: var(--bone);
}
.c-footer__links a:hover, .c-footer__links a:focus-visible { text-decoration: underline; }

/* Back to top. A fixed control that crosses every band on the page, so it
   carries its own dark ground rather than inheriting one: --ink on a
   --ditra edge clears 12.1:1 for the arrow and reads as deliberate over
   both the light and the dark sections.

   Visibility is opacity + visibility, not the `hidden` attribute that
   ships in the markup: `hidden` is the no-JavaScript state and is removed
   once (see back-to-top.ts), and visibility keeps the button out of the
   tab order while it is faded out, so a keyboard visitor never lands on a
   control they cannot see. */
.c-totop {
  position: fixed;
  right: var(--space-m); bottom: var(--space-m);
  z-index: 40;
  width: 2.75rem; height: 2.75rem;
  display: grid; place-items: center;
  padding: 0;
  background: var(--ink);
  border: 1px solid var(--ditra);
  border-radius: 0;
  color: var(--bone);
  cursor: pointer;
  opacity: 0; visibility: hidden;
  transform: translateY(0.5rem);
  transition:
    opacity var(--dur-base) var(--ease-out),
    visibility var(--dur-base) var(--ease-out),
    transform var(--dur-base) var(--ease-out),
    background-color var(--dur-fast) var(--ease-out);
}
.c-totop.is-visible { opacity: 1; visibility: visible; transform: none; }
.c-totop:hover, .c-totop:focus-visible { background: var(--ditra); color: var(--ink); }
.c-totop:focus-visible { outline: 2px solid var(--bone); outline-offset: 2px; }
.c-totop__icon {
  width: 1.1rem; height: 1.1rem;
  fill: none; stroke: currentColor; stroke-width: 1.75;
  stroke-linecap: square; stroke-linejoin: miter;
}
/* The quote form's own submit sits at the bottom right of the page on
   narrow screens, where a floating control would cover it. */
@media (max-width: 40rem) {
  .c-totop { right: var(--space-s); bottom: var(--space-s); }
}

/* Micro-interactions. Nothing here hides content: .c-line only clips the
   line-reveal transform SplitText produces, and both rules are inert until
   src/scripts/micro.ts applies a transform or opacity at runtime. */
.c-line { overflow: hidden; }
[data-magnetic] { will-change: transform; }
