/* ============================================================================
   Per-section layout + the CSS half of the motion components.
   ========================================================================== */

/* ---- SplitWords ---------------------------------------------------------- */
.sw-box { display: inline-block; overflow: hidden; vertical-align: bottom; padding-bottom: 0.06em; }
.sw-word { display: inline-block; will-change: transform; }

/* ---- ClipReveal ---------------------------------------------------------- */
.clip { position: relative; overflow: hidden; border-radius: var(--radius); }
/* 14% bleed so the +/-7% counter-parallax can never expose an edge. */
.clip__inner { position: absolute; inset: -14%; will-change: transform; }
.clip__inner img, .clip__inner video { width: 100%; height: 100%; object-fit: cover; }

/* ---- LRule --------------------------------------------------------------- */
.lrule { position: relative; width: 280px; height: 124px; }
.lrule__down, .lrule__across {
  position: absolute; background: currentColor; opacity: 0.16;
}
.lrule__down { left: 0; top: 0; width: 1px; height: 0; }
.lrule__across { left: 0; top: 122.8px; height: 1px; width: 1px; }

/* ============================================================================
   SECTION BACKGROUND FIELD — ported from 21st.dev "Elegant Dark Pattern"
   (jatin-yadav05, id 5294; its own demo calls it "Dark Gradient Background").
   Approved by Ben 2026-08-12 off _preview/background.html.

   HOW THE SOURCE IS BUILT: a black root; a corner radial (rgb(46,46,46) -> black
   from 0% 0%) masked by a second radial so it fades toward the bottom-right;
   FIVE sibling divs each carrying a VERTICAL cyan->transparent gradient carved
   by a HORIZONTAL irregular mask, skewX(45deg), opacity .20; then a repeating
   noise PNG, a 20px dot grid and a soft radial highlight.

   WHAT CHANGED IN THE PORT, and why:
     * BLACK -> OUR NAVY. Ground is --bg; the corner lift is a navy several
       stops up, so the section still reads as our blue-black.
     * ONE CYAN -> ALTERNATING BRAND COLOUR (Ben). The five same-colour streaks
       become two masked layers: ::before carries the WARM ORANGE bands, ::after
       the BLUE, and their mask stops interleave so the colours alternate across
       the section instead of muddling together.
     * FIVE DIVS -> TWO PSEUDO-ELEMENTS. Grouping by colour gets the same
       construction (vertical fade x horizontal mask x skew) with ZERO extra
       markup — so no section needs a wrapper div and the background cannot
       depend on JavaScript to exist.
     * THE NOISE PNG IS GONE. The source hot-links framerusercontent.com. A
       client page must not depend on a stranger's CDN for its own background.
     * -webkit-mask is written alongside mask; the source ships only the
       unprefixed property, which renders a blank layer in Safari.
     * EVERY OPACITY IS DOWN HARD. The source runs five streaks at .20 each on
       pure black; saturated brand colour on navy at that strength buries body
       copy. The whole field hangs off one knob, --fx.

   THE CORNER LIFT PEAKS AT 0.46, NOT 0.55, AND THAT IS A CONTRAST FIX.
   Measured off a screenshot of the approved preview (scrollbar excluded, or the
   grey gutter poses as the brightest "ground"): at 0.55 the brightest composited
   ground was rgb(48,61,95), against which --accent #0db1e2 scored 4.29:1 — under
   the 4.5:1 bar for the 12px .eyebrow, which sits in the top-left corner where
   the lift is strongest. --fg and --muted passed (9.54:1 and 4.84:1). 0.46 pulls
   the peak under the bar's ground while being invisible at a glance.

   WHERE IT APPLIES: every DARK body section, plus `.hero` — and the hero needs
   its own sentence, because Ben's instruction was "DO NOT REPLACE THE HERO WITH
   THIS" and this does not.
   The field sits BEHIND the hero's WebGL canvas, which at rest is fully opaque
   and covers the box from the header's bottom edge to the hero's own bottom, so
   at progress 0 not one pixel of it is visible. It exists only because
   js/hero/hero-flyup.js fades the sky plate out of the grade pass and drops the
   canvas alpha with it, and whatever is behind the hero is then what the visitor
   is looking at — that has to be the page's gradient, not flat navy. The only
   strip this could ever expose is the 88px under the header, and the header's
   own navy fill covers it.
   Still NOT applied to the page/service header sections or to .theme-light.

   STACKING: the layers are z-index -1 inside an `isolation: isolate` context.
   That paints them above the section's own background but below EVERY child,
   positioned or not — so nothing here needs to touch the z-index of content,
   which matters because .case already stacks its tow rope (1) and next tile (2).
   ========================================================================== */
.section:not(.theme-light):not(.page-hero):not(.svc-hero),
.case,
.hero {
  position: relative;
  isolation: isolate;
  overflow: hidden;              /* the skewed streaks would spill otherwise */
  background-color: var(--bg);
  background-image:
    radial-gradient(circle at 1px 1px,
      rgba(238, 242, 247, calc(0.05 * var(--fx))) 1px, transparent 0),
    /* The source masks a second radial to fade the lift out; the fade is
       expressed straight in these stops instead — same result, one layer. */
    radial-gradient(120% 105% at 0% 0%,
      rgba(56, 78, 140, calc(0.46 * var(--fx))) 0%,
      rgba(20, 30, 70,  calc(0.24 * var(--fx))) 45%,
      rgba(5, 9, 31, 0) 100%);
  background-size: 22px 22px, auto;
}

/* THE STREAKS. Both are the source's construction unchanged — a vertical colour
   fade, carved by a horizontal irregular mask, then skewed. Only the colour and
   the mask stops differ.
   Oversized because skewX(45deg) shears the box sideways by half its height at
   top and bottom; at inset:0 that leaves bare triangles in the corners. */
.section:not(.theme-light):not(.page-hero):not(.svc-hero)::before,
.section:not(.theme-light):not(.page-hero):not(.svc-hero)::after,
.case::before,
.case::after,
.hero::before,
.hero::after {
  content: '';
  position: absolute;
  top: -60%; bottom: -60%; left: -100%; right: -100%;
  z-index: -1;
  pointer-events: none;
  transform: skewX(45deg);
}

/* WARM ORANGE bands */
.section:not(.theme-light):not(.page-hero):not(.svc-hero)::before,
.case::before,
.hero::before {
  background: linear-gradient(to bottom, var(--fx-warm) 0%, rgba(248, 147, 52, 0) 78%);
  opacity: calc(0.16 * var(--fx));
  -webkit-mask: linear-gradient(90deg,
    rgba(0,0,0,0) 4%, rgb(0,0,0) 15%, rgba(0,0,0,0.15) 24%, rgba(0,0,0,0) 33%,
    rgba(0,0,0,0.9) 46%, rgba(0,0,0,0.1) 55%, rgba(0,0,0,0) 63%,
    rgb(0,0,0) 76%, rgba(0,0,0,0) 90%);
  mask: linear-gradient(90deg,
    rgba(0,0,0,0) 4%, rgb(0,0,0) 15%, rgba(0,0,0,0.15) 24%, rgba(0,0,0,0) 33%,
    rgba(0,0,0,0.9) 46%, rgba(0,0,0,0.1) 55%, rgba(0,0,0,0) 63%,
    rgb(0,0,0) 76%, rgba(0,0,0,0) 90%);
}

/* BLUE bands — these mask stops sit in the GAPS the orange mask leaves, which is
   what makes the two colours alternate rather than stack on each other. */
.section:not(.theme-light):not(.page-hero):not(.svc-hero)::after,
.case::after,
.hero::after {
  background: linear-gradient(to bottom, var(--fx-cool) 0%, rgba(13, 177, 226, 0) 78%);
  opacity: calc(0.15 * var(--fx));
  -webkit-mask: linear-gradient(90deg,
    rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 9%, rgba(0,0,0,0) 18%, rgba(0,0,0,0.2) 27%,
    rgb(0,0,0) 37%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0.95) 60%, rgba(0,0,0,0) 70%,
    rgba(0,0,0,0.8) 84%, rgba(0,0,0,0) 95%);
  mask: linear-gradient(90deg,
    rgba(0,0,0,0) 0%, rgba(0,0,0,0.85) 9%, rgba(0,0,0,0) 18%, rgba(0,0,0,0.2) 27%,
    rgb(0,0,0) 37%, rgba(0,0,0,0.15) 50%, rgba(0,0,0,0.95) 60%, rgba(0,0,0,0) 70%,
    rgba(0,0,0,0.8) 84%, rgba(0,0,0,0) 95%);
}

/* .case goes `overflow: visible` below 900px (it has to — the panel unpins and
   stacks), and a skewed layer in a section that no longer clips would run across
   the page. The field is dropped there; the flat navy is what shows. */
@media (max-width: 900px) {
  .case::before, .case::after { display: none; }
}

/* ---- 1. HERO ------------------------------------------------------------- */
.hero {
  position: relative;
  height: 100vh;
  height: 100svh;
  overflow: hidden;
  background: #05091f;
}
/* THE ANIMATION'S WINDOW (Ben, 2026-08-12): top edge at the header's bottom —
   the header keeps its navy fill and the scene starts under it, not behind it —
   bottom edge at the hero's own bottom. The canvas inside is the FULL 3:2 plate
   frame, cover-fitted and BOTTOM-ANCHORED, so the plate's bottom edge — the
   waterline the nav anchors sit on — is always on screen and every rung is in
   frame on first load. What exceeds the window is clipped here: sky at the top
   on wide screens, the sides on tall ones. Before this the camera cropped the
   plate SYMMETRICALLY to the viewport's aspect (36.6° of its 38.9° at 1440x900),
   which cut the waterline and parked Testimonials 4px off the fold. */
.hero__canvas {
  position: absolute;
  top: var(--header-h, 88px); left: 0; right: 0; bottom: 0;
  overflow: hidden;
  /* same clock as .site-header's hide/reveal, so the two edges move as one */
  transition: top 0.25s var(--ease-out);
}
/* THE WINDOW'S TOP EDGE IS LOCKED TO THE HEADER'S BOTTOM EDGE (Ben,
   2026-08-12). navbar.js hides the bar on scroll-down with `.is-hidden`; when
   the bar slides off, the window opens to the top of the screen on the same
   0.25s clock, and closes back under it when the bar returns. The canvas
   inside is cover-fitted to the FULL hero box (heroFit, mount.js), so the
   opening window only ever reveals more of the plate — never a gap. */
.site-header.is-hidden ~ main .hero__canvas { top: 0; }
/* The prototype's own stylesheet pins the canvas `position: fixed`. Inside a
   100vh section it has to be absolute or the page scrolls and the dock does not.
   Width/height are INLINE — heroFit() in js/hero/mount.js drives renderer
   .setSize to the plate frame's cover dims — so only the anchoring lives here. */
.hero__canvas canvas {
  position: absolute !important;
  bottom: 0; left: 50%;
  transform: translateX(-50%);
}
/* TOP-LEFT, not bottom (Ben, 2026-08-10). The copy has to clear the fixed
   header, so the offset is derived from the header's own height rather than a
   round number that would collide the moment the header changes. */
.hero__copy {
  position: absolute; left: 0; right: 0; top: 0; z-index: 2;
  padding-top: calc(var(--header-h, 92px) + clamp(20px, 4vh, 56px));
  pointer-events: none;
}
.hero__copy .wrap > * { pointer-events: auto; }
.hero .h-page { color: #f4f7fb; max-width: 15ch; text-wrap: balance; }
/* The caps live in CSS, not in the markup: the h1 stays a real sentence for
   screen readers and the document outline, and only the rendering shouts.
   Caps also set tighter than mixed case, so the tracking opens a hair. */
.hero__title { text-transform: uppercase; letter-spacing: 0.01em; }
.hero__eyebrow { color: #7fd6f4; margin-bottom: 18px; }
.hero__actions { display: flex; flex-wrap: wrap; gap: 12px; margin-top: 28px; }
.hero__actions .btn { color: #eef2f7; border-color: rgba(255,255,255,0.28); }
.hero__hint {
  margin-top: 22px; font-size: 13px; letter-spacing: 0.06em;
  color: rgba(238, 242, 247, 0.62);
}

/* SHORT VIEWPORTS: give the ladder its band back.
   Placed AFTER the base .hero__* rules on purpose — same specificity, so source
   order decides, and sitting above them these overrides did nothing at all.
   The hero copy is sized off WIDTH only (clamp(2.5rem, 6vw, 5rem)), so on a
   short-but-wide window it keeps its full height while the hero loses it —
   measured at 1100x620 the copy ran y 105-438, i.e. 71% of the hero, and the
   Services rung landed at y 368 INSIDE the CTA row at 347-396. At the ladder's
   dim 0.2 that reads as the label having vanished rather than as a collision.
   Height has to be part of the type scale, not just width. */
@media (max-height: 780px) {
  .hero .h-page { font-size: clamp(1.9rem, 4.2vw, 3.1rem); }
  .hero__eyebrow { margin-bottom: 10px; font-size: 11px; }
  .hero__actions { margin-top: 18px; gap: 10px; }
  .hero__actions .btn { padding: 11px 18px; font-size: 12px; }
  .hero__hint { margin-top: 12px; font-size: 12px; }
  .hero__copy { padding-top: calc(var(--header-h) + clamp(12px, 2.5vh, 28px)); }
}
@media (max-height: 640px) {
  .hero .h-page { font-size: clamp(1.7rem, 3.6vw, 2.5rem); }
  .hero__hint { display: none; }   /* the ladder itself now says "explore the dock" */
}

#err {
  position: fixed; inset: auto 12px 12px 12px; z-index: 90; color: #fca5a5;
  font: 12px/1.5 ui-monospace, Menlo, monospace; background: rgba(40, 8, 8, .9);
  padding: 10px 13px; border-radius: 8px; display: none; white-space: pre-wrap;
}

/* ============================================================================
   PAGE-WATER — the hero's water is the background for the whole homepage.
   (Ben, 2026-08-12: "keep the same camera angle on the water ... as the
   background for the entire home page. Then the next sections text and contents
   should scroll up into this exact view using the same scroll animations that
   they currently have.")

   HOW: the canvas host goes `position: fixed`, so once the fly-up has settled
   the water simply stays there while the document scrolls over it. Nothing about
   the scroll animations changes — every section keeps the reveal, split, clip and
   ladder motion it already had. They are just no longer scrolling over their own
   opaque grounds.

   `position: fixed` is NOT clipped by .hero's `overflow: hidden`, because a fixed
   element's containing block is the viewport unless an ancestor carries a
   transform / filter / perspective / will-change. .hero carries none of those —
   checked, not assumed, because if one is ever added the water will vanish
   everywhere below the hero and the cause will not be obvious.

   THE CHECKERBOARD IS GONE ON THIS PAGE, and that is a real loss worth naming
   rather than discovering later. The light/dark alternation was a deliberate
   decision taken from the for-living study. One continuous water ground cannot
   have it: a `.theme-light` section over dark water would be near-black ink on
   it. So on this page only, `.theme-light` re-declares to the SAME light-on-dark
   palette as the rest, which is what keeps all five of those sections readable.
   The other 22 pages still checkerboard normally.
   ========================================================================== */

/* The water, for the whole page rather than just the hero. */
.page-water .hero__canvas { position: fixed; }

/* AND THE TINT WITH IT. The dark cast over the water is .hero__scrim, which
   service-reveal ramps to 0.8 across the first viewport and then HOLDS — its
   `p` is clamped, so once the hero has handed over the value never comes back
   down. But the scrim was `position: absolute` inside the sticky hero, so it
   died the moment .svc-stage ended and the raw, much brighter water showed from
   there down. Fixed, it carries the same tint the whole way, and the sections
   below scroll over one continuous dark ground. */
.page-water .hero__scrim { position: fixed; inset: 0; }

/* NO GRADIENT FIELD ANYWHERE ON THIS PAGE (Ben, 2026-08-12: "I don't want any of
   the navy gradient backgrounds to be there at all in the home page"). The water
   is the background; the field would only ever sit behind it unseen, and where
   it did show it competed with it.

   THE `:not()` CHAIN IS REPEATED HERE ON PURPOSE. The field's own selector is
   `.section:not(.theme-light):not(.page-hero):not(.svc-hero)`, which scores
   0,4,0 — a plain `.page-water .section` is 0,2,0 and LOSES to it. Measured, not
   guessed: with the short selector the sections kept painting rgb(5,9,31) and
   the water never showed below the hero. Matching the chain and adding the body
   class takes it to 0,5,0, which wins. */
.page-water .section,
.page-water .section:not(.theme-light):not(.page-hero):not(.svc-hero),
.page-water .hero {
  background-color: transparent;
  background-image: none;
}
.page-water .section::before,
.page-water .section::after,
.page-water .section:not(.theme-light):not(.page-hero):not(.svc-hero)::before,
.page-water .section:not(.theme-light):not(.page-hero):not(.svc-hero)::after,
.page-water .hero::before,
.page-water .hero::after { display: none; }

/* THE SAND GROUND THE WATER CROSSFADES INTO (Ben, 2026-08-12: sand behind
   Contact and Find Us, navy gradient behind the footer, "the entire water
   background should fade into the sand background as the user scrolls").
   Fixed, so the crossfade is one whole-screen dissolve rather than a section
   edge sliding up over the water. Opacity is driven by js/hero/hero-flyup.js
   from the Contact section's own scroll position. z-index 1 puts it over the
   scrim (also 1, but earlier in the DOM) and under .hero__copy (2). */
.page-sand {
  position: fixed;
  inset: 0;
  z-index: 1;
  opacity: 0;
  pointer-events: none;
  background: #efe9dd;              /* --bg of .theme-light, the site's sand */
}

/* THE FOOTER CARRIES THE NAVY GRADIENT ON EVERY PAGE (Ben, 2026-08-12: "the
   footer should be the dark navy gradient on every single page"). Deliberately
   NOT scoped to .page-water — this is now the one ground that is constant
   site-wide, so all 23 pages close the same way.
   On the homepage it does a second job: being opaque, it covers the fixed sand
   layer as that scrolls up, so the page ends on the gradient rather than sand. */
.site-footer {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  background-color: var(--bg);
  background-image:
    radial-gradient(circle at 1px 1px,
      rgba(238, 242, 247, calc(0.05 * var(--fx))) 1px, transparent 0),
    radial-gradient(120% 105% at 0% 0%,
      rgba(56, 78, 140, calc(0.46 * var(--fx))) 0%,
      rgba(20, 30, 70,  calc(0.24 * var(--fx))) 45%,
      rgba(5, 9, 31, 0) 100%);
  background-size: 22px 22px, auto;
}

/* .theme-light stops meaning "light ground" on this page and becomes "the same
   palette as everything else", so its text survives over water — EXCEPT on
   `.on-sand`, where the fixed sand layer IS the ground and the copy needs its
   real dark ink. Those sections stay background-transparent like every other
   one; that is what lets the dissolve happen behind them rather than at their
   top edge. */
.page-water .theme-light.on-sand { --bg: transparent; }

.page-water .theme-light:not(.on-sand) {
  --bg:            transparent;
  --bg-alt:        rgba(238, 242, 247, 0.06);
  --fg:            #eef2f7;
  --muted:         #9fb0c4;
  --line:          rgba(255, 255, 255, 0.16);
  --line-soft:     rgba(255, 255, 255, 0.08);
  --accent:        #0db1e2;
  --accent-ink:    #05091f;
  --accent-soft:   rgba(13, 177, 226, 0.18);
  --rope:          #eef2f7;
  --scrim:         rgba(5, 9, 31, 0.62);
}

/* NO PANEL SCRIMS. Ben: "I only want the text, cards, pictures, or video
   contents from the sections below to come up onto this dark tinted water
   background." An earlier pass gave the FAQ and the contact form their own
   frosted grounds, which put opaque slabs back over the water and defeated the
   point. The scrim above already darkens the ground enough to read on, so the
   copy sits straight on the water.

   The ONE exception is the services grid, and it is a mechanism, not a taste
   call: that grid paints its hairlines as its OWN background showing through 1px
   gaps between opaque cards. Make the cards transparent and the trick inverts —
   the full grid background washes across every card instead of ruling between
   them. The cards keep just enough ground to restore the gaps as hairlines. */
.page-water .service-card { background-color: rgba(5, 9, 31, 0.35); }
.page-water .service-card:hover { background-color: rgba(10, 16, 48, 0.55); }

/* ---- 2. SERVICES --------------------------------------------------------- */
.services-grid { display: grid; gap: 1px; background: var(--line); margin-top: 48px; }
@media (min-width: 640px) { .services-grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 1024px) { .services-grid { grid-template-columns: repeat(3, 1fr); } }

/* ---- HERO -> CARDS, with the dead band taken out ----------------------------
   A page hero and the section under it each carry 120px of their own padding,
   and the grid adds another 48px of margin. On /services that stacked to 288px
   of nothing between the lede and the first card — measured — which read as the
   cards belonging to something further down the page rather than to the
   sentence that introduces them.

   Scoped to a pair of explicit classes rather than a general `.page-hero +
   .section` rule: every other page opens its next section with a rule, an
   eyebrow and a heading, and that block WANTS the air. Only /services goes
   straight from the lede into cards. */
.page-hero--tight { padding-bottom: 28px; }
.section--tight-top { padding-top: 56px; }
.section--tight-top .services-grid { margin-top: 0; }

.service-card {
  background: var(--bg); padding: 28px 24px 32px; display: flex; flex-direction: column;
  transition: background-color var(--state-ms) var(--ease-out);
  position: relative;                  /* the stretched link resolves against this */
}
.service-card:hover { background: var(--bg-alt); }
/* THE WHOLE CARD IS THE LINK. One real <a> with an overlay, rather than an <a>
   wrapping the card or a JS click handler: nesting would need the markup rebuilt
   on 13 cards, and a click handler silently loses middle-click, cmd-click, "open
   in new tab" and the status-bar URL preview. Each card carries exactly one link
   (verified across services/ and project-gallery/), so the overlay cannot bury a
   second control. */
.service-card__more::after {
  content: ''; position: absolute; inset: 0; z-index: 1;
}
/* HEIGHT COMES FROM THE VIEWPORT, NOT FROM AN ASPECT RATIO. `aspect-ratio: 4/3`
   makes the photo a function of COLUMN WIDTH alone, so a short wide window gets
   a 278px-tall photo it has no room for and the card runs off the bottom. A vh
   clamp lets the card give height back as the window loses it. See the same
   change on .about-card__media and .svc-card. */
.service-card__media {
  border-radius: var(--radius); overflow: hidden;
  height: clamp(120px, 19vh, 280px);
  margin-bottom: clamp(12px, 2vh, 20px);
}
.service-card__media img { width: 100%; height: 100%; object-fit: cover; transition: transform 0.7s var(--ease-out); }
.service-card:hover .service-card__media img { transform: scale(1.05); }
.service-card__num {
  font-family: var(--font-display); font-size: 12px; letter-spacing: 0.18em;
  color: var(--accent); margin-bottom: 10px;
}
.service-card h3 { margin-bottom: 10px; }
.service-card p { color: var(--muted); font-size: 16px; }
/* The number is also a <p>, so `.service-card p` outranked `.service-card__num`
   and quietly painted it --muted instead of --accent. */
.service-card p.service-card__num { color: var(--accent); font-size: 12px; }
.service-card__more {
  margin-top: auto; padding-top: 20px; font-family: var(--font-display);
  font-size: 12px; text-transform: uppercase; letter-spacing: 0.14em;
  opacity: 0.2; transition: opacity var(--state-ms) var(--ease-out);
}
.service-card:hover .service-card__more, .service-card:focus-within .service-card__more { opacity: 1; }

/* ---- 3. WHAT WE DO ------------------------------------------------------- */
.whatwedo { display: grid; gap: 40px; align-items: center; }
@media (min-width: 1024px) { .whatwedo { grid-template-columns: 1fr 1.15fr; gap: 64px; } }
.whatwedo .clip { aspect-ratio: 16 / 10; }

/* ---- 4. GALLERY ---------------------------------------------------------- */
.gallery-head { display: flex; flex-wrap: wrap; gap: 20px; align-items: flex-end; justify-content: space-between; margin-bottom: 40px; }
.gallery-strip { margin-top: 8px; }

/* ---- drift band ---------------------------------------------------------- */
/* The band is deliberately 116% wide so the +/-8% scrub never shows an edge;
   its scope has to clip, or that 16% becomes real page-level scroll. */
[data-drift-scope] { overflow: hidden; }
.drift-band { display: flex; gap: 16px; width: 116%; margin-left: -8%; }
.drift-band img {
  width: 25%; aspect-ratio: 4 / 3; object-fit: cover;
  border-radius: var(--radius); border: var(--stroke) solid var(--line);
}

/* ---- 7. REVIEWS ---------------------------------------------------------- */
.reviews__viewport { overflow: hidden; }
.reviews__strip { display: flex; will-change: transform; }
.review {
  flex: 0 0 auto; padding: 0 4px;
  opacity: 0.2; transition: opacity var(--state-ms) var(--ease-out);
}
.review.is-lit { opacity: 1; }
.review__inner {
  border: var(--stroke) solid var(--line); border-radius: var(--radius);
  padding: 32px; height: 100%;
}
.review blockquote { font-size: clamp(1.0625rem, 1.6vw, 1.375rem); line-height: 1.45; }
.review__by { margin-top: 22px; font-family: var(--font-display); font-size: 13px; letter-spacing: 0.1em; text-transform: uppercase; }
.review__src { color: var(--muted); font-size: 13px; margin-top: 4px; }
.reviews__nav { display: flex; gap: 8px; align-items: center; justify-content: center; margin-top: 28px; }
.reviews__nav button {
  width: 40px; height: 40px; border: var(--stroke) solid var(--line);
  border-radius: var(--radius); display: grid; place-items: center;
  opacity: 0.5; transition: opacity var(--state-ms) var(--ease-out);
}
.reviews__nav button:hover { opacity: 1; }
/* The stepper rope is 14 knots at the measured 65.76 pitch = 855px. That does
   not fit a phone, so the wrap scrolls rather than the page. */
.reviews__stepper-wrap { margin-top: 26px; display: flex; justify-content: flex-start; overflow-x: auto; }
@media (min-width: 960px) { .reviews__stepper-wrap { justify-content: center; overflow-x: visible; } }

/* ---- 8. FAQ -------------------------------------------------------------- */
.faq { border-top: var(--stroke) solid var(--line); margin-top: 40px; }
.faq__item { border-bottom: var(--stroke) solid var(--line); }
.faq__q {
  width: 100%; display: flex; gap: 24px; align-items: baseline; justify-content: space-between;
  padding: 22px 0; text-align: left;
  font-family: var(--font-display); font-size: clamp(1rem, 1.6vw, 1.25rem); font-weight: 500;
  opacity: 0.2; transition: opacity var(--state-ms) var(--ease-out);
}
.faq__item.is-open .faq__q, .faq__q:hover, .faq__q:focus-visible { opacity: 1; }
.faq__sign { flex: 0 0 auto; color: var(--accent); font-size: 20px; line-height: 1; }
.faq__a { overflow: hidden; height: 0; }
.faq__a > div { padding: 0 0 24px; max-width: 76ch; color: var(--muted); }

/* ---- 9. CONTACT ---------------------------------------------------------- */
.contact { display: grid; gap: 48px; }
@media (min-width: 1024px) { .contact { grid-template-columns: 1fr 1.1fr; gap: 80px; } }
.nap { margin-top: 32px; display: grid; gap: 24px; }
.nap h3 {
  font-family: var(--font-display); font-size: 12px; text-transform: uppercase;
  letter-spacing: 0.18em; color: var(--muted); margin-bottom: 6px;
}
.nap a { border-bottom: var(--stroke) solid var(--line); }
.nap a:hover { border-bottom-color: var(--accent); }

/* ---- find us: the location map band --------------------------------------
   Ported from the Spec-Site Engine's src/components/sections/LocationMap.astro
   to this site's own conventions: the frame is a plain `.media` (hairline +
   --radius + clipped) rather than the component's elevated card, because THIS
   PAGE HAS NO BOX-SHADOWS, by design (css/base.css). Action is the site's own
   `.btn`, not a filled accent chip.
   CLS-SAFE, and that is the reason for the fixed aspect box: the iframe loads
   lazily and third-party, so without a reserved box the footer under it would
   jump when the map finally paints. */
.locmap { display: grid; gap: 40px; align-items: center; }
@media (min-width: 1024px) { .locmap { grid-template-columns: 0.85fr 1.15fr; gap: 64px; } }
.locmap__addr { margin-top: 28px; font-size: clamp(1.0625rem, 1.4vw, 1.25rem); line-height: 1.5; }
.locmap__frame { aspect-ratio: 16 / 10; }
@media (max-width: 640px) { .locmap__frame { aspect-ratio: 4 / 3; } }
/* base.css's reset covers img/video/svg; an iframe is none of those, and .media's
   own object-fit rule does not reach it either. */
.locmap__frame iframe { display: block; width: 100%; height: 100%; border: 0; }

/* ---- service page -------------------------------------------------------- */
.svc-hero { padding-top: 168px; }
.svc-body { display: grid; gap: 40px; }
@media (min-width: 1024px) { .svc-body { grid-template-columns: 1fr 1fr; gap: 64px; } }
.svc-grid { display: grid; gap: 16px; grid-template-columns: repeat(2, 1fr); margin-top: 40px; }
@media (min-width: 768px) { .svc-grid { grid-template-columns: repeat(4, 1fr); } }
.svc-grid .clip { aspect-ratio: 4 / 5; }

/* The video that sits in every service page's intro, exactly where their own
   Divi layout puts it. 16/9 because that is what the source file is. */
.svc-video { margin-top: 40px; aspect-ratio: 16 / 9; }
/* The photos are outdoor daylight, so the scrim has to be heavy enough for
   18px body text, not just the heading. Measured against #eef2f7 on the
   lightest of the six (seawall.jpg): 0.72 clears 4.5:1. */

/* ---- interior page hero -------------------------------------------------- */
/* Same clearance as .svc-hero so every interior page starts on the same line. */
.page-hero { padding-top: 168px; }

/* ---- PAGE HERO — brand ground -----------------------------------------------
   about-us, testimonials, faqs and contact-us have no hero photograph, so they
   used to open on flat body colour while every service page opens on an image.
   This gives them the FOOTER's ground instead — the identical two-layer
   gradient plus 22px dot grid — with the footer wordmark sitting behind the
   copy (see `.foot-word--hero`). The two ends of those pages now match.

   The gradient is duplicated from `.site-footer` rather than factored into a
   shared class: the footer's version is load-bearing on the homepage, where
   being opaque is what covers the fixed sand layer as it scrolls up. Tying the
   two together would mean a change for one silently altering the other. */
.page-hero--brand {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  background-color: var(--bg);
  background-image:
    radial-gradient(circle at 1px 1px,
      rgba(238, 242, 247, calc(0.05 * var(--fx))) 1px, transparent 0),
    radial-gradient(120% 105% at 0% 0%,
      rgba(56, 78, 140, calc(0.46 * var(--fx))) 0%,
      rgba(20, 30, 70,  calc(0.24 * var(--fx))) 45%,
      rgba(5, 9, 31, 0) 100%);
  background-size: 22px 22px, auto;
}
/* The wordmark needs room to read behind the copy without crowding it. */
.page-hero--brand { padding-bottom: clamp(72px, 9vw, 140px); }

/* ---- header nav: the Services submenu ------------------------------------ */
/* FLEX, NOT BLOCK, AND THAT IS THE SECOND HALF OF THE ALIGNMENT FIX.
   The button inside is inline-level, so a BLOCK wrapper builds a LINE BOX round
   it and inherits the body font's 1.5 line-height. Measured live: the wrapper
   came out 27px tall against a 19.5px button, 7.5px of pure descender space,
   and the button sat 6px down inside it. .nav centres the wrapper, so the two
   dropdowns rendered 2.25px below the six plain links.
   Flex creates no line box, so the wrapper collapses to the button's own height
   and every item in the bar shares one baseline. */
.nav__group { position: relative; display: flex; align-items: center; }
.nav__group > button { display: inline-flex; align-items: center; gap: 7px; }
.nav__panel {
  position: absolute; top: calc(100% + 14px); left: -20px; min-width: 232px;
  padding: 8px; border-radius: var(--radius);
  background: rgba(5, 9, 31, 0.96);
  -webkit-backdrop-filter: blur(12px); backdrop-filter: blur(12px);
  border: var(--stroke) solid rgba(255, 255, 255, 0.16);
  /* Opacity is the state, per the universal rule; visibility only stops it
     eating clicks while it is dim. */
  opacity: 0; visibility: hidden;
  transition: opacity var(--state-ms) var(--ease-out), visibility 0s linear var(--state-ms);
}
.nav__group:hover .nav__panel,
.nav__group:focus-within .nav__panel {
  opacity: 1; visibility: visible; transition-delay: 0s;
}
.nav__panel a {
  display: block; padding: 9px 12px; border-radius: 6px; letter-spacing: 0.08em;
  text-transform: none; font-size: 13px;
}
.nav__panel a::after { display: none; }
.nav-mobile__sub { padding-left: 16px; }
.nav-mobile__sub a { font-size: 13px; opacity: 0.78; padding: 9px 0; }
.nav-mobile__label {
  display: block; padding: 12px 0 4px; font-family: var(--font-display);
  font-size: 12px; text-transform: uppercase; letter-spacing: 0.18em; color: #9fb0c4;
}

/* ---- about page ---------------------------------------------------------- */
.about-montage { display: grid; gap: 12px; grid-template-columns: repeat(2, 1fr); margin-top: 48px; }
@media (min-width: 768px) { .about-montage { grid-template-columns: repeat(4, 1fr); } }
.about-montage .clip { aspect-ratio: 1 / 1; }
.svc-list { display: grid; gap: 12px; margin-top: 32px; }
.svc-list > li { overflow: hidden; }
.svc-list__item {
  border: var(--stroke) solid var(--line); border-radius: var(--radius);
  padding: 16px 20px; font-family: var(--font-display); font-size: 15px; font-weight: 500;
}
.area-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 28px; }
.area-list li {
  border: var(--stroke) solid var(--line); border-radius: 999px;
  padding: 8px 16px; font-size: 14px; color: var(--muted);
}

/* ---- testimonials page --------------------------------------------------- */
.wall { columns: 1; column-gap: 20px; margin-top: 48px; }
@media (min-width: 700px) { .wall { columns: 2; } }
@media (min-width: 1100px) { .wall { columns: 3; } }
.wall > figure {
  break-inside: avoid; margin: 0 0 20px;
  border: var(--stroke) solid var(--line); border-radius: var(--radius); padding: 28px;
}
.wall blockquote { font-size: 17px; line-height: 1.5; }
.wall figcaption { margin-top: 20px; }
.wall figcaption b {
  display: block; font-family: var(--font-display); font-size: 13px; font-weight: 500;
  letter-spacing: 0.1em; text-transform: uppercase;
}
.wall figcaption span { display: block; color: var(--muted); font-size: 13px; margin-top: 4px; }

/* ---- legal / prose pages ------------------------------------------------- */
.prose { max-width: 76ch; }
.prose > * + * { margin-top: 20px; }
.prose h2 {
  font-family: var(--font-display); font-weight: 500; font-size: 1.25rem;
  letter-spacing: -0.01em; margin-top: 44px;
}
.prose p { color: var(--muted); font-size: 17px; }
.prose ul { margin-top: 12px; }
.prose li { color: var(--muted); font-size: 17px; padding: 6px 0 6px 18px; position: relative; }
.prose li::before { content: ''; position: absolute; left: 0; top: 17px; width: 7px; height: 1px; background: var(--accent); }

/* ---- reduced motion ------------------------------------------------------ */
@media (prefers-reduced-motion: reduce) {
  * { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
  .clip { clip-path: none !important; }
  .clip__inner { transform: none !important; }
  .drift-band { width: 100%; margin-left: 0; }
  .review, .faq__q, .service-card__more { opacity: 1 !important; }
  .svc-list__item { opacity: 1 !important; }
  .rope-marquee { overflow-x: auto; }
}

/* ---- 2b. SERVICE REVEAL — hero holds, cards climb over it ------------------
   Reconstructed from for-living.it (study: for-living-study.md). The pin is CSS
   sticky rather than ScrollTrigger `pin: true` because the hero's WebGL canvas
   and hero-nav rungs position against `.hero`'s own box; a pin-spacer would
   move it. js/motion/service-reveal.js drives the values.

   Stage height = (1 + --svc-sets) viewports, so the pin lasts exactly one
   viewport per set of three. */
.svc-stage {
  position: relative;
  --svc-sets: 2;
  /* The pin's -100svh margin cancels its own height, so without this the stage
     collapses to just the hero and the sticky releases almost immediately.
     One viewport for the hero, plus one per set of three. */
  min-height: calc((1 + var(--svc-sets)) * 100svh);
}
.svc-stage > .hero { position: sticky; top: 0; z-index: 0; }
/* NOT on .svc-stage, and that is not a style preference. The fx field carries
   `overflow: hidden` (its skewed streaks would spill without it), and an
   overflow container becomes the scrollport for any `position: sticky`
   descendant — putting the field on the stage would have un-pinned the hero.
   It goes on .hero, which already clips and is the box the canvas lives in. */

/* The scrim sits OVER the canvas but UNDER .hero__copy (z-index 2), so the copy
   can fade on its own faster clock. The hero's own opacity is never touched. */
.hero__scrim {
  position: absolute; inset: 0; z-index: 1;
  background: #000; opacity: 0; pointer-events: none;
}

.svc-stage__pin {
  position: sticky; top: 0; z-index: 2;
  height: 100svh;
  margin-top: -100svh;          /* ride up over the pinned hero — no spacer */
  pointer-events: none;
  overflow: hidden;
}
.svc-stage__track { position: relative; height: 100%; }

.svc-set {
  position: absolute; inset: 0;
  display: flex; align-items: flex-start; justify-content: center;
  gap: clamp(16px, 2.2vw, 32px);
  padding: 0 clamp(16px, 4vw, 48px);
  will-change: transform;
}
.svc-card {
  pointer-events: auto;
  /* Sized off BOTH axes. The media below is a square, so the card's WIDTH sets
     its height — a 25vw basis on a short wide window produced a card taller than
     the space it sits in. min() lets whichever axis is scarcer win, which keeps
     the square square instead of distorting it to fit. */
  flex: 0 1 clamp(180px, min(25vw, 34vh), 405px);
  text-decoration: none; color: #eef2f7;
  will-change: transform;
}
.svc-card__media {
  position: relative; aspect-ratio: 1 / 1;
  border-radius: var(--radius); overflow: hidden;
  background: var(--bg-alt);
  border: var(--stroke) solid var(--line);
}
.svc-card__media img {
  width: 100%; height: 100%; object-fit: cover; display: block;
  transition: transform 0.8s var(--ease-out);      /* the source's slow 800ms */
}
.svc-card:hover .svc-card__media img { transform: scale(1.06); }
.svc-card__pill {
  position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%);
  background: rgba(5, 9, 31, 0.55); backdrop-filter: blur(2px);
  border: var(--stroke) solid rgba(255, 255, 255, 0.22);
  font-family: var(--font-display); font-size: 11px; letter-spacing: 0.16em;
  text-transform: uppercase; padding: 7px 13px; border-radius: var(--radius);
  white-space: nowrap;
}
/* Title + link — the reference carries neither (its cards are not even links).
   Ben's addition, because the six detail pages exist and should be reachable. */
.svc-card__foot {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 12px; padding: 14px 2px 0;
}
.svc-card__title {
  font-family: var(--font-display); font-size: clamp(15px, 1.25vw, 20px);
  font-weight: 500; letter-spacing: 0.01em; margin: 0; text-transform: uppercase;
}
.svc-card__link {
  font-family: var(--font-display); font-size: 11px; letter-spacing: 0.14em;
  text-transform: uppercase; color: #7fd6f4;
  opacity: 0.55; transition: opacity var(--state-ms) var(--ease-out);
  white-space: nowrap;
}
.svc-card:hover .svc-card__link { opacity: 1; }

@media (max-width: 900px) {
  /* No pin on small screens — the sets become an ordinary stacked grid. */
  .svc-stage { --svc-sets: 0; }
  .svc-stage > .hero { position: relative; }
  .svc-stage__pin {
    position: relative; height: auto; margin-top: 0; overflow: visible;
    pointer-events: auto;
  }
  .svc-set {
    position: relative; inset: auto; display: grid;
    grid-template-columns: repeat(2, 1fr); gap: 20px;
    transform: none !important; visibility: visible !important;
    padding: 0 0 20px;
  }
  .hero__scrim { display: none; }
}
.reduced-motion .svc-stage { --svc-sets: 0; }
.reduced-motion .svc-stage > .hero { position: relative; }
.reduced-motion .svc-stage__pin { position: relative; height: auto; margin-top: 0; pointer-events: auto; }
.reduced-motion .svc-set { position: relative; inset: auto; transform: none !important; }

/* ---- 5b. LADDER — process rungs stepping across ----------------------------
   for-living.it's waterfall: positional indent + one-shot staggered reveal.
   The 140px measured stagger falls out of the rungs' own spacing. */
.ladder { display: grid; gap: 0; margin-top: 48px; list-style: none; }
.ladder .rung {
  display: flex; align-items: baseline;
  gap: clamp(12px, 1.6vw, 28px);
  padding: 22px 0;
  border-top: var(--stroke) solid var(--line-soft);
}
.ladder .rung:first-child { border-top: 0; }
/* Indent comes from the rung's POSITION IN THE LIST, not a hand-typed data-pos.
   The attribute version broke the moment a sixth rung was added: it was written
   data-pos="5" and sat at the same indent as the fifth, so the ladder stopped
   stepping and the last rung read as unfinished. nth-child cannot drift. */
.ladder .rung:nth-child(1)  { margin-left: 0; }
.ladder .rung:nth-child(2)  { margin-left: calc(var(--ladder-step) * 1); }
.ladder .rung:nth-child(3)  { margin-left: calc(var(--ladder-step) * 2); }
.ladder .rung:nth-child(4)  { margin-left: calc(var(--ladder-step) * 3); }
.ladder .rung:nth-child(5)  { margin-left: calc(var(--ladder-step) * 4); }
.ladder .rung:nth-child(6)  { margin-left: calc(var(--ladder-step) * 5); }
.ladder .rung:nth-child(7)  { margin-left: calc(var(--ladder-step) * 6); }
.ladder .rung:nth-child(8)  { margin-left: calc(var(--ladder-step) * 7); }
.ladder .rung:nth-child(9)  { margin-left: calc(var(--ladder-step) * 8); }
.ladder .rung:nth-child(10) { margin-left: calc(var(--ladder-step) * 9); }
.ladder { --ladder-step: clamp(14px, 3.4vw, 78px); }
.ladder .rung__n {
  font-family: var(--font-display); font-size: 12px; letter-spacing: 0.18em;
  color: var(--accent); flex: 0 0 auto; padding-top: 4px;
}
.ladder .rung__body { min-width: 0; }
.ladder .rung h3 { margin-bottom: 8px; }
.ladder .rung p { color: var(--muted); font-size: 16px; max-width: 52ch; }
@media (max-width: 700px) {
  .ladder { --ladder-step: 10px; }
}

/* 16px at opacity .6, beside the arrow — both measured off the source. */

/* The services page's own list/item styles used to live here. They were dropped
   when that page became a six-card grid, but the block stayed and its
   `.svc-list { gap: 72px }` silently overrode the about-us list defined far
   above it (gap: 12px), because it came later in the file. Same class name, two
   different components, one of them dead. Deleted. */


/* ---- PROCESS PAGE — spotlight steps + cursor-following previews -------------
   Spotlight: js/motion/spotlight.js rests every row at 0.2 and lifts the one in
   the focal band to 1.0. Same 0.2 -> 1.0 state rule the rest of the site uses. */
.spot-list { margin-top: 56px; list-style: none; }
/* TWO COLUMNS, like the source: the left half (number + name) never moves and
   the right half (the note) flies in from off-screen. Grid rather than flex so
   the two halves stay aligned down the whole list however a name wraps. */
.spot {
  display: grid; grid-template-columns: 1fr 1fr;
  align-items: center; gap: clamp(24px, 4vw, 72px);
  padding: 30px 0;
  border-top: var(--stroke) solid var(--line-soft);
  opacity: 0.2;                 /* JS overwrites this every frame */
  will-change: opacity;
}
.spot:first-child { border-top: 0; }
/* left half — ruller and name share one line */
.spot__lead { display: flex; align-items: center; gap: clamp(18px, 2.4vw, 44px); min-width: 0; }
/* the "ruller" — numeral over a tick scale, the source's measurement motif */
.spot__ruller { flex: 0 0 136px; position: relative; height: 58px; display: flex; align-items: center; }
.spot__n { font-family: var(--font-display); font-size: clamp(26px, 3vw, 44px); line-height: 1; }
.spot__ticks {
  position: absolute; left: 0; right: 0; bottom: 0; height: 10px; opacity: 0.35;
  background: repeating-linear-gradient(to right, currentColor 0 1px, transparent 1px 17px);
}
.spot__name { font-family: var(--font-display); font-size: clamp(19px, 2.2vw, 34px); line-height: 1.15; margin: 0; }
/* right half — the flying one */
.spot__note {
  color: var(--muted); font-size: 16px; max-width: 52ch; margin: 0;
  will-change: transform, opacity;
}
/* The notes park one full screen right. Without this the page is horizontally
   scrollable for the frame before GSAP sets them, and on any row that has not
   been reached yet. `clip` rather than `hidden` — `hidden` would make this list
   a scrollport and break any sticky/pinned ancestor, which is exactly the trap
   that cost us #process last session. */
.spot-list { overflow-x: clip; }
@media (max-width: 860px) {
  /* One column: a 52ch note cannot share a phone row with a 44px numeral, and
     the fly-in has nowhere to fly from. */
  .spot { grid-template-columns: 1fr; gap: 14px; }
  .spot__lead { gap: 16px; }
  .spot__ruller { flex-basis: 78px; }
}

/* Service rows with a cursor-following preview. The tonal break is the point:
   this band inverts to the light theme. On a dark ground it loses its impact.
   Driven by js/motion/cursor-preview.js, ported from the Oak & Umber demo. */
.ap__rows { border-top: var(--stroke) solid var(--line); margin-top: 48px; }
.ap__row {
  display: flex; align-items: center;
  min-height: 219px;
  border-bottom: var(--stroke) solid var(--line);
  text-decoration: none; color: inherit;
  opacity: 0.4;                            /* measured rest */
  transition: opacity 0.35s var(--ease-out), padding-left 0.4s var(--ease-out);
}
.ap__row:hover, .ap__row:focus-visible { opacity: 1; }
/* the row steps aside slightly under the cursor — Oak & Umber's `.svc-row:hover
   { padding-left: 18px }`. It is what makes the row feel picked up. */
.ap__row:hover { padding-left: 18px; }
.ap__inner {
  width: 100%; display: grid; grid-template-columns: 1fr 1fr;
  gap: 40px; padding: 30px 0;
}
/* LEFT-ALIGNED. The old layout parked the text in column 2 to leave column 1
   free for the row-anchored image. The image follows the cursor now, so that
   reservation is dead space — the text takes the left column. */
.ap__text { grid-column: 1; }
.ap__title { font-family: var(--font-display); font-size: clamp(22px, 2.4vw, 36px); line-height: 1.1; margin: 0 0 10px; }
.ap__desc { color: var(--muted); font-size: 16px; max-width: 44ch; }
.ap__more {
  display: inline-block; margin-top: 14px; font-family: var(--font-display);
  font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--accent);
}
/* fixed + pointer-events:none — can never steal the hover from the row beneath */
.ap__layer { position: fixed; inset: 0; pointer-events: none; z-index: 55; }
.ap__preview {
  position: absolute; top: 0; left: 0;
  width: min(24vw, 340px); aspect-ratio: 3 / 3.6;   /* PORTRAIT, per Oak & Umber */
  border-radius: var(--radius); overflow: hidden;
  border: var(--stroke) solid var(--line);
  opacity: 0; transition: opacity 0.35s var(--ease-out);
  will-change: transform, opacity;
}
.ap__preview.is-on { opacity: 1; }
/* every image stacked, cross-faded by class — swapping one src flashes */
.ap__preview img {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover; display: block;
  opacity: 0; transition: opacity 0.4s var(--ease-out);
}
.ap__preview img.is-shown { opacity: 1; }
@media (max-width: 900px) {
  .ap__inner { grid-template-columns: 1fr; gap: 18px; }
  .ap__row { min-height: 0; opacity: 1; }
  .ap__row:hover { padding-left: 0; }
}
/* No pointer, no preview — the JS bails too, this is the belt to its braces. */
@media (hover: none) { .ap__layer { display: none; } }

/* ---- NEXT STEP — a closing CTA that shares its neighbour's background --------
   Used at the foot of testimonials/, inside the reviews wrap rather than as its
   own <section>, so the page does not open a new tonal band immediately before
   the footer. The rule and the top margin do the separating instead. */
.next-step {
  margin-top: clamp(56px, 7vw, 104px);
  padding-top: clamp(40px, 5vw, 64px);
  border-top: var(--stroke) solid var(--line);
}

/* ---- CASE SLIDE — service page content column + next-service panel ----------
   The column scrolls normally, then ScrollTrigger pins the section and the WHOLE
   column translates left at 2x while the next panel comes in at 1x (measured on
   for-living.it). js/motion/case-slide.js drives it.

   Column width mirrors the source: ~60% of the viewport, CENTRED. */
.case { position: relative; overflow: hidden; padding: 8vh 0 10vh; }
/* Centred while the page scrolls normally; only the pin pushes it left.
   I first read the source as left-anchored with an open right margin — wrong.
   Its 1280px column sits at left 435 in a 2149px viewport, so the right margin
   is 434. Equal margins: it is centred, and the leftward move is entirely the
   pin's doing. */
.case__col {
  width: min(1280px, 60vw);
  margin: 0 auto;
  display: flex; flex-direction: column;
  gap: clamp(48px, 7vw, 120px);          /* source gap: 120px */
  will-change: transform;
}

/* beats ------------------------------------------------------------------- */
.case-row { display: flex; flex-wrap: wrap; gap: 32px; align-items: flex-start; }
.case-fig { margin: 0; border-radius: var(--radius); overflow: hidden; background: var(--bg-alt); }
.case-fig img { width: 100%; height: 100%; object-fit: cover; display: block; }
.case-fig--full { width: 100%; aspect-ratio: 3 / 2; }
.case-fig--wide { width: 100%; aspect-ratio: 1.85 / 1; }
.case-fig--half { width: calc(50% - 16px); aspect-ratio: 3 / 2; }
.case-fig--tall { width: calc(50% - 16px); aspect-ratio: 2 / 3; }

/* Text is never full column width — always the half, as on the source. That is
   what stops the column reading as a document. */
.case-copy { width: min(640px, 100%); }
.case-copy .eyebrow { margin-bottom: 14px; }
.case-copy h2 { margin-bottom: 16px; }
.case-copy p { color: var(--muted); font-size: 17px; }
.case-copy p + p { margin-top: 14px; }

/* Ben: the big pictures belong BESIDE the text as it moves, not dumped at the
   bottom of a wall of copy. Split beat = copy left, picture right, same row. */
.case-split { display: flex; gap: 32px; align-items: center; }
.case-split .case-copy { flex: 1 1 0; min-width: 0; }
.case-split .case-fig { flex: 1 1 0; align-self: stretch; aspect-ratio: 4 / 5; }

/* the incoming panel ------------------------------------------------------- */
/* Anchored to the section's LAST viewport, not its middle. ScrollTrigger pins
   the section with its bottom at the viewport bottom, so `bottom: 50svh` centres
   the panel in the window the user actually sees during the pin. Anchored at
   `top: 50%` of a ~5000px section it sat halfway down the page — visible long
   before the handoff, and never reachable to click.
   The transform is COMPOSED here: case-slide.js writes only --slide-x, so the
   horizontal tween cannot clobber the vertical centring. */
.case__next {
  position: absolute; right: clamp(16px, 4vw, 56px); bottom: 50svh;
  transform: translate3d(var(--slide-x, 0px), 50%, 0);
  width: min(578px, 34vw);
  display: block; text-decoration: none; color: inherit;
  will-change: transform;
}
.case__nextimg {
  width: 100%; aspect-ratio: 578 / 431; object-fit: cover; display: block;
  border-radius: var(--radius); border: var(--stroke) solid var(--line);
}
.case__nextmeta { display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; margin: 16px 0 10px; font-size: 16px; }
.case__nextmeta .t { font-family: var(--font-display); font-weight: 500; }
.case__nextmeta .d { color: var(--muted); }
.case__nexttag {
  display: inline-block; font-family: var(--font-display); font-size: 11px;
  letter-spacing: 0.14em; text-transform: uppercase;
  background: var(--accent-soft); color: var(--accent);
  padding: 5px 10px; border-radius: var(--radius);
}
.case__nextcue { display: flex; align-items: center; gap: 14px; margin-top: 24px; font-size: 16px; opacity: 0.6; }
.case__nextcue svg { width: 140px; height: 24px; overflow: visible; }
.case__nextcue path, .case__nextcue line { stroke: currentColor; stroke-width: 1.2; fill: none; }
.case__next:hover .case__nextcue { opacity: 1; }
.case__next:hover .case__nextimg { border-color: var(--accent); }

@media (max-width: 900px) {
  .case { overflow: visible; }
  .case__col { width: auto; margin: 0 clamp(16px, 5vw, 40px); transform: none !important; }
  .case-fig--half, .case-fig--tall { width: 100%; }
  .case-split { flex-direction: column; }
  .case-split .case-fig { width: 100%; aspect-ratio: 3 / 2; }
  .case__next {
    position: relative; right: auto; bottom: auto; transform: none !important;
    width: auto; margin: 12vh clamp(16px, 5vw, 40px) 0;
  }
}
.case.is-static .case__col, .case.is-static .case__next { transform: none !important; }

/* The tow line — the rope+anchors strung across the gap while the column leaves
   and the next tile is hauled in. Built and driven entirely by
   js/motion/case-slide.js (see the TOW block there for why it pays out rather
   than being a fixed-length rope); this only parks it and sets its stacking.
   `top` and `transform` are written by JS, so neither is set here. */
.case__tow {
  position: absolute;
  left: 0;
  z-index: 1;                  /* over the section ground, under .case__next (2) */
  pointer-events: none;
  will-change: transform, opacity;
}
.case__next { z-index: 2; }
/* No rope below 900px: the panel goes static and stacks under the column there,
   so there is no horizontal gap to string it across. buildTow() bails at the
   same breakpoint — this is the belt to that braces. */
@media (max-width: 900px) { .case__tow { display: none; } }

/* ---- 6b. ABOUT CARDS — three across, flown in from the right ---------------
   Replaces the .deck (three panels stacked in one grid cell, cross-fading), which
   showed one at a time and made the section read as mostly empty space. All three
   are visible at once now; the motion is entry, not disclosure.
   Movement is StepFlyIn (js/motion/step-fly-in.js): x from innerWidth -> 0,
   scrubbed, ~117px of scroll stagger between cards, so they land one after
   another rather than arriving as a block. */
.about-cards {
  display: grid; gap: 24px; margin-top: 48px;
  align-items: stretch;
}
@media (min-width: 900px) { .about-cards { grid-template-columns: repeat(3, 1fr); gap: 28px; } }
/* Each card is its own overflow context so a card still off to the right cannot
   widen the page while it travels. */
.about-cards > * { overflow: hidden; will-change: transform; }

/* ---- THE CARD SIZES ITSELF TO THE WINDOW ------------------------------------
   This block PINS (card-deal.js), so its height is not a scrolling problem — it
   is a fitting problem. Measured at 1440x900 the assembled block came to 998px
   against a 900px viewport, and because the pin centres it, the overflow split
   in two: the "Building the future" heading was pushed ~49px off the top while
   the cards ran ~49px past the bottom. Exactly what Ben described.

   Every vertical measurement below is therefore a vh clamp, not a fixed px or a
   width-derived aspect ratio. The photo was the single biggest term at 278px —
   `aspect-ratio: 4/3` is a function of column width only, so it had no way to
   know the window was short. */
.about-card {
  height: 100%;
  display: flex; flex-direction: column;
  border: var(--stroke) solid var(--line);
  border-radius: var(--radius);
  background: var(--bg-alt);
  padding: clamp(16px, 2vh, 26px) 24px clamp(16px, 2.2vh, 28px);
  position: relative;                  /* the stretched link resolves against this */
}
.about-card__media {
  border-radius: var(--radius); overflow: hidden;
  height: clamp(96px, 14vh, 280px);
  margin-bottom: clamp(12px, 1.7vh, 20px); background: var(--bg);
}
.about-card__media img { width: 100%; height: 100%; object-fit: cover; display: block; }
.about-card .eyebrow { margin-bottom: clamp(6px, 1vh, 10px); }
.about-card h3 {
  font-family: var(--font-display); font-size: clamp(19px, 1.6vw, 26px);
  line-height: 1.15; margin-bottom: clamp(7px, 1.3vh, 12px);
}
.about-card p { color: var(--muted); font-size: 16px; }
.about-card p + p { margin-top: 12px; }
.about-card__list { margin-top: clamp(9px, 1.4vh, 16px); }
/* Six rows, so every pixel here is multiplied by twelve. The vh coefficient is
   what shrinks on a short window; the max is left alone so a tall one keeps the
   original spacing. */
.about-card__list li {
  padding: clamp(4px, 0.55vh, 7px) 0; font-size: 15px;
  border-bottom: var(--stroke) solid var(--line-soft);
}
.about-card__cta { margin-top: auto; padding-top: clamp(12px, 1.7vh, 22px); }
/* Whole card clickable — same one-real-link overlay as .service-card, and the
   reason is the same. Only the two cards that HAVE a cta get it; the third has
   no destination and must not look like it does. */
.about-card__cta .btn::after {
  content: ''; position: absolute; inset: 0; z-index: 1;
}

/* ---- 6c. ABOUT DEAL — the block pins while cards are dealt in --------------
   js/motion/card-deal.js pins this and gives each card an exclusive slice of the
   pin, so only one is ever in flight. The grid slots are reserved from the start,
   so nothing reflows as cards arrive — they simply fly into space already held
   for them. */
.about-deal { --deal-vh: 0.7; --deal-hold: 0.15; }
/* Set by card-deal.js when the block is still taller than the window after the
   vh clamps have done what they can. Only the CARDS scale — the heading above
   them keeps its size, because it is the part that has to stay readable, and it
   is also what gets lost first when a pinned block overflows.
   transform-origin is top centre so shrinking pulls the bottom up rather than
   opening a gap under the heading. */
.about-cards {
  transform: scale(var(--deal-scale, 1));
  transform-origin: top center;
}
/* Each slot clips its own card so one still parked off to the right cannot widen
   the page or paint over the next section. */
.about-cards > * { overflow: hidden; will-change: transform; }
.about-deal.is-static .about-cards > * { transform: none !important; overflow: visible; }

/* ---- ABOUT-US "Building the future" — pinned deal, one box at a time --------
   Six small boxes in the LEFT column beside a paragraph column. They come from
   their own right edge (--deal-from: 1) rather than the viewport edge, and each
   <li> clips its own box, so a box in flight can never appear over the text to
   the right. --deal-vh is shorter than the home page's because these are small
   boxes and six of them; 0.7vh each would be a 4.2vh pin. */
.svc-deal { --deal-vh: 0.3; --deal-hold: 0.12; --deal-from: 1; }
.svc-deal .svc-list__item { will-change: transform; }
.svc-deal.is-static .svc-list__item { transform: none !important; }

/* ---- PHOTO BELT — always crawling, widens on hover -------------------------
   for-living.it's "WORK THAT SPEAKS FOR US" strip, measured. Driven by
   js/motion/photo-belt.js. The numbers below are the source's, scaled to this
   site's type and radius. */
.belt {
  --slide-w: clamp(210px, 22vw, 331.5px);
  --slide-w-hover: clamp(340px, 37vw, 560px);
  --slide-h: clamp(300px, 32vw, 455px);   /* source: 28.4375rem */
  --expand-ms: 300ms;                     /* transition: all .3s */
  --label-ms: 250ms;                      /* opacity .25s — faster than the width */
  overflow: hidden; position: relative;
}
/* Both rules are rope now, not tick marks. */
.belt::before, .belt::after { display: none; }

/* THE TOP ROPE. Lives on the TRACK, not on the belt, so it travels with the
   photographs and stays continuous across the 32px gaps between them — a rope
   drawn on the belt instead would sit still while the anchors below it moved,
   and the two would read as unrelated. Continuous here: no caption to make room
   for, so no gap. Braid and anchors are the same two layers as the bottom cord,
   with each layer carrying its own opacity (color-mix on the braid) since a
   single element opacity cannot differ per background layer. */
/* width: max-content is load-bearing: the track is a flex container, so at
   `width: auto` it is only as wide as the BELT while its slides overflow. */
.belt__track { position: relative; width: max-content; padding-top: 30px; }

.belt__track { display: flex; gap: 32px; will-change: transform; }

/* THE TOP ROPE lives on the SLIDE, not the track, so it stretches with the slide
   exactly as the bottom cord does — a rope painted on the track would hold its
   length while the photograph under it grew. It reaches half the 32px gutter
   either side (-16px), so each slide's rope meets its neighbour's and the run
   still reads as ONE unbroken rope: it stretches, but it never breaks.
   Everything is centred on one line 18px above the slide (track padding 30, rope
   centre at y 12) — cord, minor anchors and the major anchor all share it, so the
   marks are locked to the rope rather than floating near it. */
.belt__slide::after {
  content: ""; position: absolute; left: -16px; right: -16px; top: -24px; height: 12px;
  pointer-events: none;
  background-image:
    var(--anchor-tile),
    repeating-linear-gradient(to right,
      color-mix(in srgb, var(--rope) 42%, transparent) 0 2.27px,
      transparent 2.27px 3.152px);
  background-repeat: repeat-x, repeat-x;
  background-size: 65.76px 12px, 100% 4px;
  background-position: left center, left center;
}
/* The MAJOR anchor, centred over each photograph and sitting ON that same line. */
.belt__slide::before {
  content: ""; position: absolute; left: 50%; top: -30px;
  width: 23px; height: 24px; transform: translateX(-50%);
  background-image: var(--anchor-major);
  background-size: contain; background-repeat: no-repeat; background-position: center;
  z-index: 2; pointer-events: none;
}
.belt__slide {
  position: relative;
  flex: 0 0 auto; width: var(--slide-w);
  transition: width var(--expand-ms) ease;   /* the neighbours' push animates with it */
  text-decoration: none; color: inherit;
}
.belt__frame {
  display: flex; align-items: center; justify-content: center;
  height: var(--slide-h);
  overflow: hidden;                          /* the window that reveals more photo */
  border-radius: var(--radius);
  background: var(--bg-alt);
  border: var(--stroke) solid var(--line);
}
/* Sized by HEIGHT and allowed to overflow — widening the slide reveals more of
   the same photograph at 1:1. NEVER swap this for transform: scale(). */
.belt__frame img {
  height: 100%; aspect-ratio: 3 / 2; max-width: none; object-fit: cover; display: block;
}

/* THE ROPE. A cord runs the full width of each slide and the caption sits in a
   GAP in it — which is where the big major anchor would otherwise be. The two
   cord runs are flex:1, so when the caption widens from initials to the full
   description the gap opens by exactly that much and the words are never
   crossed by rope. No JS measures anything; the flexbox does it.
   Cord metrics are the site's own: 4px thick, bead period 0.788 x 4 = 3.152px at
   0.72 duty, 42% opacity — the same braid buildRope() strokes. Minor anchors tile
   every KNOT_PITCH (65.76px) from a data URI built in photo-belt.js off the
   traced artwork, so the mark here IS the mark everywhere else. */
.belt__rope {
  display: flex; align-items: center; gap: 10px;
  height: 20px; margin-top: 12px;
}
.belt__cord {
  flex: 1 1 auto; min-width: 0; align-self: stretch; position: relative;
}
/* the braid */
.belt__cord::before {
  content: ""; position: absolute; left: 0; right: 0; top: 50%;
  height: 4px; transform: translateY(-50%);
  background: repeating-linear-gradient(to right,
      var(--rope) 0 2.27px, transparent 2.27px 3.152px);
  opacity: 0.42;
}
/* the minor anchors, one per 65.76px */
.belt__cord::after {
  content: ""; position: absolute; inset: 0;
  background-image: var(--anchor-tile);
  background-repeat: repeat-x;
  background-size: 65.76px 12px;
  background-position: left center;
}

.belt__cap {
  flex: 0 0 auto; height: 20px;
  display: inline-flex; align-items: center;
  opacity: 0.5; transition: opacity var(--label-ms) var(--ease-out);
  font-family: var(--font-display); font-size: 12px; letter-spacing: 0.1em;
  text-transform: uppercase; white-space: nowrap;
}
/* The gap must track the VISIBLE label, not the longest one. Stacking both in a
   grid cell would size the caption to the full description permanently, so the
   rope would sit open even at rest. Instead the hidden label collapses to
   max-width 0 and the shown one opens to its content — the caption's width is
   therefore the visible text's width, and the cords either side give way by
   exactly that much. max-width animates, so the rope parts as the words arrive. */
.belt__cap i {
  font-style: normal; overflow: hidden; max-width: 0; opacity: 0;
  transition: max-width var(--expand-ms) ease, opacity var(--label-ms) var(--ease-out);
}
.belt__cap [data-short] { max-width: 8em; opacity: 1; }

.belt__slide:hover, .belt__slide:focus-visible { width: var(--slide-w-hover); }
.belt__slide:hover .belt__cap, .belt__slide:focus-visible .belt__cap { opacity: 1; }
.belt__slide:hover [data-short], .belt__slide:focus-visible [data-short] { max-width: 0; opacity: 0; }
.belt__slide:hover [data-full], .belt__slide:focus-visible [data-full] { max-width: 22em; opacity: 1; }

/* Reduced motion: no crawl, drive it yourself. */
.belt.is-static { overflow-x: auto; }
.belt.is-static .belt__track { transform: none !important; }

/* ============================================================================
   FOOTER WORDMARK — the cut-off name across the bottom.
   Ported from our own Oak & Umber demo (reticulum-sites/prototypes/
   woodwork-atelier, css/site.css `.foot-word`), which Ben pointed at.

   THE CLIP IS TWO PROPERTIES WORKING TOGETHER, and neither does anything alone:
     `overflow: hidden` on the block, and a NEGATIVE margin-bottom on the
     heading. The negative margin drags the name down past the block's own box;
     the overflow then cuts what hangs below. Remove either and the wordmark just
     sits there, fully visible, which is not the effect.
   The demo uses -0.18em. Its face is a light serif italic with deep descenders;
   Space Grotesk 500 sits higher, so -0.12em clips the same proportion of the
   letterform rather than the same number.

   OUTLINE AT REST, FILL ON ARRIVAL. `-webkit-text-stroke` with a transparent
   fill draws the name as a hairline outline; the `.is-in` class then transitions
   a faint fill up over 1.2s. That is the demo's behaviour, on the demo's clock.
   -webkit-text-stroke is unprefixed nowhere — there is no standard property for
   it — so a browser without it shows a transparent name. The fallback colour
   below is what it falls back TO, and it is why `color` is set before the stroke.
   ========================================================================== */
.foot-word {
  position: relative;
  overflow: hidden;                          /* half of the clip */
  padding: clamp(28px, 4vw, 56px) 0 0;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  gap: clamp(12px, 1.6vw, 28px);
  pointer-events: none;
}
/* The hairline the demo runs above the word, fading out at both ends. */
.foot-word::before {
  content: '';
  position: absolute; top: 0; left: 50%; transform: translateX(-50%);
  width: min(520px, 70%); height: var(--stroke);
  background: linear-gradient(90deg, transparent, var(--accent), transparent);
}
.foot-word__mark {
  width: clamp(38px, 6vw, 96px); height: auto;
  margin-bottom: clamp(10px, 1.6vw, 30px);
  opacity: 0.22;
}
.foot-word__name {
  font-family: var(--font-display);
  font-weight: 500;
  font-size: clamp(58px, 13.5vw, 210px);
  line-height: 0.95;
  letter-spacing: -0.02em;
  white-space: nowrap;
  margin-bottom: -0.12em;                    /* the other half of the clip */
  color: rgba(238, 242, 247, 0.05);
  -webkit-text-stroke: 1px rgba(238, 242, 247, 0.22);
  transition: color 1.2s var(--ease-out);
}
.foot-word__name.is-in { color: rgba(238, 242, 247, 0.10); }

@media (prefers-reduced-motion: reduce) {
  .foot-word__name { transition: none; }
}

/* ---- the same wordmark, behind a page header --------------------------------
   Used on about-us, testimonials, faqs and contact-us, which open on a bare
   ground where the six service pages open on a photograph. Same component as
   the footer's, not a copy of it — so if the mark or the face changes it
   changes in both places.

   `z-index: -1` against the hero's own `isolation: isolate` puts it behind the
   header copy but still above the hero's gradient — the same layering
   `.svc-hero__photo` uses for the service-page photos.

   Bottom-anchored on purpose: the hero's `overflow: hidden` then clips the
   descenders exactly the way the footer's does, so it reads as the same object
   rather than a full wordmark that happens to be faint. */
.foot-word--hero {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  z-index: -1;
  padding-top: 0;
}
/* The accent hairline is doing a job in the footer — separating the wordmark
   from the link columns above it. There is nothing to separate here. */
.foot-word--hero::before { display: none; }
/* a span, not the footer's h2 — this must not enter the heading outline */
.foot-word--hero .foot-word__name { display: block; margin-top: 0; }

/* ============================================================================
   WATER BACKGROUND ON NON-HOMEPAGE PAGES.
   The homepage's water comes from the hero itself and is positioned by
   .page-water above. These three fixed layers are the equivalent for a page that
   has no hero: the water, the tint that darkens it to the ground every one of
   these pages' type was judged against, and the sand it hands over to.
   They sit before <main>, so the page's own content paints over all three.
   ========================================================================== */
.water-bg {
  position: fixed;
  inset: var(--header-h, 88px) 0 0 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  background: var(--bg);          /* the ground before the canvas has loaded */
  /* the header's own clock, so the two edges move as one object */
  transition: top 0.25s var(--ease-out);
}
/* THE TOP EDGE IS LOCKED TO THE HEADER'S BOTTOM EDGE.
   navbar.js hides the bar on scroll-down by moving it to top:-110px. The slot
   was inset by --header-h regardless, so the moment the bar left there was an
   88px strip of bare page ground across the top of every water page — the dark
   band. Opening the slot to 0 on the same 0.25s transition closes it.
   No gap is exposed by opening it: the canvas is sized to the FULL viewport and
   bottom-anchored, so the slot growing upward only ever reveals more water.
   The homepage does the same thing to .hero__canvas; this is the sibling
   selector for pages where the slot sits beside <main> rather than inside it. */
.site-header.is-hidden ~ .water-bg { top: 0; }
.water-bg canvas {
  position: absolute; bottom: 0; left: 50%;
  transform: translateX(-50%);
  display: block;
}
/* Carried over from .hero__scrim at the same 0.8 — see the note in
   js/hero/page-water.js for why this is not a per-page judgement call. */
.water-bg__tint {
  position: fixed; inset: 0; z-index: 0;
  background: #000; opacity: 0.8;
  pointer-events: none;
}
/* If the water cannot mount, the tint would be 80% black over nothing. */
.water-bg-failed .water-bg__tint,
.water-bg-static .water-bg__tint { display: none; }

/* Sections float over the water, exactly as on the homepage. Same `:not()`
   chain, same reason: the field's own selector outranks a short one. */
.has-water .section,
.has-water .section:not(.theme-light):not(.page-hero):not(.svc-hero),
.has-water .case {
  background-color: transparent;
  background-image: none;
}
.has-water .section::before,
.has-water .section::after,
.has-water .section:not(.theme-light):not(.page-hero):not(.svc-hero)::before,
.has-water .section:not(.theme-light):not(.page-hero):not(.svc-hero)::after,
.has-water .case::before,
.has-water .case::after { display: none; }

/* Light sections over water need the dark-ground palette, except where they sit
   on the sand. Same split the homepage makes. */
.has-water .theme-light.on-sand { --bg: transparent; }
.has-water .theme-light:not(.on-sand) {
  --bg:            transparent;
  --bg-alt:        rgba(238, 242, 247, 0.06);
  --fg:            #eef2f7;
  --muted:         #9fb0c4;
  --line:          rgba(255, 255, 255, 0.16);
  --line-soft:     rgba(255, 255, 255, 0.08);
  --accent:        #0db1e2;
  --accent-ink:    #05091f;
  --accent-soft:   rgba(13, 177, 226, 0.18);
  --rope:          #eef2f7;
  --scrim:         rgba(5, 9, 31, 0.62);
}
.has-water .service-card { background-color: rgba(5, 9, 31, 0.35); }
.has-water .service-card:hover { background-color: rgba(10, 16, 48, 0.55); }

/* ---- service page header photograph -------------------------------------
   The page opens on its own photograph and dissolves into the water.
   js/motion/hero-photo.js drives the opacity; everything static is here.
   The scrim is not optional: these are outdoor daylight photographs and the
   header carries an h1 plus a lede at --muted. The existing svc-page note in
   this file measured 0.72 as what clears 4.5:1 against #eef2f7 on the
   lightest of them, so that is the value, not a guess. */
.svc-hero { position: relative; overflow: hidden; isolation: isolate; }
.svc-hero__photo {
  position: absolute; inset: 0; z-index: -1;
  background-position: center; background-size: cover; background-repeat: no-repeat;
  will-change: opacity;
}
.svc-hero__photo::after {
  content: ''; position: absolute; inset: 0;
  background: linear-gradient(180deg,
    rgba(5, 9, 31, 0.62) 0%, rgba(5, 9, 31, 0.72) 55%, rgba(5, 9, 31, 0.88) 100%);
}
/* The header keeps its own dark ground under the photo, so the dissolve lands on
   water rather than flashing the page white at the half-way point. */
.has-water .svc-hero { background-color: transparent; }

/* ============================================================================
   LADDER STAGE — "What we do" and "The process" pin while their heading travels
   up and the rungs arrive one at a time. js/motion/ladder-stage.js drives it;
   only the box lives here.

   --stage-vh IS SET BY JS, not here, because it depends on the rung COUNT and
   the two ladders do not have the same one (six vs five). The fallback in the
   calc() is what a section gets if the module never runs.

   THE SECTION ONLY GROWS ONCE `.is-live` IS ON. If the stage cannot engage —
   no JS, reduced motion, a viewport too small to pin — the section keeps its
   ordinary flow height and every rung is readable. A tall empty pinned box that
   never animates is the worst outcome here, so it is the one made impossible.
   ========================================================================== */
.ladder-stage.is-live {
  min-height: calc(var(--stage-vh, 3) * 100svh);
  padding-top: 0; padding-bottom: 0;
}
.ladder-stage.is-live .ladder-stage__pin {
  position: sticky;
  top: var(--header-h, 88px);
  height: calc(100svh - var(--header-h, 88px));
  display: flex; align-items: center;
  overflow: hidden;              /* rungs enter from below the pin, not the page */
}
/* The heading and the rungs share one column, so the rungs can occupy the space
   the heading vacates rather than starting below it. */
.ladder-stage.is-live .wrap { width: 100%; }
.ladder-stage.is-live .ladder-intro { will-change: transform, opacity; }
.ladder-stage.is-live .ladder { margin-top: 24px; }
/* The pinned composition is tighter than the flowing one. #services assembles to
   1122px against 1111px of room at 1199px tall — 11px over — and a section that
   cannot fit is a section that never animates. 22px -> 16px of rung padding
   returns 72px across six rungs, which clears it with real margin rather than
   squeaking past. */
.ladder-stage.is-live .ladder .rung { padding: 16px 0; }
.ladder-stage.is-live .ladder .rung { will-change: transform, opacity; }

/* ---- MAKING IT FIT, instead of refusing to run -------------------------------
   The height check above used to be pass/fail: too tall, no animation. Measured
   2026-08-12, #services assembles to 1074px and wants 1162px of WINDOW once the
   header is counted. A 1440x900 laptop offers 812. So the ladder was not
   "failing on small windows" — it had never run on an ordinary laptop at all,
   only on a tall external monitor.

   Two stages now, cheapest first.

   1. `.is-compact` tightens the pinned composition. Padding and margins only,
      plus one step down on the body copy — nothing that changes the layout's
      shape. This alone reclaims ~190px on a six-rung ladder.
   2. `--stage-scale` scales whatever is still over. Proportional, so the
      composition holds together instead of being re-tuned by hand at every
      height, and it is applied to .wrap INSIDE the pin so the sticky element
      itself is untouched.

   The scale has a floor (see MIN_SCALE in ladder-stage.js). Below it the type
   really is too small to read and static is the honest outcome. */
.ladder-stage.is-live.is-compact .ladder { margin-top: 10px; }
.ladder-stage.is-live.is-compact .ladder .rung { padding: 9px 0; }
.ladder-stage.is-live.is-compact .ladder .rung h3 { margin-bottom: 3px; }
.ladder-stage.is-live.is-compact .ladder .rung p { font-size: 15px; line-height: 1.45; }
.ladder-stage.is-live.is-compact .ladder-intro .h-section { font-size: clamp(1.7rem, 3.4vw, 2.6rem); }
.ladder-stage.is-live.is-compact .ladder-intro .lede { font-size: 15px; margin-top: 12px; }

/* transform-origin is TOP centre: scaling about the middle would lift the
   heading off the top of the pin and leave a gap that grows as it shrinks. */
.ladder-stage.is-live .wrap {
  transform: scale(var(--stage-scale, 1));
  transform-origin: top center;
}

/* Not engaged: nothing is hidden, nothing is pinned. */
.ladder-stage.is-static .ladder-stage__pin { position: static; height: auto; }

/* A LADDER STAGE MUST NOT BE AN OVERFLOW CONTAINER.
   `position: sticky` resolves against the nearest ancestor with a non-visible
   overflow, so an `overflow: hidden` section becomes the pin's scrollport and
   the pin scrolls away WITH it instead of holding. The background field sets
   exactly that on every dark section, which is why #process (dark) never pinned
   while #services (.theme-light, excluded from the field's selector) did —
   measured: the process pin sat at top -2385 when it should have been at 88.
   The `:not()` chain is repeated here only to out-specify the field rule,
   0,5,0 against its 0,4,0. The rungs are still clipped, by the PIN, which is
   the sticky element itself rather than an ancestor of it. */
.section.ladder-stage:not(.theme-light):not(.page-hero):not(.svc-hero) {
  overflow: visible;
}
