/* P3 STUDIO — site styles. Tokens from p3-studio-DESIGN.md, locked. */

:root {
  --canvas:      #12100E;
  --surface:     #1A1613;
  --surface-hi:  #241E19;
  --cream:       #F2ECE3;
  --cream-muted: #A79C8E;
  --hairline:    rgba(242, 236, 227, 0.12);
  --copper:      #C1793A;
  --copper-lit:  #E8A765;
  --glow:        #FFD9A3;
  --ink:         #2B1F1E;
  --ease:        cubic-bezier(0.25, 0.1, 0.25, 1);
  --radius-card: 18px;
  --radius-pill: 999px;

  --bar-h: 64px;
  /* ---- Stage 15: the rhythm tokens (Andrea: "too much scrolling"; the
     fades "should take about 60" where they took 100, "constant across
     every single page"; and "the vertical room, the headroom ... not the
     margin of the subtitle or the paragraph" — two different things).
     Declared HERE and only here. This stage wires them on /contact/ and
     /portfolio/'s feather; the other pages still carry the literal 44px
     rhythm and the clamp(56px, 10vw, 120px) band and are re-pointed by
     the later stages against §106's clearance budget.
       --fade-depth   the seam's fade band: 60% of clamp(56px,10vw,120px)
                      = 34px on a phone, 72px on desktop, 6vw between
       --headroom     the vertical room BETWEEN sections, per edge: 60% of
                      the 44px rhythm. A banded section's top = fade +
                      headroom, so its first line always clears the band.
       --intra-gap    title -> paragraph WITHIN a section: 1.4rem, the
                      value .h-section already carried — UNTOUCHED, and a
                      separate token so no one collapses the two. */
  --fade-depth: clamp(34px, 6vw, 72px);
  --headroom: 26px;
  --intra-gap: 1.4rem;
  --wavep: 0;
}

* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
html { scroll-behavior: auto; }
body {
  background: var(--canvas);
  color: var(--cream);
  font-family: 'Schibsted Grotesk', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
  font-weight: 400;
  overflow-x: hidden;
}
img { display: block; max-width: 100%; }
a { color: inherit; }

/* Stage 77: "Skip to content". The first focusable element on every public
   page. Off-screen by clipping (never display:none, which would take it out
   of the Tab order) until it takes focus; then it shows top-left with the
   site's own ring, above the bar (90), the portfolio view (200) and the
   mobile glass UI (9000). Its target is the page's <main>, which carries
   tabindex="-1" so the jump moves focus as well as scroll. A pointer never
   reaches it, so mouse and touch see nothing. */
.skip-link {
  position: fixed; z-index: 10000;
  top: max(10px, env(safe-area-inset-top)); left: max(10px, env(safe-area-inset-left));
  padding: 11px 18px;
  background: var(--canvas); color: var(--cream);
  font: 500 0.95rem/1.2 'Schibsted Grotesk', sans-serif;
  text-decoration: none;
  border: 1px solid rgba(242, 236, 227, 0.35); border-radius: var(--radius-pill);
}
.skip-link:not(:focus) {
  width: 1px; height: 1px; padding: 0; border: 0; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap;
}
.skip-link:focus-visible { outline: 2px solid var(--cream); outline-offset: 3px; }
/* the skip target is a landmark, not a control: focus lands on it without
   a ring round the whole page */
main[tabindex="-1"]:focus { outline: none; }

.display, .h-display {
  font-family: 'Instrument Sans', 'Schibsted Grotesk', sans-serif;
  font-size: clamp(2.9rem, 13.5vw, 9rem);
  font-weight: 500;
  line-height: 0.95;
  letter-spacing: -0.03em;
}
.h-section {
  font-family: 'Instrument Sans', 'Schibsted Grotesk', sans-serif;
  font-size: clamp(1.9rem, 7vw, 4rem);
  font-weight: 500;
  line-height: 1.05;
  letter-spacing: -0.02em;
}
.body-copy {
  font-size: clamp(1rem, 4.2vw, 1.15rem);
  line-height: 1.6;
  max-width: 62ch;
}
.label {
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
}
.small-print {
  font-size: 0.7rem;
  line-height: 1.55;
}

/* ---- underglow, the signature ---- */
.underglow { position: relative; }
.underglow::after {
  content: '';
  position: absolute;
  left: 8%; right: 8%; bottom: -14px; height: 42px;
  background: radial-gradient(ellipse at 50% 0%,
    rgba(255, 217, 163, 0.34) 0%,
    rgba(255, 217, 163, 0.10) 45%,
    transparent 72%);
  filter: blur(14px);
  pointer-events: none;
}

/* ---- scroll-linked colour wave, display type only ----
   The padding-bottom / negative-margin pair extends the background box under
   descenders: background-clip:text can only paint where the background box
   reaches, so with line-height 0.95 a descender (the g in "design") fell
   outside it and rendered clipped. Layout height is unchanged. */
.wave-dark, .wave-light {
  background-size: 240% 100%;
  background-position: calc(var(--wavep) * 100%) 0;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  padding-bottom: 0.16em;
  margin-bottom: -0.16em;
}
.wave-dark {
  background-image: linear-gradient(105deg,
    #2B1F1E 0%, #5A322E 22%, #7C453B 40%, #5A322E 58%, #2B1F1E 78%, #5A322E 100%);
}
.wave-light {
  background-image: linear-gradient(105deg,
    #F2DCC0 0%, #E1AA6A 24%, #C9835A 44%, #E1AA6A 62%, #F2DCC0 80%, #E1AA6A 100%);
}

/* ---- top bar: never a visible bar, only a soft gradient ----
   ONE fixed scrim, identical on every page and every section, from first
   paint, never changing with the frame or the ground beneath (Andrea,
   29 Aug — supersedes the sampled bar zone and the light-section flip).
   SHORT (Andrea, same day): the dark band behind the marks (y 14-48),
   then a feather finished within ~30px below them — fully transparent by
   78px — then nothing; the page reads completely clear from there down.
   Pixel stops, so the --bar-zone height the homepage engine sets cannot
   stretch or dilute it. Marks sit over >= 0.70 scrim: cream holds 6.7:1
   over the site's palest ground (#EFE8DC). Do not lengthen the fade.
   The bar never takes pointer events, so it can't block the film. */
.bar {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 90;
  height: max(64px, var(--bar-zone, 0px));
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  padding: 14px 20px 0;
  pointer-events: none;
  /* tightened again (29 Aug, Andrea: still too big): the feather now ends
     16px below the marks. The band to 48px cannot shrink further without
     dropping the marks below 4.5:1 on the palest grounds. */
  background: linear-gradient(180deg,
    rgba(18, 16, 14, 0.85) 0px,
    rgba(18, 16, 14, 0.70) 48px,
    rgba(18, 16, 14, 0) 64px);
}
.bar-logo, .burger { pointer-events: auto; }
/* the scrolled state is ONLY the warm ember under the marks — no transform:
   the old translateY(6px) slid the scrim down and left an unscrimmed seam
   at the very top of the screen (Andrea's photograph, 29 Aug) */
.bar.scrolled::after {
  content: '';
  position: absolute;
  left: 12%; right: 12%; top: 54px; height: 30px;
  background: radial-gradient(ellipse at 50% 0%,
    rgba(255, 217, 163, 0.14) 0%, transparent 70%);
  filter: blur(12px);
  pointer-events: none;
}
/* The mark alone, identical on every page, always linking home (A3).
   The full lockup lives in the hero sky and the footer, never up here. */
.bar-logo { display: block; height: 34px; }
.bar-logo .logo-mark {
  height: 34px; width: auto;
  /* stage 11b (Andrea): the glow behind the bar mark is gone, at EVERY
     width — the 3b footer precedent split desktop/mobile for an 84px
     lockup on open canvas (§16 H's merge risk); a 34px mark on the fixed
     dark scrim measured clear of the 3:1 non-text floor at 390 and 1440
     with the glow off, so the split is not needed here. */
}
.burger {
  width: 40px; height: 34px;
  background: none; border: 0; cursor: pointer;
  display: flex; flex-direction: column; justify-content: center; gap: 6px;
  padding: 0 8px;
}
.burger i { display: block; height: 1.5px; background: var(--cream); border-radius: 2px; transition: transform 0.4s var(--ease), opacity 0.4s var(--ease); }
.menu-open .burger i:nth-child(1) { transform: translateY(7.5px) rotate(45deg); }
.menu-open .burger i:nth-child(2) { opacity: 0; }
.menu-open .burger i:nth-child(3) { transform: translateY(-7.5px) rotate(-45deg); }

/* ---- STAGE 3 (31 Aug): persistent desktop navigation ----
   Above 1024 the burger gives way to the four section links, right-aligned
   where it sat. The links live in the mark's own y 14-48 band, where the
   scrim holds >= 0.70 (§31) — uppercase labels carry no descenders, so no
   glyph reaches the weaker fade below 48px. No Home link (the mark links
   home, §16 A3) and no Enquire (the hero's left column carries it; two in
   one viewport read as a bug, §5 item 7 — SUPERSEDED 1 Sep, stage 10d:
   Andrea rules Enquire joins the bar as its last item on every page; see
   .bar-cta below and BUILD-LOG 79). Labels are authored in sentence
   case and uppercased here, the site-wide pattern. Hover and current-page
   are opacity plus a fine cream underline — never copper, which stays
   Enquire's alone. (Stage 10d: while the capsule lives — .caps-on, added
   by navcaps.js — the capsule replaces the underline; the underline rule
   stays as the no-JS fallback, per Andrea's open ruling on the line.)
   The bar's height, scrim and ember are untouched. */
@media (min-width: 1024px) {
  .burger { display: none; }
  .bar-nav {
    pointer-events: auto;
    display: flex;
    align-items: center;
    gap: clamp(1.7rem, 2.4vw, 2.8rem);
    height: 34px;                    /* the mark's band; the bar's own height is unchanged */
  }
  .bar-nav a {
    position: relative;
    color: var(--cream);
    opacity: 0.85;
    text-decoration: none;
    /* stage 3b: raised from the 0.8rem small-label register — this is the
       only navigation on the screen; tracking stays em-relative */
    font-size: clamp(0.85rem, 0.95vw, 1rem); font-weight: 500;
    letter-spacing: 0.18em; text-transform: uppercase;
    transition: opacity 0.3s var(--ease);
  }
  .bar-nav a::after {
    content: '';
    position: absolute;
    left: 0; right: 0; bottom: -7px;
    height: 1px;
    background: rgba(242, 236, 227, 0.55);
    transform: scaleX(0);
    transition: transform 0.3s var(--ease);
  }
  .bar-nav a:hover, .bar-nav a:focus-visible, .bar-nav a[aria-current="page"] { opacity: 1; }
  .bar-nav a:hover::after, .bar-nav a[aria-current="page"]::after { transform: scaleX(1); }
  .bar-nav a:focus-visible { outline: 2px solid var(--cream); outline-offset: 5px; border-radius: 2px; }

  /* while the capsule lives (navcaps.js adds .caps-on) it replaces the
     underline; the rule above stays untouched as the no-JS fallback */
  .bar-nav.caps-on a::after { content: none; }

  /* ---- STAGE 10d: Enquire, last in the bar, on every page (Andrea) ----
     NOT a .cta instance: .cta's 0.72rem label is one of §62's locked levers
     and its 58px pill cannot live in this 34px band. A nav-sized CTA with
     its own class, nav type, and ONE thing that marks it as the enquiry
     family: the rim — the same masked conic, the same stops as the resting
     .cta rim, driven by the same p3-orbit keyframes so the 9s lap is shared
     by construction. No bloom, no flare. */
  .bar-nav a.bar-cta {
    opacity: 1;
    height: 34px;
    padding: 0 16px;
    display: inline-flex; align-items: center;
    border-radius: 999px;
    animation: p3-orbit 9s linear infinite;
  }
  .bar-nav a.bar-cta::after { content: none; }   /* never the underline: the rim is its mark */
  .bar-nav a.bar-cta::before {
    content: '';
    position: absolute; inset: 0; border-radius: 999px; padding: 1.5px;
    /* the quiet arc sits at .42 — the 10b hover rim's own value, so the bar
       CTA wears at rest what the big CTA shows on hover, and the 1.5px arc
       clears the 3:1 non-text floor over the scrim (gate-2 measurement) */
    background: conic-gradient(from var(--p3-ang),
      rgba(242,236,227,.42) 0deg, rgba(242,236,227,.42) 236deg,
      #C1793A 290deg, #E8A25C 316deg, #FFF6EA 334deg, #8FB8DE 352deg, rgba(242,236,227,.42) 360deg);
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    mask-composite: exclude;
    pointer-events: none;
  }

  /* ---- STAGE 10d: the active-page indicator is a dark glass capsule ----
     The reference (iOS 26's light capsule) INVERTS here: our bar is a dark
     scrim under cream labels, and a light fill behind cream lettering is
     the exact failure 10b removed from the enquiry face. So the capsule
     DEEPENS the scrim — warm-family dark glass, a warm hairline for its
     edge — and contrast under it improves rather than degrades. Positioned
     and sized by navcaps.js; travel is translateX (transition) with a
     scaleX stretch keyframe on the inner element — transform-only. */
  .bar-nav { position: relative; }
  .nav-caps {
    position: absolute;
    top: 50%; left: 0;
    height: 30px; margin-top: -15px;
    width: 24px;
    opacity: 0;
    pointer-events: none;
    transition: transform .38s cubic-bezier(.32, .72, 0, 1), opacity .3s var(--ease);
  }
  .nav-caps i {
    display: block; width: 100%; height: 100%;
    border-radius: 999px;
    background: rgba(12, 10, 9, 0.42);
    /* .40/1px -> .75/1.5px (stage 10e correction, Andrea): the 10d figure
       of 3.05 was one lucky draw of the top edge — the full distribution
       ran 1.39-3.10 (the bottom edge sits against the scrim's feather, and
       the stretch dims a 1px line when the compositor resamples it). At
       .75 and 1.5px every cell of the battery holds: rest 3.64+, stretch
       3.60+, tops 6.9+. Measured over both page extremes, five positions,
       multiple draws. */
    box-shadow: inset 0 0 0 1.5px rgba(255, 217, 163, 0.75);
    -webkit-backdrop-filter: blur(8px) saturate(1.15);
    backdrop-filter: blur(8px) saturate(1.15);
  }
  .nav-caps.travel i { animation: caps-stretch .38s cubic-bezier(.32, .72, 0, 1); }
  /* ⚠ STAGE 33: ROUTE 2 IS REMOVED, NOT DISABLED. 10d shipped the capsule
     across same-origin page loads via View Transitions -- `@view-transition
     { navigation: auto }`, `:root { view-transition-name: none }` and a
     `nav-capsule` snapshot name. Andrea has DEFERRED that past launch, and it
     was site-wide: the at-rule opted EVERY cross-document navigation on the
     site into a transition, in an API the build could only ever test in
     Chromium. A deferred feature cannot ship on, so the whole of it is gone
     and the capsule keeps route 1 -- hover, focus and its resting position --
     which is what this stage is about. Restoring it is those four lines back,
     after launch and with a WebKit pass behind it. */
}
@keyframes caps-stretch { 50% { transform: scaleX(var(--stretch, 1.15)); } }
@media (max-width: 1023px) {
  .bar-nav { display: none; }        /* mobile keeps the burger and the overlay */
}

/* ---- menu ---- */
.menu {
  position: fixed; inset: 0; z-index: 80;
  display: flex; flex-direction: column; align-items: flex-start; justify-content: center;
  gap: 12px;
  padding: 0 clamp(1.5rem, 8vw, 6rem);
  background: rgba(18, 16, 14, 0.9);
  -webkit-backdrop-filter: blur(22px) saturate(130%);
  backdrop-filter: blur(22px) saturate(130%);
  opacity: 0; pointer-events: none;
  /* visibility, not just opacity (29 Aug scroll-judder fix): at opacity 0
     alone this full-viewport fixed element kept its blur(22px)
     backdrop-filter live, so iOS re-blurred the whole backdrop on every
     scroll frame of every page. hidden releases it; the 0.5s delay lets
     the close fade finish before it lets go. */
  visibility: hidden;
  transition: opacity 0.5s var(--ease), visibility 0s 0.5s;
}
.menu-open .menu {
  opacity: 1; pointer-events: auto;
  visibility: visible;
  transition: opacity 0.5s var(--ease);
}
.menu a.menu-link {
  font-family: 'Instrument Sans', sans-serif;
  font-size: clamp(2rem, 8vw, 4.4rem);
  font-weight: 500;
  letter-spacing: -0.02em;
  line-height: 1.15;
  color: var(--cream);
  text-decoration: none;
  opacity: 0; transform: translateY(18px);
  transition: opacity 0.55s var(--ease), transform 0.55s var(--ease), color 0.3s var(--ease);
}
.menu a.menu-link:hover, .menu a.menu-link:focus-visible { color: var(--copper-lit); }
.menu-open .menu a.menu-link { opacity: 1; transform: none; }
.menu-open .menu a.menu-link:nth-child(1) { transition-delay: 0.06s; }
.menu-open .menu a.menu-link:nth-child(2) { transition-delay: 0.12s; }
.menu-open .menu a.menu-link:nth-child(3) { transition-delay: 0.18s; }
.menu-open .menu a.menu-link:nth-child(4) { transition-delay: 0.24s; }
.menu-open .menu a.menu-link:nth-child(5) { transition-delay: 0.30s; }
.menu .cta-wrap { margin-top: 26px; opacity: 0; transform: translateY(18px); transition: opacity 0.55s var(--ease) 0.3s, transform 0.55s var(--ease) 0.3s; }
.menu-open .menu .cta-wrap { opacity: 1; transform: none; }

/* ---- the Enquire button: the single copper element ----
   ONE shared component. Every enquiry button on the site (Enquire, Start your
   enquiry, Begin your enquiry, Start, and the hamburger's) is this class -
   there were never per-page copies to consolidate. The plain hairline pills
   (See what we do, See the work, Meet the studio, See this built, Swipe) are
   deliberately NOT this component and are untouched.

   Only transform, opacity, filter and the registered custom properties below
   are animated: no layout property, and the old box-shadow pulse is gone. */
@property --p3-ang { syntax: '<angle>';      initial-value: 0deg;  inherits: true }
@property --p3-fx  { syntax: '<percentage>'; initial-value: -140%; inherits: false }
@property --p3-fo  { syntax: '<number>';     initial-value: 0;     inherits: false }
/* Stage 14: the selected option's travelling rim. A SEPARATE registered
   angle — .cta's --p3-ang (inherits: true, verified) is measured and locked
   and is not reused. ONE animation, on the active step container, and every
   selected rim reads the inherited angle: sixteen selections on the
   whole-home step cost one animation and sixteen paint consumers, never
   sixteen animations. */
@property --p3-sel-ang { syntax: '<angle>'; initial-value: 0deg; inherits: true }
@keyframes p3-sel-orbit { to { --p3-sel-ang: 360deg } }

.cta-wrap {
  position: relative; display: inline-block;
  /* one lap drives both the rim and the bloom, so they can never drift apart */
  --p3-ring: conic-gradient(from var(--p3-ang),
    rgba(242,236,227,.14) 0deg, rgba(242,236,227,.14) 236deg,
    #C1793A 290deg, #E8A25C 316deg, #FFF6EA 334deg, #8FB8DE 352deg, rgba(242,236,227,.14) 360deg);
  animation: p3-orbit 9s linear infinite;
}
@keyframes p3-orbit { to { --p3-ang: 360deg } }

/* the bloom - this is the brand underglow, cast by the ring itself.
   THE BLUR IS BACK, WITH ROOM (30 Aug, stage 18). Stage 17 replaced it with a
   radial alpha mask; on a wide pill a radial gradient is an ELLIPSE, so the
   halo became an oval blob and lost the thing that gives it its character -
   the glow is meant to be LOCAL TO THE TRAVELLING HEAD, brightest around the
   bright arc of the conic and falling away round the rest of the pill. Only a
   blurred pill carrying the conic does that. Reverted.

   The rectangle is not the blur's fault, it is the BOX's. A blur must paint
   outside its element to fade out, and a compositor that rasterises the layer
   to its border box cuts the tail off square. So the element is made much
   larger than the shape it paints, and the tail terminates inside it:

     - the OUTER box is inset -64px and carries filter: blur(16px). It has no
       background of its own; its 57px border is transparent, which is what
       holds the painted shape away from the edges.
     - the INNER pill is that element's PADDING box - border-box minus the
       57px border, i.e. exactly button + 7px on every side, the shape and
       position the bloom always had - and `background: var(--p3-ring)
       padding-box` paints the conic into it. border-radius: 999px gives the
       padding box an inner radius of exactly half its height, so it is a
       true pill, never an ellipse.

   Two boxes, one element, no extra markup on thirteen instances. Every pixel
   at the outer boundary is fully transparent, so a layer clipped there clips
   nothing - which is the property that makes the bug impossible rather than
   unlikely, and it is measurable in any browser. */
.cta-wrap::after {
  content: '';
  position: absolute; inset: -64px; z-index: 0;
  border: 57px solid transparent;
  border-radius: 999px;
  background: var(--p3-ring) padding-box;
  filter: blur(16px);
  /* .6 -> .43 (stage 10c-tune, Andrea's defect ruling): at .6 the conic's hot
     segment bled into the label rect at ~120/300deg and held the home-canvas
     cell below 4.5:1 for 22.9% of every 36s beat (min 3.27). .43 is the
     smallest global cut that clears 4.5 + 0.2 margin on the worst cell over
     a full lap (4.74). One value, every instance; the rim is untouched. */
  opacity: .43;
  pointer-events: none;
}

.cta {
  position: relative; z-index: 1;
  /* overflow:hidden is kept from the original component: the flare's box is
     translated +/-140%, so without it the beam's scroll area extends past the
     pill (and could paint outside it). Both pseudo-elements are inset:0 and
     belong inside the pill, so clipping costs nothing. */
  overflow: hidden;
  display: inline-flex; align-items: center; justify-content: center;
  min-height: 58px; padding: 0 44px;   /* inline-flex so min-height centres the label */
  border: 0; border-radius: 999px;
  cursor: pointer;
  color: var(--cream);
  text-decoration: none;
  font: 600 0.72rem/1 'Schibsted Grotesk', sans-serif;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  /* stage 10b (Andrea's ruling): the face's white tint is GONE. It was the
     only paint sitting directly under the label, and it lightened the
     label's ground by ~4.5% of white on every dark instance — the hero
     portal cell measured 4.44:1 under §60's own frame because of it. The
     glass reading comes from the backdrop blur, which stays. The light
     variant sets its own face below and is untouched. */
  background: transparent;
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  transition: transform .18s cubic-bezier(.32, .72, 0, 1);
}
.cta:active { transform: scale(.975); }
.cta:focus-visible { outline: 2px solid currentColor; outline-offset: 5px; }
/* ---- the hover (stage 10b, Andrea: "a luminance effect that highlights
   the button even more when somebody hovers on it with a mouse") ----
   THE RIM BRIGHTENS; NOTHING UNDER THE LABEL CHANGES. Measurement closed
   every other door: the face and the bloom both paint behind the
   lettering, and every brightening of either walked the worst cell below
   the floor. The rim does not — 2.5px masked to the border ring, nowhere
   near the label. Mechanism: the wrap's free ::before carries a SECOND
   rim with the quiet 236° arc lifted (same accent head, same
   var(--p3-ang), so the 9s lap is shared by construction), sitting at
   opacity 0 at rest and fading in on hover — the only animated property
   is opacity. Nothing new runs at rest; a coarse pointer gets nothing. */
.cta-wrap::before {
  content: '';
  position: absolute; inset: 0; border-radius: 999px; padding: 2.5px; z-index: 2;
  background: conic-gradient(from var(--p3-ang),
    rgba(242,236,227,.42) 0deg, rgba(242,236,227,.42) 236deg,
    #C1793A 290deg, #E8A25C 316deg, #FFF6EA 334deg, #8FB8DE 352deg, rgba(242,236,227,.42) 360deg);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  opacity: 0;
  pointer-events: none;
}
.cta-wrap[data-cta="light"]::before {
  background: conic-gradient(from var(--p3-ang),
    rgba(43,31,30,.44) 0deg, rgba(43,31,30,.44) 236deg,
    #8A4F1F 290deg, #A9662C 316deg, #5C4326 334deg, #3D5670 352deg, rgba(43,31,30,.44) 360deg);
}
@media (hover: hover) and (pointer: fine) {
  .cta-wrap::before { transition: opacity .3s var(--ease); }
  .cta-wrap:hover::before, .cta-wrap:focus-within::before { opacity: 1; }
  /* 11j: the phone number's hover, measured before built (candidate C —
     A and B passed independently, the composite was measured too).
     A: the INK moves to the palette's warm top #F2DCC0 — a color
     transition on the clipped span (the .hs2-cap idiom): the opaque
     fill covers the waved gradient, 6.142:1 at the canonical 0.0784
     crest, 7.77:1 at the measured worst draw. The ground never
     changes, so contrast can only rise. B: the padding-box rim fades
     in (see .hero-tel::before) — its inner edge 13-15px clear of the
     nearest glyph, zero paint in the content box, 6.08:1 as a graphic.
     10e-b's --tel-shift fill sweep RETIRES here — Andrea: the whitish
     fill was never the light he meant. The glyph still lifts to full
     strength. Stage 19 deleted the registration and the inert
     `+ var(--tel-shift, 0)` term; the background-position calc itself
     stays, because the wave's own --wavep runs through it. */
  .hero-tel-glyph { transition: opacity .3s var(--ease); }
  .hero-tel::before { transition: opacity .35s var(--ease); }
  .hero-tel .wave-panel.wave-light { transition: color .35s var(--ease); }
  .hero-tel:hover .hero-tel-glyph, .hero-tel:focus-visible .hero-tel-glyph { opacity: 1; }
  .hero-tel:hover::before, .hero-tel:focus-visible::before { opacity: 1; }
  .hero-tel:hover .wave-panel.wave-light,
  .hero-tel:focus-visible .wave-panel.wave-light { color: #F2DCC0; }
}

/* the rim - 2.5px, drawn with the mask-composite border trick */
.cta::before {
  content: '';
  position: absolute; inset: 0; border-radius: inherit; padding: 2.5px; z-index: 2;
  background: var(--p3-ring);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  pointer-events: none;
}

/* the flare - two passes per 12s cycle, one quiet, one bright.

   The element is STATIC and the beam travels by moving the gradient's own
   stops with --p3-fx, rather than by translateX. That is not a stylistic
   preference: the label protection below is a mask, and a mask travels with
   its element, so on a translated flare the protected band would slide along
   WITH the beam and protect nothing. The element has to hold still for the
   mask to stay over the lettering. --p3-fx is still the same registered
   <percentage> driving the same keyframes at the same 12s. */
.cta::after {
  content: '';
  position: absolute; inset: 0; border-radius: inherit; z-index: 3;
  background: linear-gradient(100deg,
    transparent                            calc(var(--p3-fx) + 40%),
    rgba(255, 244, 230, var(--p3-fo))      calc(var(--p3-fx) + 50%),
    transparent                            calc(var(--p3-fx) + 60%));
  animation: p3-flare 12s cubic-bezier(.45, 0, .55, 1) infinite;
  /* Soft falloff across the lettering, never a hole. The ramps live inside
     the button's own 44px padding, so the beam is at full strength as it
     enters and leaves and only eases down over the label - light crossing the
     glass, with the raised type not catching it the same way. */
  -webkit-mask: linear-gradient(90deg, #000 0,
    rgba(0,0,0,.30) 34px, rgba(0,0,0,.30) calc(100% - 34px), #000 100%);
  mask: linear-gradient(90deg, #000 0,
    rgba(0,0,0,.30) 34px, rgba(0,0,0,.30) calc(100% - 34px), #000 100%);
  pointer-events: none;
}
/* hold-then-jump: interpolating 140% straight back to -140% would run the
   beam backwards across the face between passes */
@keyframes p3-flare {
  0%     { --p3-fx: -140%; --p3-fo: .18 }
  20%    { --p3-fx:  140%; --p3-fo: .18 }
  49%    { --p3-fx:  140%; --p3-fo: .18 }
  49.01% { --p3-fx: -140%; --p3-fo: .55 }
  70%    { --p3-fx:  140%; --p3-fo: .55 }
  100%   { --p3-fx:  140%; --p3-fo: .55 }
}

/* the label sits above the flare: measured, ink under a 0.55 near-white beam
   drops below the floor on the light ground otherwise */
.cta span { position: relative; z-index: 4; }

/* LIGHT-GROUND MODIFIER - the contact page's sage. A rim tuned for near-black
   needs a darker copper and ink text on a cream face; same design, swapped
   values, not a second component. */
.cta-wrap[data-cta="light"] {
  --p3-ring: conic-gradient(from var(--p3-ang),
    rgba(43,31,30,.16) 0deg, rgba(43,31,30,.16) 236deg,
    #8A4F1F 290deg, #A9662C 316deg, #5C4326 334deg, #3D5670 352deg, rgba(43,31,30,.16) 360deg);
}
.cta-wrap[data-cta="light"]::after { opacity: .42; }
.cta-wrap[data-cta="light"] .cta {
  color: var(--ink);
  background: rgba(255, 250, 242, .30);
}

/* ---- STAGE 86c: THE SAME LIGHT, DRAWN BY THE COMPOSITOR ----
   The rules above draw the rim, the bloom, the hover rim and the flare from
   registered custom properties (--p3-ang, --p3-fx, --p3-fo). A browser cannot
   hand those to the compositor, so while one moves the WHOLE PAGE restyles and
   repaints every frame: on a 4x-slowed phone at rest, ~980 ms of every second
   (Stage 86 NOTES, 86c perf/). This block draws the same pixels with
   transform and opacity only, which the compositor runs on its own.

   ⚠ The pseudo-element rules above are NOT retired. They are what draws the
   light wherever it does not move (reduced motion, parked at 40deg) and in a
   browser without container units; this block takes over only while it moves,
   and switches them off for exactly that case.

   HOW. A conic gradient is a function of the angle round its centre and
   nothing else, so `conic-gradient(from A, ...)` is the same picture as
   `conic-gradient(from 0deg, ...)` turned A about that centre. Each moving
   light is therefore a STILL box carrying the old pseudo-element's geometry,
   mask, filter and opacity (the ring stays still, as it must), and its
   ::before is the gradient at 0deg on a square large enough to cover the box
   at any angle, turned by `p3-lap` — the old keyframes' 0 -> 360deg, 9s,
   linear. The flare's beam is the same three stops on a strip the beam's own
   width, tilted 10deg (the gradient's 100deg) and slid along it by
   --p3-fx x the gradient line's length, with --p3-fo as its opacity, on the
   old keyframe offsets and easing. The beam's position needs the box's size,
   so the still boxes are size containers and the moving parts use cq units.
   ⚠ The blur of the bloom must happen OUTSIDE the clip that shapes it, with
   room to fade: WebKit's compositor cuts a blur off square at the clip box
   (the Stage 18 rectangle). So the bloom is two boxes — the outer one carries
   the blur and the room, the inner one is the pill, clipped by a mask.

   The boxes are empty <i class="lap ..."> elements in the markup, one per
   moving layer (a pseudo-element cannot hold a still mask AND a turning
   gradient). They are display:none until this block shows them, and an old
   pseudo-element is switched off only where ITS box is present (:has), so an
   instance without the boxes (a merge, a copied snippet) keeps its old rim
   rather than losing it. A browser without :has() or container units keeps
   every old rule. */
.lap { display: none; }
@supports (width: 1cqw) and selector(:has(*)) {
  @media (prefers-reduced-motion: no-preference) {
    .lap { display: block; position: absolute; pointer-events: none; }

    /* the old animations stop only once every consumer has its box: the
       wrap's p3-orbit feeds its ::before, its ::after and .cta::before.
       ⚠ animation: none on .cta::after too: WebKit keeps a pseudo-element's
       animation running after `content: none` has removed its box */
    .cta-wrap:has(> .lap-hover):has(> .lap-bloom):has(> .cta > .lap-rim) { animation: none; }
    .cta-wrap:has(> .lap-hover)::before, .cta-wrap:has(> .lap-bloom)::after,
    .cta:has(> .lap-rim)::before, .cta:has(> .lap-flare)::after { content: none; }
    .cta:has(> .lap-flare)::after { animation: none; }

    /* every ring: a still box with the old pseudo-element's mask */
    .lap-rim, .lap-hover, .lap-bar {
      inset: 0;
      -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
      -webkit-mask-composite: xor;
      mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
      mask-composite: exclude;
    }
    .lap-rim, .lap-hover, .lap-bar, .lap-pill, .lap-flare { container-type: size; }

    /* the turning gradient: centred on its still box, a square of side
       >= the box's diagonal (+12px: a padded box's cq units are its content box) */
    .lap-rim::before, .lap-hover::before, .lap-bar::before, .lap-pill::before {
      content: ''; position: absolute; left: 50%; top: 50%;
      --lap-s: calc(max(100cqw, 100cqh) * 1.415 + 12px);
      width: var(--lap-s); height: var(--lap-s);
      margin: calc(var(--lap-s) / -2) 0 0 calc(var(--lap-s) / -2);
      animation: p3-lap 9s linear infinite;
    }
    /* .cta::before's and .cta-wrap::after's stops (--p3-ring) */
    .lap-rim::before, .lap-pill::before {
      background: conic-gradient(from 0deg,
        rgba(242,236,227,.14) 0deg, rgba(242,236,227,.14) 236deg,
        #C1793A 290deg, #E8A25C 316deg, #FFF6EA 334deg, #8FB8DE 352deg, rgba(242,236,227,.14) 360deg);
    }
    /* the hover rim's and the bar Enquire's: the quiet arc at .42 */
    .lap-hover::before, .lap-bar::before {
      background: conic-gradient(from 0deg,
        rgba(242,236,227,.42) 0deg, rgba(242,236,227,.42) 236deg,
        #C1793A 290deg, #E8A25C 316deg, #FFF6EA 334deg, #8FB8DE 352deg, rgba(242,236,227,.42) 360deg);
    }

    /* the rim: .cta::before's box */
    .lap-rim { border-radius: inherit; padding: 2.5px; z-index: 2; }

    /* the hover rim: .cta-wrap::before's box and opacity. Hidden, not
       removed, where it can never show (no fine pointer): its lap keeps the
       rim's clock should a fine pointer arrive, and a hidden layer is never drawn */
    .lap-hover { border-radius: 999px; padding: 2.5px; z-index: 2; opacity: 0; visibility: hidden; }
    @media (hover: hover) and (pointer: fine) {
      /* inherit, not visible: inside the closed menu (visibility: hidden) it must stay hidden */
      .lap-hover { visibility: inherit; transition: opacity .3s var(--ease); }
      .cta-wrap:hover .lap-hover, .cta-wrap:focus-within .lap-hover { opacity: 1; }
    }

    /* the bloom: .cta-wrap::after. The outer box is its border box (the room
       and the blur), the inner one its padding box (the pill) */
    .lap-bloom { inset: -64px; z-index: 0; filter: blur(16px); opacity: .43; }
    .lap-pill {
      inset: 57px; border-radius: 999px;
      -webkit-mask: linear-gradient(#000 0 0); mask: linear-gradient(#000 0 0);
    }

    /* the flare: .cta::after's box and its label mask (which, with the
       radius, is also the pill's clip) */
    .lap-flare {
      inset: 0; border-radius: inherit; z-index: 3;
      -webkit-mask: linear-gradient(90deg, #000 0,
        rgba(0,0,0,.30) 34px, rgba(0,0,0,.30) calc(100% - 34px), #000 100%);
      mask: linear-gradient(90deg, #000 0,
        rgba(0,0,0,.30) 34px, rgba(0,0,0,.30) calc(100% - 34px), #000 100%);
    }
    .lap-flare::before {
      content: ''; position: absolute; left: 50%; top: 50%;
      /* L, the 100deg gradient line of the box: w sin100 + h |cos100| */
      --lap-l: calc(98.4807753cqw + 17.3648178cqh);
      /* the beam (20% of L) plus 2px of clear either side; long enough to
         cross the box along the beam (w |cos100| + h sin100) */
      --lap-w: calc(var(--lap-l) * .2 + 4px);
      --lap-h: calc(17.3648178cqw + 98.4807753cqh + 8px);
      width: var(--lap-w); height: var(--lap-h);
      margin: calc(var(--lap-h) / -2) 0 0 calc(var(--lap-w) / -2);
      background: linear-gradient(90deg, transparent 2px, rgb(255, 244, 230) 50%, transparent calc(100% - 2px));
      /* at rest where the old one rests (--p3-fx -140%, --p3-fo .18), should
         anything ever stop the animation */
      transform: rotate(10deg) translateX(calc(var(--lap-l) * -1.4)); opacity: .18;
      animation: p3-lap-flare 12s cubic-bezier(.45, 0, .55, 1) infinite;
    }

    .cta-wrap[data-cta="light"] .lap-rim::before, .cta-wrap[data-cta="light"] .lap-pill::before {
      background: conic-gradient(from 0deg,
        rgba(43,31,30,.16) 0deg, rgba(43,31,30,.16) 236deg,
        #8A4F1F 290deg, #A9662C 316deg, #5C4326 334deg, #3D5670 352deg, rgba(43,31,30,.16) 360deg);
    }
    .cta-wrap[data-cta="light"] .lap-hover::before {
      background: conic-gradient(from 0deg,
        rgba(43,31,30,.44) 0deg, rgba(43,31,30,.44) 236deg,
        #8A4F1F 290deg, #A9662C 316deg, #5C4326 334deg, #3D5670 352deg, rgba(43,31,30,.44) 360deg);
    }
    .cta-wrap[data-cta="light"] .lap-bloom { opacity: .42; }

    /* the bar's Enquire (desktop only, as the bar is): its ::before rim */
    .bar-nav a.bar-cta:has(> .lap-bar) { animation: none; }
    .bar-nav a.bar-cta:has(> .lap-bar)::before { content: none; }
    .lap-bar { border-radius: 999px; padding: 1.5px; }
    @media not all and (min-width: 1024px) { .lap-bar { display: none; } }
  }
}
@keyframes p3-lap { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
/* --p3-fx -> translateX(--p3-fx x L) along the tilted strip; --p3-fo -> opacity.
   The same offsets, values and per-segment easing as p3-flare. */
@keyframes p3-lap-flare {
  0%     { transform: rotate(10deg) translateX(calc(var(--lap-l) * -1.4)); opacity: .18 }
  20%    { transform: rotate(10deg) translateX(calc(var(--lap-l) *  1.4)); opacity: .18 }
  49%    { transform: rotate(10deg) translateX(calc(var(--lap-l) *  1.4)); opacity: .18 }
  49.01% { transform: rotate(10deg) translateX(calc(var(--lap-l) * -1.4)); opacity: .55 }
  70%    { transform: rotate(10deg) translateX(calc(var(--lap-l) *  1.4)); opacity: .55 }
  100%   { transform: rotate(10deg) translateX(calc(var(--lap-l) *  1.4)); opacity: .55 }
}

/* ---- hero ---- */
.hero { position: relative; }
.hero-pin {
  position: sticky; top: 0;
  /* the LARGE viewport height: does not move when the mobile toolbar
     collapses, so the film never resizes or leaves a gap at the bottom */
  height: 100vh;
  height: 100lvh;
  overflow: hidden;
  background: var(--canvas);
}
.film-box {
  position: absolute;
  /* sized and placed by JS */
  overflow: hidden;
}
.film-box img.film {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
}
/* the film's top edge feathers into the ground above it (image rule 3:
   every image blends into its ground). TIGHT — 24px, not the old 64px:
   the long feather read as a second scrim continuing down the screen and
   veiled the ceiling in the room frames (Andrea, 29 Aug). */
.film-fade {
  position: absolute;
  left: 0; right: 0; top: 0; height: 16px;
  z-index: 2;
  background: linear-gradient(180deg, var(--pin-bg, var(--canvas)), transparent);
  pointer-events: none;
}

/* Skip control (29 Aug): returning visitors jump past the film without
   scrubbing it. Bottom right of the film box (anchored to the visible
   bottom), a circled line arrow at the site stroke weight; hides once the
   film is done. */
.hero-skip {
  position: absolute;
  right: 16px; bottom: 18px;
  z-index: 6;
  width: 44px; height: 44px;
  border-radius: 50%;
  border: 1.5px solid rgba(242, 236, 227, 0.75);
  background: rgba(18, 16, 14, 0.3);
  color: var(--cream);
  cursor: pointer;
  display: flex; align-items: center; justify-content: center;
  padding: 0;
  box-shadow: 0 2px 14px rgba(18, 16, 14, 0.25);
  transition: opacity 0.4s var(--ease), border-color 0.3s var(--ease);
}
.hero-skip svg { width: 20px; height: 20px; }
.hero-skip:hover, .hero-skip:focus-visible { border-color: var(--cream); }
.hero-skip.gone { opacity: 0; pointer-events: none; }

/* Captions over the film. 28 Aug overrides: the 4.3 rotation is fixed
   (top-left, middle-right, bottom-left, the same cycle in every room) and
   every caption sits on a soft blurred glass backdrop — not a hard box —
   so legibility is independent of what the film shows behind it. Shadow
   wide and weak, per 4.3. Ink text on cream glass keeps 4.3's dark-text
   decision; the 0.58 tint alpha holds 4.5:1 even over a pure black ground. */
.film-captions { position: absolute; inset: 0; z-index: 3; pointer-events: none; }
.cap {
  position: absolute;
  /* Stage 20 item 2: a measure cap, aimed at what actually renders.
     ⚠ The brief's ~60ch would have been a no-op: `ch` on .cap resolves
     against .cap's OWN font-size, which is the inherited 16px, so 1ch =
     10.655px and 60ch = 639px — above the 512.5px the box ever reaches.
     Measured instead: the box is driven by .cap-sub (9.6px, 0.09em tracking),
     not by the reading line. At 1024 the sub ran 80 characters on ONE line
     while .cap-line sat at 46 characters — the reading line was already
     inside the comfortable range and the SUB was the long measure.
     40ch = 426px is aimed so the sub wraps while the reading line does not:
     .cap-line's widest is 380.5px against 396px of content box. It cannot
     bind on phones — 64% is 205/250/275px at 320/390/430, all below 426 —
     so those widths stay byte-identical. */
  max-width: min(64%, 40ch);
  padding: 12px 15px 13px;
  border-radius: 16px;
  color: var(--ink);
  /* Stage 56 item 1 (7 Sep). Was 0.58. ⚠ The plate is raised because the
     SUB-LINE's opacity comes off, not for the reading line — see .cap-sub
     below. Six of the nine sub-lines were below the 4.5 floor once the
     post-composite opacity was accounted for; together the two changes lift
     seventeen of the eighteen caption lines to 7.38-11.49:1. The 0.9
     no-backdrop-filter fallback is already above this and is untouched. */
  background: rgba(242, 236, 227, 0.72);
  -webkit-backdrop-filter: blur(18px) saturate(1.12);
  backdrop-filter: blur(18px) saturate(1.12);
  border: 1px solid rgba(255, 255, 255, 0.26);
  box-shadow: 0 16px 52px rgba(18, 16, 14, 0.16);
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.5s var(--ease), transform 0.5s var(--ease);
  font-family: 'Instrument Sans', sans-serif;
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  .cap { background: rgba(242, 236, 227, 0.9); }
}
.cap.on { opacity: 1; transform: none; }
/* tl top 11%, was 5% (29 Aug): the svh anchor lifted the film ~110px and
   the first caption of each room rode into the bar's fade and the mark.
   The 4.3 rotation (tl → mr → bl) is untouched. */
.cap.pos-tl { left: 4%; top: 11%; text-align: left; }
.cap.pos-mr { right: 4%; top: 44%; text-align: right; }
.cap.pos-bl { left: 4%; bottom: 6%; text-align: left; }
.cap .cap-line {
  font-size: clamp(0.92rem, 3.9vw, 1.1rem);
  font-weight: 500;
  line-height: 1.28;
  letter-spacing: -0.015em;
}
.cap .cap-sub {
  margin-top: 6px;
  font-size: 0.6rem;
  font-weight: 500;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  line-height: 1.5;
  /* Stage 56 item 1 (7 Sep): was `opacity: 0.72`, REMOVED.
     ⚠ CSS opacity is applied AFTER the colour is composited, so this was not a
     0.72 tint of the ink over the plate — it was a 0.72 multiplier on the whole
     composited pixel, and it put six of the nine sub-lines under the 4.5 floor
     while stage 53's instrument, which modelled the declared colour only, read
     them as passing. The hierarchy between line and sub is now carried by size
     and tracking alone: 9.6px uppercase at 0.09em against the reading line. */
}

/* Hero intro, in film coordinates (it lives inside the film box).
   The stacked lockup sits HIGH in the sky, clear above the cloud line
   (clouds start at ~32% of the frame); the Enquire button sits well below
   the house on the ridge (~55%), over the hillside, with the scroll cue
   at the foot. Light logo master: black wordmark for the pale sky. */
.hero-intro {
  position: absolute; inset: 0;
  z-index: 5;
  text-align: center;
  pointer-events: none;
  transition: opacity 0.4s linear;
}
.intro-lockup {
  position: absolute;
  /* 9%, was 4% (29 Aug): the svh anchor lifted the whole film ~110px, which
     carried the lockup into the bar's fade; 9% brings it back down the
     screen while its bottom still clears the ~32% cloud line. */
  left: 50%; top: 9%;
  transform: translateX(-50%);
  width: min(48vw, 215px);
}
.intro-lockup img {
  width: 100%;
  filter: drop-shadow(0 12px 32px rgba(43, 31, 30, 0.28));
  /* Optical centring (measured, 28 Aug): the artwork sits right of the file's
     centre — alpha centroid x = 336 of 600 (mark band 338, STUDIO band 330,
     so the two are mutually on one axis) against a file centre of 300. The
     6% shift puts that optical axis on the layout axis; the bounding box is
     NOT what is centred, the artwork's mass is. */
  transform: translateX(-6%);
}
.intro-low {
  position: absolute;
  left: 0; right: 0; top: 71%;
  display: flex; flex-direction: column; align-items: center;
  pointer-events: auto;
}
.hero-tag {
  margin-top: 14px;
  display: flex; align-items: center; gap: 12px;
}
.hero-tag i { flex: 1; height: 1px; background: linear-gradient(90deg, transparent, rgba(43, 31, 30, 0.4), transparent); }
.hero-tag em {
  font-style: normal;
  font-size: 0.66rem; font-weight: 500;
  letter-spacing: 0.34em; text-transform: uppercase;
  color: var(--ink);
  white-space: nowrap;
}
/* cream: the cue sits over the dark hillside below the house, not the sky */
.hero-cue {
  margin-top: 24px;
  font-size: 0.62rem; letter-spacing: 0.28em; text-transform: uppercase;
  color: rgba(242, 236, 227, 0.85);
  text-shadow: 0 2px 14px rgba(18, 16, 14, 0.4);
}
.hero-cue i {
  display: block; width: 1px; height: 38px; margin: 12px auto 0;
  background: linear-gradient(180deg, rgba(242, 236, 227, 0.7), transparent);
  animation: cue 2.4s ease-in-out infinite;
}
@keyframes cue { 0%, 100% { opacity: 0.35; transform: scaleY(0.6); } 50% { opacity: 1; transform: scaleY(1); } }

/* the two display lines over the descent, mobile. They live in film
   coordinates so they always sit on the sky, never on the bar's canvas. */
.design-lines {
  position: absolute;
  /* 11%, was 6% (29 Aug): the svh anchor lifted the film ~110px and the
     lines rode into the bar's fade. Their fade-out frame moves with them:
     roof meets the copy bottom at ~frame 217 now (was 220) — hero.js. */
  left: 5%; right: 5%; top: 11%;
  z-index: 4;
  text-align: center;
  pointer-events: none;
  opacity: 0;
  transform: translateY(16px);
  transition: opacity 0.6s var(--ease), transform 0.6s var(--ease);
}
.design-lines.on { opacity: 1; transform: none; }
.design-lines .display { display: block; font-size: clamp(2.4rem, 11.4vw, 8rem); white-space: nowrap; }
.design-lines .l2 { opacity: 0; transform: translateY(14px); transition: opacity 0.6s var(--ease), transform 0.6s var(--ease); }
.design-lines.l2on .l2 { opacity: 1; transform: none; }

/* ---- STAGE 6a: the served hero left column ----
   The homepage H1 ("We design it / Then we build it") lives in #heroLeft in
   the SERVED markup — hero.js animates it, it no longer generates copies.
   Base state (mobile, and desktop under reduced motion): the lockup and CTA
   are hidden, the heading is visually hidden but STAYS in the accessibility
   tree, so the outline is identical at every width; the film's own
   aria-hidden .design-lines remain the mobile visuals, untouched. */
.hero-left { position: absolute; top: 0; left: 0; pointer-events: none; }
.hero-left .hl-lockup, .hero-left .hl-cta, .hero-left .hero-tag,
.hero-left .hl-area { display: none; }   /* stage 68: the area line is desktop-only, like the lockup and the button */
.hl-heading {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%);
  white-space: nowrap;
}
.hs-label { display: none; }
.film-cue { display: none; }

/* ⚠ Stage 16c, §85 CLOSED. The desktop two-column hero applies only in
   LANDSCAPE — width >= height — as well as at >=1024. 16b proved the
   portrait case cannot be fixed by type: the wrap took the H1 from -125.5
   to -4.6 and revealed five offenders behind it (the skip button at -87.4,
   its arrow, its label, the Design & Build tag, the Enquire pill), every
   one a content-sized control that cannot wrap and that no font-size cap
   governs. A 128px column cannot hold a "Skip the film" button.

   The test is PORTRAIT, not the min-aspect-ratio: 5/4 priced in §110.
   5/4 would have thrown away 1024x900 (ratio 1.138), the cell 16b had just
   rescued to +22.6. min-aspect-ratio: 1/1 catches 1024x1366 (0.750) and
   every portrait tablet while leaving every landscape cell untouched,
   1024x900 included. The square case goes to desktop.

   SCOPE: the HERO only. The other 17 min-width:1024 blocks — nav, sections,
   footer, portfolio, about, contact, enquire — are NOT touched, so at
   1024 wide in portrait the page below the hero keeps the desktop layout
   that 1024px legitimately deserves, and the five other pages are
   unaffected at every viewport. Verified: the stacked hero renders
   correctly at that width (see §112). */
@media (min-width: 1024px) and (min-aspect-ratio: 1/1) and (prefers-reduced-motion: no-preference) {
  /* nothing sits on the film (Andrea): the intro lockup leaves it — the
     column below carries the full lockup instead. Reduced motion keeps the
     intro (its static stills page depends on it), which is why this whole
     block is scoped to no-preference. */
  .hero-intro { display: none; }
  .hero-left {
    top: var(--bar-h); bottom: 0; left: 0;
    width: var(--panel-w, 26vw);
    /* stage 10a item 1: z 4 -> 5. At 4 the later sibling .panel-l painted
       over this column and swallowed the skip's clicks and hover
       (elementFromPoint at the skip's centre returned the aside). The
       container is pointer-events: none, so nothing else is captured;
       .panel-r and this box never intersect (proven at all gate widths). */
    z-index: 5;
    display: flex; flex-direction: column; align-items: flex-start;
    padding: clamp(2rem, 4vw, 4.5rem);
    /* stage 10i-b: the width the column's type must fit — the film's left
       edge (published by layout() on the pin; the calc() fallback is the
       engine's own law, film left = 50vw − 0.28125 × 100svh, for the
       pre-JS column) minus this box's left inset minus a 12px safety gap.
       12px: the same minimum breath the right panel's phase box keeps
       from the film (clamp(12px, 1.2vw, 20px)'s floor), and an order of
       magnitude above the published value's sub-pixel rounding. */
    /* Stage 16: the 12px safety gap becomes the stage's guaranteed gutter
       (--hero-gutter). Everything else about this derivation is 10i-b's
       and is untouched: the film's published left edge minus this box's
       left inset minus the gap the type may not cross. */
    --col-w: calc(var(--film-left, calc(50vw - 0.28125 * 100svh)) - clamp(2rem, 4vw, 4.5rem) - var(--hero-gutter, 26px));
  }
  /* Stage 6b: the opening group holds through the exterior approach and is
     faded BY THE ENGINE across frames 258-352, in step with the portal
     cross-fade — no CSS transition here, the scroll drives it directly.
     Visible by default so a no-JS desktop still shows the column. The skip
     below it persists for the whole film. */
  .hero-left .hl-group {
    /* stage 6c: the group OWNS THE TOP of the column (Andrea's screenshots)
       — no auto top margin centring it; the auto bottom margin still sends
       the skip to the column's foot */
    margin: 0 0 auto;
    display: flex; flex-direction: column; align-items: flex-start;
    pointer-events: none;
  }
  .hero-left.on .hl-group { pointer-events: auto; }
  /* stage 10a item 3: markedly bigger (Andrea). The brief's starting
     clamp(240px, 26vw, 460px) measured 11px INTO the film at 1024x768, so
     the width is taken from the panel itself: panel width minus the left
     inset minus 16px breathing — it tracks the film at every size and can
     never touch it. The tag below shares the same width. */
  /* stage 10h item 5: the width-only cap crowded short viewports — a
     tablet's landscape height never entered the min(). The added term is
     the lockup's own 600x445 aspect solved for "no taller than 30svh",
     so the column's height finally bounds the mark that leads it. */
  /* Stage 16 item 4 (backlog B): the lockup is sized by the SAME
     derivation as the headings — the left column's usable width, --col-w,
     which is the film's real edge minus the inset minus the gutter. That
     replaces two hand-set terms: the flat 16px breathing (now the gutter,
     so the mark obeys the same rule as the type) and the arbitrary 26vw
     ceiling, which was never derived from anything and never bound at any
     gate cell. 10h's height guard STAYS: the lockup's own 600x445 solved
     for "no taller than 30svh" is what keeps a short landscape window from
     being led by an oversized mark, and it is the term that actually binds
     at 1440 and above.
     That guard is lifted 30svh -> 32svh, because the width derivation
     ALONE does not deliver the backlog wish: with the guard left at 30svh
     the lockup came out smaller at six gate cells and larger at none, the
     opposite of "larger when the display allows". Measured at 32svh it
     grows where the display is generous — 1440 364.0 -> 377.7, 1680
     364.0 -> 388.3, 1920 436.8 -> 460.0 — and still shrinks where it is
     not (1024x768 239.0 -> 232.5, 1024x1366 71.0 -> 64.5), the gutter
     taking its due. The cost is the left column's slack between the
     opening group and the skip at its foot; its tightest cell is
     1680x900, measured 64px at 30svh and 46px at 32svh, against the 24px
     minimum breath 10h itself declares on .hero-skip. Item 1's clearance
     is unaffected at every cell — the H1 is bound by --col-w, not by the
     mark above it. */
  .hero-left .hl-group { --hl-lockup-w: min(var(--col-w), 460px, calc(32svh * 600 / 445)); }
  .hero-left .hl-lockup {
    display: block;
    width: var(--hl-lockup-w); height: auto;
  }
  /* stage 10a item 3b: the Design & Build tag, desktop copy. The BASE
     .hero-tag rules (ink hairlines for the light intro ground) are
     untouched; only this scoped copy recolours for the dark portal. */
  .hl-group .hero-tag { display: flex; width: var(--hl-lockup-w); }
  .hl-group .hero-tag i { background: linear-gradient(90deg, transparent, rgba(242, 236, 227, 0.45), transparent); }
  .hl-group .hero-tag em { color: var(--cream); }
  .hl-heading {
    position: static; width: auto; height: auto;
    margin: clamp(1.4rem, 3.2vh, 2.4rem) 0 0; padding: 0;
    overflow: visible; clip: auto; clip-path: none;
    white-space: normal;
  }
  .hl-heading .hl-line {
    display: block;
    font-family: 'Instrument Sans', 'Schibsted Grotesk', sans-serif;
    /* stage 6c: substantially larger (Andrea) — it carries the column now.
       Stage 10i-b: the width-only clamp let a taller viewport march the
       film's edge left under a type size that never gave way ("Then we
       build it" crossed by ~49px at 1440×1200). The second min() term
       caps the size by the column the line must fit: --col-w divided by
       the line's MEASURED width — 6.995em, Instrument Sans 500 at
       −0.02em tracking, 391.73px @ 56px and 699.50px @ 100px, with the
       font's load verified before measuring (an earlier probe read
       6.684 off the FALLBACK font — a page that never loaded Instrument
       Sans — and shipped, it would have left the type 1–10px inside the
       film with every gate green). The inner
       max() keeps the clamp's own 1.9rem floor: the cap may shrink the
       type, never below legibility (on a portrait tablet the columns
       collapse to ~128px — a recorded open layout defect, §85, that no
       font size fixes). Still ONE line per line at every gate cell. */
    font-size: min(clamp(1.9rem, 5vw - 16px, 4.9rem), max(1.9rem, calc(var(--col-w) / 6.995)));
    font-weight: 500;
    line-height: 1.12;
    letter-spacing: -0.02em;
    white-space: nowrap;
  }
  .hl-heading .hl-line + .hl-line { margin-top: 0.2em; }
  /* Stage 16b: the wrap that rescues the tall-and-narrow cells.
     Both of stage 16's failures came from ONE constraint — nowrap on this
     line — which makes the cap the width-per-em of the whole string
     "Then we build it" (6.9950). Once the column is too narrow for that at
     the 1.9rem floor, the line cannot shrink and simply runs onto the film.
     If it may WRAP, the binding width becomes the longest WORD instead:
     measured, font loaded, "design" at 3.0161em (not "build" at 2.2123 —
     the wider word is in the other line).

     ⚠ SCOPED, and the scope is the point. The font-size formula above is
     NOT touched: inside this block the line still sizes exactly as it did,
     so wherever nowrap already fitted, the same size still fits on one line
     and nothing wraps — the rendering is unchanged. The wrap only ever
     engages where the line was ALREADY overflowing onto the film.
     max-aspect-ratio: 5/4 is a deliberate SUPERSET of the true failing
     region (nowrap fails when --col-w < 212.65px, i.e. vh > 1.5573vw - 756
     for the widths where the paddings run linear). It cannot miss a failure
     at >=1024 — that would need vw < 998 — and where it over-applies, the
     line still fits and does not wrap. It is also the exact complement of
     the min-aspect-ratio: 5/4 that option (b) would have used.

     The width is set to --col-w so the wrap boundary IS the gutter line.
     Without it the heading shrink-to-fits against the flex container's
     content box (--panel-w minus TWO paddings), which is narrower than
     --col-w, and the line would break earlier than the gutter requires. */
  @media (max-aspect-ratio: 5/4) {
    .hl-heading { width: var(--col-w); }
    .hl-heading .hl-line { white-space: normal; }
  }
  .hero-left .hl-cta { display: block; margin-top: clamp(1.8rem, 4vh, 2.8rem); }
  /* ⚠ STAGE 68 — the service-area line, a smaller sibling of the h1.
     SAME family, SAME weight, SAME -0.02em tracking as .hl-heading .hl-line,
     and its size is a fraction of the h1's OWN derived size (--hl-area, set
     with --hl-h1 in the fit block below), so it tracks the title at every
     viewport instead of being a second hand-set scale.
     ⚠ Solid --cream, NOT the .wave-light gradient the h1 wears. Two reasons:
     "quieter" is the brief, and a clipped gradient cannot be contrast-measured
     against a ground the way a solid colour can. On the pin's --canvas #12100E
     ground this reads 16.17:1. */
  .hero-left .hl-area {
    display: block;
    margin-top: var(--hl-area-m);
    font-family: 'Instrument Sans', 'Schibsted Grotesk', sans-serif;
    font-size: var(--hl-area);
    font-weight: 500;
    line-height: 1.2;
    letter-spacing: -0.02em;
    color: var(--cream);
    max-width: var(--col-w);
  }
  /* the skip, re-homed to the column's foot: same button, same behaviour,
     now an arrow with its words */
  .hero-left .hero-skip {
    position: static;
    width: auto; height: 44px;
    padding: 0 20px 0 14px;
    border-radius: var(--radius-pill);
    display: flex; align-items: center; gap: 10px;
    pointer-events: auto;
    /* stage 10h item 5: the group's auto bottom margin sends the skip to
       the foot but promises nothing when the column runs short — this is
       the guaranteed minimum breath between Enquire and the skip. */
    margin-top: 24px;
  }
  .hero-left .hs-label {
    display: inline;
    font-size: 0.72rem; font-weight: 500;
    letter-spacing: 0.18em; text-transform: uppercase;
    white-space: nowrap;
  }
  /* the cue moves ONTO the film — the one thing allowed there — bottom
     centre with the moving line, fading out as the film starts (driven by
     the engine on the intro's own curve) */
  .film-cue {
    display: block;
    position: absolute;
    left: 0; right: 0;
    /* 12.2 (Andrea's iPad recording: the cue half-clipped by the fold and
       moving with the toolbar). The pin is 100lvh — toolbar hidden — and
       11h made the film fill it, so anything anchored to the pin's bottom
       sits lvh − svh below the fold whenever the toolbar shows. The cue
       anchors to the SMALL viewport's bottom instead: 22px above the fold
       with the toolbar up, and where svh = lvh (every fixed-toolbar
       browser, and this harness) it computes to exactly the 22px it was.
       The pin and the film are untouched. Right by construction, not by
       measurement — stated in §101. */
    bottom: calc(22px + 100lvh - 100svh);
    z-index: 5;
    margin: 0;
    text-align: center;
    /* 11i item 6 (Andrea's ruling: BOTH cues stay, and this one was "way
       too small" — more cue, so people understand they can scroll). Up
       from 0.62rem; same small-caps register, same tracking, same static
       shadow, same animated hairline, same engine-driven fade. */
    font-size: 0.8rem; letter-spacing: 0.28em; text-transform: uppercase;
    color: rgba(242, 236, 227, 0.85);
    text-shadow: 0 2px 14px rgba(18, 16, 14, 0.4);
    pointer-events: none;
  }
  .film-cue i {
    display: block; width: 1px; height: 34px; margin: 12px auto 0;
    background: linear-gradient(180deg, rgba(242, 236, 227, 0.7), transparent);
    animation: cue 2.4s ease-in-out infinite;
  }
}

/* stage 10a item 4: phone and email at the hero's bottom right (Andrea:
   contact details findable without leaving the first screen). Served in
   the HTML, desktop-only — nothing below 1024 (display: none; the footer
   and /contact/ carry these on mobile). The quiet .work-btn pill family,
   NOT the enquiry .cta (ruled: three animated rims here is a real cost). */
.hero-right-foot { display: none; }

/* desktop side panels */
.panel { display: none; }

@media (min-width: 1024px) {
  :root {
    --bar-h: 72px;
    /* Stage 16 item 1b (Andrea): a guaranteed empty gutter on EACH side of
       the film that no hero content may enter. Measured from the film's
       REAL edge — layout() publishes --film-left (rounded, the same value
       the film box is positioned with) and --panel-w, so both columns
       derive from what actually rendered, never from a CSS declaration.
       It replaces the left column's flat 12px and the right column's
       clamp(12px, 1.2vw, 20px): about 22px at 1024 and 42px at 1920, so
       the breathing room grows with the display instead of looking mean
       on a large one. */
    /* ⚠ Stage 31: the floor rises 20px -> 26px. MEASURED, and it is the whole
       fix: wherever the H1 cap binds, the clearance between the type and the
       film IS this gutter, exactly. 2.2vw is 22.53px at 1024 and 25.96px at
       1180, so both fell under the 24px clearance floor while every wider cell
       already passed. 26px rather than 24px so no cell sits ON the line, which
       the +/-1px instrument noise on this site makes indistinguishable from
       failing. Above 1181px viewport width 2.2vw already exceeds 26px and
       NOTHING changes -- the H1 keeps its size at 1280 and above.
       Both columns derive from this one value, so it fixes both sides at once. */
    --hero-gutter: clamp(26px, 2.2vw, 44px);
  }
}

/* the hero's own desktop block — landscape only, per 16c above. --bar-h
   stays in the plain >=1024 root immediately above: it sizes the site nav
   on every page and must not follow the hero. */
@media (min-width: 1024px) and (min-aspect-ratio: 1/1) {
  .design-lines { display: none; }
  .film-captions { display: none; }
  .intro-lockup { top: 50%; transform: translate(-50%, -50%); }
  .intro-low { display: none; }
  /* Stage 4: the material-portal photograph behind the film. Built by
     hero.js in the desktop path only — mobile never constructs or fetches
     it. BOTTOM-ANCHORED, non-negotiable: the photograph's wall-to-ledge
     seam reads as a shelf and the film must appear to stand on it, so
     cover-cropping happens at the top and sides, never the bottom. The
     engine drives its opacity (held to frame 258, gone by 352). */
  .pin-bg {
    position: absolute;
    inset: 0;
    z-index: 0;
    width: 100%; height: 100%;
    object-fit: cover;
    object-position: center bottom;
    pointer-events: none;
  }

  /* ---- STAGE 5: the hero light (31 Aug) ----
     Three fixed composited layers on the §65 pattern: transform-only
     animation, long alternating cycles, plain alpha (no blend modes, no
     grain), tunable values declared HERE on the pin's children — no runtime
     writes anywhere, so nothing enters the scroll loop. Built by hero.js in
     the desktop path only; none of it exists below 1024. */

  /* B: ambient side motion. Two big soft radials drifting on 34s/47s
     alternate cycles, masked so the energy lives in the outer thirds and is
     gone before the film. Palette sampled from the portal master (Andrea):
     highlight #FACD8F softened, seam #9D653A, shadow #201710. The motion is
     continuous across the whole film — it never restarts when the ground
     sequence fades at 258/566/850; the warm light simply reads over each
     ground. --amb-o is the one-word tuning knob. */
  .pin-ambient {
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    /* Stage 9 (Andrea: the wave did not read). Three levers moved
       together: SCREEN blending — the handover's own spec — so the warm
       radial lifts a near-black ground instead of vanishing into it; the
       mask now holds full strength to 26% and reaches 40% before it is
       gone, so the motion lives across the whole panel rather than only
       the darkest outer strip (0 by 40%: at every gate width the film's
       edge sits at 30-35%, so no light ever appears beyond the film —
       everything past the panel is beneath it); and --amb-o set to what
       those two leave right. The stage-5 "no blend modes" note yields to
       the handover spec for THIS layer only, by Andrea's stage-9 brief. */
    mix-blend-mode: screen;
    opacity: var(--amb-o, 0.24);
    -webkit-mask-image: linear-gradient(90deg, #000 0%, rgba(0,0,0,0.9) 26%, transparent 40%, transparent 60%, rgba(0,0,0,0.9) 74%, #000 100%);
    mask-image: linear-gradient(90deg, #000 0%, rgba(0,0,0,0.9) 26%, transparent 40%, transparent 60%, rgba(0,0,0,0.9) 74%, #000 100%);
  }
  .pin-ambient::before,
  .pin-ambient::after {
    content: '';
    position: absolute;
    width: 58%; height: 120%; top: -10%;
    border-radius: 50%;
    will-change: transform;
  }
  .pin-ambient::before {
    left: -20%;
    background: radial-gradient(ellipse at 42% 36%,
      rgba(248, 204, 146, 0.55) 0%, rgba(157, 101, 58, 0.30) 34%, rgba(32, 23, 16, 0) 68%);
    animation: amb-left 34s ease-in-out infinite alternate;
  }
  .pin-ambient::after {
    right: -20%;
    background: radial-gradient(ellipse at 58% 62%,
      rgba(157, 101, 58, 0.45) 0%, rgba(248, 204, 146, 0.26) 30%, rgba(32, 23, 16, 0) 66%);
    animation: amb-right 47s ease-in-out infinite alternate;
  }

  /* C: the drafting lines — copper construction geometry drifting very
     slowly above the ambient light, below everything that matters. Its own
     centre is empty, and the side mask keeps it off the film and soft under
     the panel text. --draft-o is Andrea's one-word knob: more, less, or 0. */
  .pin-draft {
    position: absolute;
    inset: -1.5%;
    z-index: 0;
    pointer-events: none;
    background: url('../../_assets/website/desktop-hero/p3-drafting-lines-overlay.svg') 50% 50% / cover no-repeat;
    opacity: var(--draft-o, 0.5);
    -webkit-mask-image: linear-gradient(90deg, #000 0%, rgba(0,0,0,0.7) 26%, transparent 40%, transparent 60%, rgba(0,0,0,0.7) 74%, #000 100%);
    mask-image: linear-gradient(90deg, #000 0%, rgba(0,0,0,0.7) 26%, transparent 40%, transparent 60%, rgba(0,0,0,0.7) 74%, #000 100%);
    animation: draft-drift 71s ease-in-out infinite alternate;
    will-change: transform;
  }

  /* A: the film edge light. The film reads as set INTO the wall, lit along
     its reveals: a fine warm line down each side whose brightness varies
     along its length, a restrained spill onto the material beside it, and a
     soft highlight travelling each line at its own pace — the two sides on
     different cycles and phases so they never read as a matched pair. The
     bottom edge stays dark: the film sits on the ledge. */
  .film-light {
    position: absolute;
    z-index: 1;
    pointer-events: none;
  }
  .film-light i { position: absolute; display: block; }
  .fl-left, .fl-right {
    top: 0; bottom: 0; width: 30px;
    /* the line dies away before the corners */
    -webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 10%, #000 90%, transparent 100%);
    mask-image: linear-gradient(180deg, transparent 0%, #000 10%, #000 90%, transparent 100%);
    overflow: hidden;
  }
  .fl-left { left: -28px; }
  .fl-right { right: -28px; }
  /* the reveal line, brightness varying along its run */
  .fl-left::before, .fl-right::before {
    content: '';
    position: absolute; top: 0; bottom: 0; width: 1.5px;
    background: linear-gradient(180deg,
      rgba(250, 205, 143, 0) 0%, rgba(232, 167, 101, 0.42) 20%,
      rgba(255, 228, 196, 0.62) 46%, rgba(232, 167, 101, 0.38) 74%,
      rgba(250, 205, 143, 0) 100%);
  }
  .fl-left::before { right: 0; }
  .fl-right::before { left: 0; }
  /* the spill onto the material beside the reveal */
  .fl-left::after, .fl-right::after {
    content: '';
    position: absolute; inset: 0;
  }
  .fl-left::after { background: linear-gradient(270deg, rgba(200, 130, 74, 0.16) 0%, rgba(157, 101, 58, 0.07) 40%, transparent 100%); }
  .fl-right::after { background: linear-gradient(90deg, rgba(200, 130, 74, 0.16) 0%, rgba(157, 101, 58, 0.07) 40%, transparent 100%); }
  /* the travelling highlight: a soft radial segment, gradient-soft rather
     than filter-blurred (§72: nothing paints outside its own box), moving
     on transform alone */
  .fl-left b, .fl-right b {
    position: absolute; top: 0; height: 34%; width: 200%;
    animation: fl-travel 11.5s ease-in-out infinite alternate;
  }
  .fl-left b { right: -50%; background: radial-gradient(ellipse 60% 50% at 75% 50%, rgba(255, 235, 211, 0.5) 0%, rgba(232, 167, 101, 0.2) 45%, transparent 72%); }
  .fl-right b { left: -50%; background: radial-gradient(ellipse 60% 50% at 25% 50%, rgba(255, 235, 211, 0.5) 0%, rgba(232, 167, 101, 0.2) 45%, transparent 72%); animation-duration: 14.7s; animation-delay: -6.4s; }
  /* the top light, Andrea's override of the handover: a gold line along the
     film's top edge. It sits under the bar scrim (0.85 alpha at y=0), so
     much of it is absorbed — measured and reported for his ruling. */
  .fl-top {
    left: 0; right: 0; top: 0; height: 12px;
    -webkit-mask-image: linear-gradient(90deg, transparent 0%, #000 6%, #000 94%, transparent 100%);
    mask-image: linear-gradient(90deg, transparent 0%, #000 6%, #000 94%, transparent 100%);
    overflow: hidden;
  }
  .fl-top::before {
    content: '';
    position: absolute; left: 0; right: 0; top: 0; height: 1.5px;
    background: linear-gradient(90deg,
      rgba(250, 205, 143, 0) 0%, rgba(232, 167, 101, 0.5) 22%,
      rgba(255, 228, 196, 0.7) 50%, rgba(232, 167, 101, 0.5) 78%,
      rgba(250, 205, 143, 0) 100%);
  }
  .fl-top b {
    position: absolute; top: -100%; height: 300%; width: 26%; left: 0;
    background: radial-gradient(ellipse 50% 40% at 50% 33%, rgba(255, 235, 211, 0.55) 0%, rgba(232, 167, 101, 0.22) 45%, transparent 72%);
    animation: fl-travel-x 17s ease-in-out infinite alternate;
    animation-delay: -3s;
  }
}
@keyframes amb-left {
  from { transform: translate3d(-3%, -2%, 0) rotate(-3deg) scale(0.96); }
  to   { transform: translate3d(7%, 4%, 0) rotate(4deg) scale(1.06); }
}
@keyframes amb-right {
  from { transform: translate3d(4%, 5%, 0) rotate(3deg) scale(1.04); }
  to   { transform: translate3d(-6%, -4%, 0) rotate(-4deg) scale(0.97); }
}
@keyframes draft-drift {
  from { transform: translate3d(-0.6%, -0.4%, 0) scale(1); }
  to   { transform: translate3d(0.6%, 0.5%, 0) scale(1.012); }
}
@keyframes fl-travel { from { transform: translateY(0); } to { transform: translateY(194%); } }
@keyframes fl-travel-x { from { transform: translateX(0); } to { transform: translateX(285%); } }
@media (prefers-reduced-motion: reduce) {
  /* the §65 pattern: the light holds at the mid-point of its own travel and
     stays visible. (The desktop reduced-motion path never builds these
     layers today; these rules make the freeze correct if that ever changes.) */
  .pin-ambient::before { animation: none; transform: translate3d(2%, 1%, 0) scale(1.01); }
  .pin-ambient::after { animation: none; transform: translate3d(-1%, 0.5%, 0) scale(1.005); }
  .pin-draft { animation: none; }
  .fl-left b, .fl-right b { animation: none; transform: translateY(97%); }
  .fl-top b { animation: none; transform: translateX(142%); }
}
@media (min-width: 1024px) and (min-aspect-ratio: 1/1) {
  .panel .p-cue {
    margin-top: 30px;
    font-size: 0.62rem; letter-spacing: 0.28em; text-transform: uppercase;
    opacity: 0.75;
  }
  .panel .p-cue i {
    display: block; width: 1px; height: 34px; margin-top: 12px;
    background: linear-gradient(180deg, currentColor, transparent);
    opacity: 0.6;
    animation: cue 2.4s ease-in-out infinite;
  }
}

/* The pale-ground chrome flip is retired (29 Aug): the bar is ONE fixed
   scrim everywhere — a bar that swapped its gradient per section was still
   a changing bar, and its pale state was a weak scrim. The fixed dark
   gradient keeps cream marks legible over every ground. */
@media (min-width: 1024px) and (min-aspect-ratio: 1/1) {
  .panel {
    display: block;
    position: absolute;
    top: var(--bar-h); bottom: 0;
    width: var(--panel-w, 26vw);
    z-index: 4;
    padding: clamp(2rem, 4vw, 4.5rem);
    color: var(--panel-fg, var(--ink));
    transition: color 0.5s linear;
  }
  .panel-l { left: 0; }
  .panel-r { right: 0; text-align: right; display: flex; flex-direction: column; align-items: flex-end; justify-content: center; }
  .panel-l { display: flex; flex-direction: column; justify-content: center; }

  .panel .p-phase {
    position: absolute;
    left: clamp(2rem, 4vw, 4.5rem); right: clamp(2rem, 4vw, 4.5rem);
    /* stage 8, Andrea's ruling: phase content centres on the VISIBLE
       REGION, not the panel box. The box starts below the bar, so its own
       midline sat half the bar height low (the measured constant +36px);
       the bias is expressed in the variable so it tracks any bar-height
       change. The film and the bar do not move. */
    top: calc(50% - var(--bar-h) / 2); transform: translateY(calc(-50% + 18px));
    opacity: 0;
    transition: opacity 0.6s var(--ease), transform 0.6s var(--ease);
    pointer-events: none;
  }
  .panel .p-phase.on { opacity: 1; transform: translateY(-50%); pointer-events: auto; }
  /* stage 7: the right column's content area widens — the LEFT edge moves
     toward the film (the panel's left edge IS the film's right edge), the
     right edge stays where it is (Andrea) */
  /* Stage 16: the right column's inset toward the film becomes the same
     guaranteed gutter as the left's. This is the .p-phase inset, so the
     scroll-driven caption lines honour it too — they are the right column
     during the film. */
  .panel-r .p-phase { left: var(--hero-gutter, 26px); }

  .panel .p-display {
    font-family: 'Instrument Sans', sans-serif;
    font-size: clamp(2.6rem, 3.6vw, 4.6rem);
    font-weight: 500; line-height: 0.98; letter-spacing: -0.03em;
  }
  .panel .p-display + .p-display { margin-top: 0.35em; }
  /* Stage 4 fix, Andrea's ruling (option c): the DESKTOP PANEL wave floors
     its darkest stop at #E1AA6A. The site-wide .wave-light runs down to
     #C9835A, which measured 4.28:1 over the portal's lit wall pixels at
     frame 1 — below the 4.5 floor. Scoped to .panel .wave-panel ONLY: the
     page-level .wave-light / .wave-dark used by the other pages' H1s are
     untouched. Same stop positions, so the wave's rhythm is unchanged. */
  .panel .wave-panel.wave-light {
    background-image: linear-gradient(105deg,
      #F2DCC0 0%, #E1AA6A 24%, #E1AA6A 44%, #E1AA6A 62%, #F2DCC0 80%, #E1AA6A 100%);
  }
  /* Stage 6c, Andrea's ruling (a): the H1's wave floored at #E1AA6A — the
     identical remedy applied to the identical cause as the panel floor
     above (the wave's darkest stop #C9835A over a dark ground; here the
     portal photograph's doorway, which the enlarged heading now reaches).
     Scoped to .hero-left ONLY: the page-level .wave-light on the other
     pages' H1s keeps its full stop chain. Same stop positions, so the
     wave's rhythm is unchanged. Measured 4.96:1+ across frames 1-352 at
     1280/1440/1920 with the stage-5 layers at both travel extremes.
     (Stage 10e's plan to widen this to the hero phone number is HELD with
     item 2 — the scope below is unchanged.) */
  .hero-left .wave-panel.wave-light {
    background-image: linear-gradient(105deg,
      #F2DCC0 0%, #E1AA6A 24%, #E1AA6A 44%, #E1AA6A 62%, #F2DCC0 80%, #E1AA6A 100%);
  }
  .panel .p-phase .cta-wrap { margin-top: 40px; }

  .panel .p-room {
    font-family: 'Instrument Sans', sans-serif;
    font-size: clamp(1.4rem, 1.9vw, 2.2rem);
    font-weight: 500; letter-spacing: -0.02em;
  }
  .panel .p-rule {
    width: 100%; height: 1px;
    background: currentColor; opacity: 0.25;
    transform-origin: left center;
  }
  .panel-r .p-rule { transform-origin: right center; }

  /* ---- Selected Work (stage 8): the right column's opening state ----
     A vertical stack — the front card fully visible, the two behind
     offset by exactly one name-row so their title bars peek below
     (occlusion, never a crop: the 4:3 photographs render width 100%,
     height auto, no object-fit). Hover or focus brings a card forward
     with a light travelling its top edge; the pointer tilt lives in the
     engine (transform-only, mounted only while the stack shows). The
     phase's opacity is engine-driven on the opening curve, so its own
     transition carries transform only. */
  /* stage 10g: the open phase stops being a centred block and becomes a
     top-anchored COLUMN — label, mosaic (which takes all the run), See
     the work — ending above a reserved slice for .hero-right-foot, whose
     4.74:1 must keep its dark ground (Andrea's ruling: "the entire
     space" is the panel's space between label and button, never the
     phone number's box). The centring bias belonged to short centred
     content; a full-height column doesn't centre. */
  .panel-r .p-open, .panel-r .p-open.on {
    top: 26px;
    /* the reserved slice mirrors .hero-right-foot's own construction —
       its vertical padding pair plus the number's type size — so the
       column ends a fixed 20px above the foot at every width */
    bottom: calc(2 * clamp(2rem, 4vw, 4.5rem) + clamp(1.6rem, 4vw - 14px, 3.9rem) + 20px);
    transform: none;
    display: flex; flex-direction: column;
    /* Stage 16 item 3a: the block centres in its column instead of sitting
       at the top. The band it centres in is THIS box — below the bar and
       above the reserved foot slice — not the pin. SAFE centring: where
       the content is taller than the band (measured: 1680x900 already
       overran it before this stage), `safe` falls back to flex-start so
       the overflow spills downward as it does today, never upward behind
       the fixed bar. */
    justify-content: safe center;
    /* Stage 16 item 1a (Andrea): the right column aligns RIGHT, mirroring
       the left column's natural left alignment — 11g's left-aligned
       statement is reversed here.
       ⚠ align-items STAYS flex-start, deliberately. Right alignment is
       carried by each part's own `margin-left: auto` instead, because the
       two differ exactly where it matters: when a nowrap line is WIDER
       than the column (which happens once the 1.9rem floor stops the cap
       shrinking it — see §110), `text-align: right` and `align-items:
       flex-end` both overflow to the LEFT, i.e. straight onto the film,
       while an auto margin against a flex-start item resolves to zero and
       the line overflows to the RIGHT, away from it. The gutter is
       therefore honoured by construction in both cases. */
    align-items: flex-start;
    text-align: right;
    transition: opacity 0.6s var(--ease);
  }
  /* the auto margin is what right-aligns each part; .hs2-imgs is excluded
     because it stretches to the full column by design (its two cards span
     the width and an auto margin would cancel that stretch). */
  .panel-r .p-open > .hs2-title,
  .panel-r .p-open > .hs2-para { margin-left: auto; }
  /* ---- stage 11g (Andrea's render, superseding 10h's bands): the right
     column as a second statement ----
     Title at the LEFT H1's construction: the same clamp ceiling, capped
     by the column the longer line must fit — "considered as one."
     measures 8.247em at Instrument Sans 500 / -0.02em (font-verified),
     so the cap is the panel's content width over that ratio, 10i-b's
     move with this column's own numbers. The wave treatment floors at
     #E8BE8C, the phone's 10e-b stop, NOT the left's #E1AA6A: the §78
     sweep measured this corner's ground ceiling L 0.0697 (frames 1-300,
     44s of wave), where #E1AA6A reads 4.23 and #E8BE8C 5.07 (floor
     4.5). The paragraph and captions are NOT cream-muted despite the
     render's grey: cream-muted measured 3.72 worst over this ground —
     they carry cream at .86, ~7.5:1 worst, subordination by size and
     alpha instead. */
  /* Stage 12.4 (Andrea): "Design and build, considered as one." was the
     third appearance of that phrase on one screen. Now three lines — One
     detail / One room / The whole home — as a RATIO, not three hand-set
     sizes: the largest (third) line takes the capped heading size, the
     middle one step below it, the first two steps below, step 1.07
     ("ever so slightly visible" — never past 1.10). The cap is applied
     to the LARGEST line and divides the column by that line's OWN
     measured width — "The whole home" is 7.435em at Instrument Sans 500
     / -0.02em (font-verified; "considered as one." re-measured at the
     same 8.247 11g recorded) — so the block cannot outgrow the column
     at any width. */
  /* ⚠ Stage 38b, widened at 38c: --hs2-size is declared on :root, not on
     .hs2-title. Nothing about the title changes -- it inherits the identical
     value and .hs2-line still reads it -- but the whole hero can now see it,
     which is what lets the arithmetic at the foot of this file know the
     title's height without measuring it. 38b put it on the column; 38c needs
     it one level higher because .hero-right-foot is the column's SIBLING and
     could not inherit from it. */
  :root {
    --hs2-size: min(clamp(1.9rem, 5vw - 16px, 4.9rem),
      max(1.9rem, calc((var(--panel-w, 26vw) - var(--hero-gutter, 26px) - clamp(2rem, 4vw, 4.5rem)) / 7.435)));
  }
  .hs2-title { margin: 0; }
  .hs2-line {
    display: block;
    font-family: 'Instrument Sans', 'Schibsted Grotesk', sans-serif;
    font-weight: 500; line-height: 1.12; letter-spacing: -0.02em;
    white-space: nowrap;
    font-size: var(--hs2-size);
  }
  .hs2-line:nth-child(1) { font-size: calc(var(--hs2-size) / 1.1449); }
  .hs2-line:nth-child(2) { font-size: calc(var(--hs2-size) / 1.07); }
  .panel-r .hs2-line.wave-panel.wave-light {
    background-image: linear-gradient(105deg,
      #F2DCC0 0%, #E8BE8C 24%, #E8BE8C 44%, #E8BE8C 62%, #F2DCC0 80%, #E8BE8C 100%);
  }
  .hs2-para {
    /* Stage 16 item 3b (Andrea, "a little" more): the three gaps down this
       column — title -> paragraph -> tiles -> button — now carry a term
       that scales with the COLUMN (--panel-w, published by layout()) on
       top of the viewport-height term they already had, so the rhythm
       opens up as the column widens instead of being set in px. The
       height term is kept so a short window still tightens them. */
    margin: clamp(16px, calc(1.6svh + var(--panel-w, 26vw) * 0.025), 26px) 0 0;
    font-size: clamp(0.92rem, 1vw, 1.05rem);
    line-height: 1.55;
    color: rgba(242, 236, 227, 0.86);
    max-width: 40ch;
  }
  /* 12.3: the .hs2-rule hairline and the .hs2-cue line are gone with the
     second scroll cue — see the builder in hero.js. */
  /* two supporting images: the gap between them is the flex gap —
     TRANSPARENT, the hero's own ground, 10h's rule. Edge lighting reuses
     the film's OWN .fl-left/.fl-right vocabulary (the line and its
     spill; the travelling highlight stays the film's alone, so nothing
     here animates at rest and the column never competes with the film).
     Top/bottom fades are .img-blend, the site's one image-fade pattern. */
  .hs2-imgs {
    display: flex;
    gap: clamp(16px, 1.8vw, 26px);
    margin-top: clamp(18px, calc(1.9svh + var(--panel-w, 26vw) * 0.03), 30px);
    align-self: stretch;
  }
  .hs2-card {
    flex: 1 1 0; min-width: 0;
    text-decoration: none; color: inherit;
  }
  .hs2-frame { position: relative; display: block; }
  .hs2-frame img {
    display: block; width: 100%;
    height: clamp(120px, 22svh, 200px);   /* cropped: never a whole photograph */
    object-fit: cover;
  }
  .hs2-frame .film-light { inset: 0; }
  /* 11h item 4: the gap between the two images must read as the hero's
     own ground. Each inner edge-light juts 28px into a 16-26px gap with
     a 30px spill, so the two spills overlapped and filled it — measured
     gap ~45% brighter than the ground beside the cards, and the pair
     read as one lit block. The INNER spills (::after) come off; the
     1.5px reveal lines stay — they mark the edges without washing the
     gap. Outer edges keep the film's full vocabulary. */
  .hs2-card:first-child .fl-right::after,
  .hs2-card:last-child .fl-left::after { content: none; }
  .hs2-cap {
    display: block;
    margin-top: 7px;
    font-size: 0.62rem; font-weight: 500;
    letter-spacing: 0.2em; text-transform: uppercase;
    color: rgba(242, 236, 227, 0.86);
    transition: color 0.3s var(--ease);
  }
  .hs2-card:hover .hs2-cap, .hs2-card:focus-visible .hs2-cap { color: var(--copper-lit); }
  .hs2-card:focus-visible { outline: 2px solid var(--cream); outline-offset: 3px; border-radius: 4px; }
  .work-btn.sw-more {
    display: inline-block;
    /* 11h item 6: under the RIGHT image (Outdoor spaces) — the pill's
       right edge aligns with that image's right edge, still content-sized.
       Stage 16: expressed as flex-start + an auto left margin rather than
       align-self: flex-end. Where the pill FITS the two are identical (the
       auto margin absorbs all the free space and lands it flush right);
       where it does not — 11h's "can never overflow the column" is not
       true at 1024x1366, where the column is 64px and the pill ~180px —
       flex-end overflows LEFT onto the film and this overflows right,
       away from it. Same rule as the headings above. */
    align-self: flex-start;
    margin-left: auto;
    margin-top: clamp(18px, calc(1.9svh + var(--panel-w, 26vw) * 0.03), 30px);
    padding: 13px 30px;
    border: 1px solid rgba(242, 236, 227, 0.4);
    border-radius: var(--radius-pill);
    color: var(--cream);
    text-decoration: none;
    font-size: 0.74rem; font-weight: 500;
    letter-spacing: 0.18em; text-transform: uppercase;
    transition: border-color 0.3s var(--ease), color 0.3s var(--ease), box-shadow 0.3s var(--ease);
  }
  .work-btn.sw-more:hover, .work-btn.sw-more:focus-visible {
    border-color: rgba(242, 236, 227, 0.85);
    box-shadow: 0 0 18px -6px rgba(242, 236, 227, 0.4);
  }
  .work-btn.sw-more:focus-visible { outline: 2px solid var(--cream); outline-offset: 4px; }

  /* ---- the expanded view (10f, approved at 10g, retired at 11g with
     the bands, RESTORED at 11h item 5 for the column's two images) ----
     A card click opens the photograph in place (the anchor keeps its
     served /portfolio#slug href — the script preventDefaults; without JS
     the link navigates and the crawler keeps the links). Only "See the
     work" navigates with JS on. The film must not move a frame while
     the view is open — since 12.1 by the view absorbing its own input,
     not by locking the document. */
  /* 12.1: no document lock any more — the view absorbs its own touches
     (touch-action, overscroll-behavior; the script stops propagation and
     swallows wheel/keys) and the document's scroll is never touched */
  .sw-view {
    position: fixed; inset: 0; z-index: 200;
    touch-action: none; overscroll-behavior: contain;
    display: flex; align-items: center; justify-content: center;
    opacity: 0; visibility: hidden;
    transition: opacity 0.25s var(--ease), visibility 0s linear 0.25s;
  }
  .sw-view.on { opacity: 1; visibility: visible; transition: opacity 0.25s var(--ease); }
  .sw-view-back {
    position: absolute; inset: 0;
    background: rgba(18, 16, 14, 0.78);
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(10px);
  }
  .sw-view-box {
    position: relative; margin: 0;
    /* ⚠ stage 47: the growth. The transform is set by hero.js's FLIP and
       released on the next frame; this is the only thing that animates it, and
       it is transform ONLY -- no width, no height, no margin -- so the whole
       travel is compositor work. transform-origin is the box's own centre
       because the FLIP is computed centre-to-centre. Matched to the backdrop's
       0.25s so the two are one motion, not two. */
    transform-origin: 50% 50%;
    transition: transform 0.25s var(--ease);
    /* stage 10h item 4 (Andrea, on the iPad): the view opened too big —
       the old min(86vw, 1180px) box with an 82vh photograph owned the
       whole screen. The photo box now caps at min(78vw, 70vh, 900px):
       the 70vh WIDTH term is what shrinks it on the short landscape
       tablet. Aspect is untouched — width caps, height follows. */
    /* ⚠ stage 47: DEFINITE, not just a cap. As a max-width this box was a
       shrink-to-fit flex item whose width came from the image's intrinsic size,
       so before the file loaded it measured 285px and only became 630px when
       the bytes arrived. The FLIP measured that 285 and started the card in the
       wrong place; the image's own width/height attributes could not fix it,
       because `width: 100%` on the image makes its contribution indeterminate.
       The same expression as a WIDTH is identical once loaded — the files are
       1440 wide and this cap is at most 900, so the cap always bound anyway —
       and it is known before a byte arrives. */
    width: min(78vw, 70vh, 900px);
    max-width: 100%;
  }
  .sw-view-box img {
    display: block;
    width: 100%; height: auto;          /* the photograph's own 4:3 — no crop */
    max-height: 70vh;                   /* guard only: a 4:3 file under the
                                           box cap can never reach it */
    border-radius: 14px;
    box-shadow: 0 30px 80px -30px rgba(0, 0, 0, 0.7);
  }
  .sw-view-box figcaption {
    margin-top: 14px;
    font-size: 0.74rem; font-weight: 500;
    letter-spacing: 0.18em; text-transform: uppercase;
    color: var(--cream);
    text-align: center;
  }
  /* stage 10g (Andrea): the way through to the project — the removed card
     titles' route, one level down where the title now shows. The
     .work-btn family, as See the work. */
  .sw-view-box .sw-view-more {
    display: inline-block;
    margin-top: 16px;
    padding: 12px 26px;
    border: 1px solid rgba(242, 236, 227, 0.4);
    border-radius: var(--radius-pill);
    color: var(--cream);
    text-decoration: none;
    font-size: 0.72rem; font-weight: 500;
    letter-spacing: 0.18em; text-transform: uppercase;
    transition: border-color 0.3s var(--ease), box-shadow 0.3s var(--ease);
  }
  .sw-view-box .sw-view-more:hover, .sw-view-box .sw-view-more:focus-visible {
    border-color: rgba(242, 236, 227, 0.85);
    box-shadow: 0 0 18px -6px rgba(242, 236, 227, 0.4);
  }
  .sw-view-box .sw-view-more:focus-visible { outline: 2px solid var(--cream); outline-offset: 4px; }
  .sw-view-box { text-align: center; }
  .sw-view-close {
    position: absolute; top: -18px; right: -18px;
    width: 44px; height: 44px;
    border: 1px solid rgba(242, 236, 227, 0.45);
    border-radius: 999px;
    background: rgba(18, 16, 14, 0.85);
    color: var(--cream);
    font-size: 1.3rem; line-height: 1;
    cursor: pointer;
    transition: border-color 0.3s var(--ease);
  }
  .sw-view-close:hover, .sw-view-close:focus-visible { border-color: rgba(242, 236, 227, 0.9); }
  .sw-view-close:focus-visible { outline: 2px solid var(--cream); outline-offset: 3px; }

  .hero-right-foot {
    display: flex; flex-direction: column; align-items: flex-end; justify-content: flex-end;
    gap: 10px;
    position: absolute; right: 0; bottom: 0;
    width: var(--panel-w, 26vw);
    padding: clamp(2rem, 4vw, 4.5rem);
    /* ⚠ Stage 38c: the ONLY edge that moves. --foot-pad equals the shorthand
       above at every window where the column already clears, so the number
       does not move there; where the column asks for room it falls, never
       below the shorthand's own 2rem floor. See the block at the foot of this
       file for the chain. */
    padding-bottom: var(--foot-pad, clamp(2rem, 4vw, 4.5rem));
    z-index: 5;
    pointer-events: none;
  }
  /* stage 10e/10e-b (Andrea): the email pill is gone; the phone number is
     display type, not a button — no border, no background, no padding.
     10e held this at 3.92:1; 10e-b's mechanism ruling: at 3.92 the ground
     is the DARK side (L 0.08) and the amber the deficit, so the fix is
     the type's own darkest stop, raised — see the .hero-tel gradient
     below — never the wave. Sized a step below the H1 (~0.78 of it at
     every desktop width). Hover is §61's mechanism: a registered custom
     property sweeps the gradient's stops; no animated text-shadow or
     filter. The glyph is /contact/'s path verbatim at the same stroke. */
  .hero-right-foot { width: auto; }   /* the two-pill column width is gone;
                                         the box hugs the number, still
                                         anchored bottom-right */
  .hero-tel {
    pointer-events: auto;
    display: inline-flex; align-items: center; gap: 0.4em;
    text-decoration: none;
    font-family: 'Instrument Sans', 'Schibsted Grotesk', sans-serif;
    font-size: clamp(1.6rem, 4vw - 14px, 3.9rem);
    font-weight: 500; letter-spacing: -0.02em; line-height: 1;
    white-space: nowrap;
    color: #E1AA6A;                 /* the glyph's stroke (currentColor) */
    /* 11j: hosts the hover rim below. The padding gives the ring a
       padding box CLEAR of the ink (measured gap 13.0-15.0px at six
       widths) and the equal negative margins cancel it out of layout —
       the digits' rendered rects are byte-identical to 11i's. */
    position: relative;
    padding: 12px 20px; margin: -12px -20px;
    border-radius: var(--radius-pill);
  }
  /* 11j candidate B, measured before built: a soft warm rim on the
     link's padding box — the .cta::before mask-composite border trick,
     verbatim mechanism. The mask excludes the content box, and the
     measurement holds it to that: rim on vs off, ground under the
     glyph footprint, max per-pixel RGB delta = 0 over 37 swept phases
     — the ink is untouched at four decimals and beyond. The rim itself
     reads 6.08:1 (worst swept phase) against the ground it sits on,
     5.21:1 even against the canonical 0.0784 crest bound — a non-text
     graphic clearing its 3:1 floor. Nothing at rest: opacity 0 here,
     revealed by the hover+fine block only; opacity is the one animated
     property and the rim itself never changes. */
  .hero-tel::before {
    content: '';
    position: absolute; inset: 0; border-radius: inherit;
    padding: 2px;
    background: linear-gradient(105deg, #F2DCC0, #E8BE8C 50%, #F2DCC0);
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    mask-composite: exclude;
    pointer-events: none;
    opacity: 0;
  }
  /* 11i item 4: the glyph's stroke floats to the digits' floored stop —
     10e-b raised the digits to #E8BE8C but left the icon at #E1AA6A, two
     ambers side by side. Stroke floor is 3:1 (non-text); lighter can only
     improve the measured 3.40. Same path, same 1.6 weight, same size. */
  .hero-tel-glyph { width: 0.62em; height: 0.62em; flex: none; opacity: 0.9; color: #E8BE8C; }
  /* 10e-b: the phone's OWN wave — same stops and rhythm as the H1's
     floored variant, with the darkest stop raised to clear 4.5 + 0.2 over
     the measured ground (the H1's own rule above is untouched; this is a
     separate rule on one element, not a scope widening). The raised stop
     is mixed from #E1AA6A toward #F2DCC0 — the family's own light end —
     so it cannot leave the warm family. Value set by the 10e-b ladder. */
  .hero-tel .wave-panel.wave-light {
    /* darkest stop #E8BE8C = #E1AA6A mixed 0.40 toward #F2DCC0 (10e-b
       ladder): the lowest stop clearing 4.5 + 0.2 over the measured ground
       ceiling (Lgmax 0.0784, stable across five draws — the crest's
       composited brightness is a hard bound). 4.74:1 at the ceiling. */
    background-image: linear-gradient(105deg,
      #F2DCC0 0%, #E8BE8C 24%, #E8BE8C 44%, #E8BE8C 62%, #F2DCC0 80%, #E8BE8C 100%);
    background-position: calc(var(--wavep, 0) * 100%) 0;
  }
  /* 11i item 1 NOT SHIPPED — the edge-glow hover (a static-shadow
     duplicate revealed by opacity) measured 3.21:1 under the §78 sweep
     with hover forced (ground under the glyph footprint L 0.1397,
     identical across three draws; the lit halo becomes the digits'
     ground). The floor's whole headroom over the 0.0784 crest is
     +0.0068 L — no perceptible glow fits inside it. Finding reported
     for Andrea's ruling; 14b's border-only hover stands meanwhile. */
  .hero-tel:focus-visible { outline: 2px solid var(--cream); outline-offset: 6px; border-radius: 4px; }

  .panel .p-title {
    font-family: 'Instrument Sans', sans-serif;
    /* stage 7: grows with the lines but stays subordinate to them */
    font-size: clamp(1.15rem, 1.55vw, 1.9rem);
    font-weight: 500; letter-spacing: -0.01em;
    opacity: 0.75;
    margin-bottom: clamp(14px, 3svh, 34px);
  }
  /* Stage 7: the column rebuilt around type. The three lines are the
     writing that carries the film — they dominate the column; the room
     name grows with them but stays subordinate; the narrative stays
     small. The engine still measures each child's extent once per layout
     and translates the phase by half the VISIBLE set's height (6b), so
     the assembled set stays optically centred as elements arrive on the
     existing 0.55s reveal curve. Vertical air is svh-driven (§32-static)
     so the column actually uses the height it has. */
  .panel .hb-line {
    display: block;
    font-family: 'Instrument Sans', sans-serif;
    font-size: clamp(1.35rem, 2.1vw, 2.75rem);
    font-weight: 500; line-height: 1.18; letter-spacing: -0.02em;
    margin-top: clamp(16px, 3.2svh, 36px);
    opacity: 0; transform: translateY(12px);
    transition: opacity 0.55s var(--ease), transform 0.55s var(--ease);
  }
  .panel .hb-line.on { opacity: 1; transform: none; }
  /* authored breaks: each segment is its own block, split at render time
     in the desktop builder — the CAPTIONS strings are untouched */
  .panel .hb-seg { display: block; }
  /* the two hairline rules between the lines: quiet, in the cream family
     at low alpha (copper stays Enquire-only), revealed as one gesture
     with the line below — same curve, and the following line closes up
     so rule + line read together */
  .panel .hb-rule {
    height: 1px; width: 100%;
    background: rgba(242, 236, 227, 0.25);
    margin-top: clamp(16px, 3.2svh, 36px);
    opacity: 0; transform: translateY(12px);
    transition: opacity 0.55s var(--ease), transform 0.55s var(--ease);
  }
  .panel .hb-rule.on { opacity: 1; transform: none; }
  .panel .hb-rule + .hb-line { margin-top: clamp(10px, 1.8svh, 20px); }
  /* (the stage-6b material-study images between the lines were REMOVED at
     6c by Andrea — they pulled attention from the film; the hero-side
     assets stay committed for use elsewhere) */
  .panel .p-body {
    margin-top: clamp(24px, 4.5svh, 52px);
    font-size: clamp(0.8rem, 0.95vw, 1rem);
    line-height: 1.55;
    max-width: 44ch;
    opacity: 0; transform: translateY(12px);
    transition: opacity 0.7s var(--ease), transform 0.7s var(--ease);
  }
  .panel .p-body.on { opacity: 1; transform: none; }

  .panel .p-spec { list-style: none; }
  .panel .p-spec li {
    font-size: clamp(0.82rem, 0.95vw, 1rem);
    font-weight: 500;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    line-height: 1.5;
    /* stage 7: the rule is a FIXED flex item beside the text, not part of
       the inline flow — previously an inline-block ::after wrapped like a
       word on long items (alone onto its own line, or trailing the wrapped
       word), which read as the rule disappearing. As a flex column the
       text wraps inside its own box and the rule keeps its place beside
       every item regardless of length. */
    display: flex; align-items: center; justify-content: flex-end;
    gap: 14px;
    opacity: 0; transform: translateX(14px);
    transition: opacity 0.5s var(--ease), transform 0.5s var(--ease);
  }
  .panel .p-spec li + li { margin-top: clamp(12px, 2.2svh, 24px); }
  .panel .p-spec li.on { opacity: 0.9; transform: none; }
  .panel .p-spec li::after {
    content: '';
    flex: none;
    width: 26px; height: 1px;
    background: currentColor;
    opacity: 0.4;
  }
}

/* ---- sections shared ----
   Rhythm per the prototype's .sec{padding:44px 26px} (28 Aug, B1): 44px top
   and bottom on every section after the film, one rule for the whole page.
   Was clamp(5rem, 18vw, 11rem) — 80px per edge on a 390px phone, up to
   176px on desktop. The 1.5rem (24px) gutter stays the site-wide gutter. */
.section {
  position: relative;
  padding: 44px 1.5rem;
  background: var(--sec-bg, var(--canvas));
  color: var(--sec-fg, var(--cream));
}
.section::before {
  content: '';
  position: absolute;
  left: 0; right: 0; top: 0;
  height: clamp(56px, 10vw, 120px);
  background: linear-gradient(180deg, var(--prev-bg, var(--canvas)), var(--sec-bg, var(--canvas)));
  pointer-events: none;
}
.section > .inner { position: relative; max-width: 1440px; margin: 0 auto; }

/* ============ STAGE 63 ====================================================
   THE HORIZONTAL OVERFLOW AT 320 AND 360, AND IT WAS NEVER THE HEADER.

   Measured: the page is 351px wide in a 320px viewport and 371px in a 360px
   one, on index, about, portfolio, what-we-do, contact and 404. A
   getBoundingClientRect walk finds NOTHING over the line on five of the six,
   because the offender is a PSEUDO-ELEMENT and a rect walk cannot see one.
   Removing things and watching scrollWidth does see it: `*::after {content:
   none}` drops the page straight back to 320.

   It is `.cta-wrap::after` -- the brand bloom, `inset: -64px` on the Enquire
   button. The button is 254px wide and centred, so its gutter is
   (viewport - 254) / 2: 33px at 320 and 53px at 360, against a 64px bloom.
   The arithmetic is exact -- 64 - 33 = 31 and 64 - 53 = 11, which is the 351
   and the 371 -- and it predicts the threshold: the overflow ends at
   viewport >= 382px, which is why 390 was already clean.

   ⚠ /privacy and /enquire were clean for the dullest possible reason: neither
   page has an Enquire button. That is the whole difference.

   THE FIX AND WHY THIS SCOPE. `.section` is full-bleed, so its padding box IS
   the viewport: clipping there falls exactly on the screen edge and removes
   only the part of the glow nobody could see. `:has(> .inner > .cta-wrap)`
   narrows it to the sections that actually host the button -- the homepage's
   two other .cta-wraps, inside the hero, overflow nothing and are untouched.
   ⚠ `clip`, NOT `hidden`: hidden makes a scroll container, which would break
   position:sticky and the hero's pin. `overflow-x: clip` with `overflow-y`
   left visible keeps the glow above and below the button exactly as it was.
   ⚠ If :has() is ever unsupported the rule is simply dropped and the page
   behaves as it does today -- the fallback is the status quo, not something
   worse.
   ⚠ NOT done: overflow-x hidden on html or body (hides the symptom, breaks
   the pin), and no blanket max-width: 100%. */
.section:has(> .inner > .cta-wrap) { overflow-x: clip; }

/* Mobile-only two-line headings (29 Aug, Andrea): each sentence on its own
   line and the joining punctuation drops with the break; desktop keeps the
   single line with its punctuation. */
@media (max-width: 1023px) {
  .ht-sep { display: none; }
  .ht-a { display: block; }
}

/* Reveals are VISIBLE by default (29 Aug). The hidden starting state applies
   only under .js-reveal, which an inline head script sets before first paint
   and a 3s failsafe removes if page.js/main.js never initialises. A visitor
   whose JS is off, blocked or slow sees the copy rather than empty sections.
   Values below are byte-identical to the previous .reveal rule. */
.reveal { opacity: 1; transform: none; }
.js-reveal .reveal { opacity: 0; transform: translateY(24px); transition: opacity 0.7s var(--ease), transform 0.7s var(--ease); }
.js-reveal .reveal.on { opacity: 1; transform: none; }

.img-blend {
  -webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 11%, #000 89%, transparent 100%);
  mask-image: linear-gradient(180deg, transparent 0%, #000 11%, #000 89%, transparent 100%);
}

/* I1 (28 Aug): the page's blocks read centred on mobile. Exceptions, by
   design and flagged: the film captions (their positions are the fixed 4.3
   rotation), the desktop side panels (edge-anchored), and the footer's
   small-print footnotes (multi-line legal text stays left). */
@media (max-width: 1023px) {
  .wwd .wwd-copy { text-align: center; }
  .wwd .wwd-copy .body-copy { margin-left: auto; margin-right: auto; }
  .projects .inner { text-align: center; }
  .people .inner { text-align: center; }
}

/* ---- What We Do section ----
   Text alone on its own ground (28 Aug, C1): the staircase-arches image came
   out; the sampled #453324 ground stays, per the 4.4 colours. */
.wwd { --sec-bg: #453324; --sec-fg: var(--cream); --prev-bg: var(--canvas); }
.wwd .inner { max-width: 720px; }
/* stage 10a item 5 (Andrea: the copy block reads too narrow — the heading
   is untouched). Desktop only; the global .body-copy 62ch and every other
   page's copy are unaffected. */
@media (min-width: 1024px) {
  .wwd .inner { max-width: 900px; }
  .wwd .wwd-copy .body-copy { max-width: 78ch; }
}
.wwd .wwd-copy .h-section { margin-bottom: 1.4rem; }
.wwd .wwd-copy .body-copy + .body-copy { margin-top: 1rem; }
.wwd .wwd-copy .body-copy.standalone { margin-top: 1.6rem; }
/* Stage 78 (Andrea): at desktop widths the standalone line ran to two lines
   with "complimentary." alone on the second. `pretty` carries "are" down
   with it (balance split the line after "The"); below 1024px the line
   wraps as before. */
@media (min-width: 1024px) {
  .wwd .wwd-copy .body-copy.standalone { text-wrap: pretty; }
}
.wwd-link {
  display: inline-block;
  margin-top: 2.2rem;
  color: var(--cream);
  font-size: 0.78rem; font-weight: 500;
  letter-spacing: 0.18em; text-transform: uppercase;
  text-decoration: none;
  border-bottom: 1px solid rgba(242, 236, 227, 0.4);
  padding-bottom: 7px;
  transition: border-color 0.3s var(--ease), color 0.3s var(--ease);
}
.wwd-link:hover, .wwd-link:focus-visible { color: var(--copper-lit); border-color: var(--copper-lit); }
/* ---- projects carousel (28 Aug, rebuilt to p3-home-dark-v2.html) ----
   Snap rail, 74% cards with 14px gap so the neighbours show as slivers,
   4/5 frames with an 18px radius and hairline border, warm glow beneath
   each card, title + subtitle below, Swipe label, See the work button.
   The image inside each card is rendered at frame HEIGHT with its own
   aspect (never scaled up), and --pan moves the crop window horizontally
   as the card travels the viewport — image rule 2's "move the crop window
   across the frame". No scale transform exists here.
   The button is deliberately NOT the copper .cta: copper is Enquire-only
   by the locked colour rule, so See the work is a cream-hairline pill. */
.projects { --sec-bg: #654832; --sec-fg: var(--cream); --prev-bg: #453324; --rail-top: clamp(2rem, 7vw, 3rem); }
.projects .rail {
  display: flex;
  gap: 14px;
  overflow-x: auto;
  /* overflow-y NEVER auto (29 Aug): overflow-x:auto alone computes
     overflow-y to auto, and 16px of content overflow made the rail a
     two-axis scroller — a finger on a photograph could drag the whole
     row up and down. hidden kills the vertical axis (gestures fall
     through to the page scroll); the extra bottom padding absorbs the
     old overflow so nothing is clipped. The horizontal snap and the pan
     parallax are untouched. */
  overflow-y: hidden;
  scroll-snap-type: x mandatory;
  margin: var(--rail-top) -1.5rem 0;   /* 82: a property, so the seam padding can give it back */
  padding: 0 1.5rem 24px;
  -webkit-overflow-scrolling: touch;
  scrollbar-width: none;
}
.projects .rail::-webkit-scrollbar { display: none; }
.projects .card {
  flex: 0 0 74%;
  /* the pan image is wider than its frame by design; without this the
     image's min-content width inflates the card past its 74% basis */
  min-width: 0;
  max-width: 74%;
  scroll-snap-align: center;
  position: relative;
  display: block;
  text-decoration: none;
  color: inherit;
  text-align: center;
}
.projects .card .imwrap { position: relative; display: block; }
.projects .card .imwrap::after {
  content: '';
  position: absolute;
  left: 12%; right: 12%; bottom: -8px; height: 28px;
  background: radial-gradient(ellipse at 50% 0%, rgba(255, 217, 163, 0.24), transparent 70%);
  filter: blur(12px);
  pointer-events: none;
}
.projects .card .im {
  display: block;
  position: relative;
  aspect-ratio: 4 / 5;
  border-radius: 18px;
  border: 1px solid var(--hairline);
  overflow: hidden;
  box-shadow: 0 18px 40px -18px rgba(0, 0, 0, 0.55), inset 0 1px 0 rgba(255, 240, 220, 0.14);
}
.projects .card .im img {
  height: 100%;
  width: auto;
  max-width: none;
  display: block;
  transform: translateX(var(--pan, 0px));
}
.projects .card b {
  display: block;
  font-weight: 500;
  font-size: 0.95rem;
  margin-top: 14px;
  color: var(--cream);
}
.projects .card i {
  display: block;
  font-style: normal;
  font-size: 0.74rem;
  /* ⚠ Stage 55, sweep finding 1. WAS 0.62, which measured 3.88:1 against a
     4.5 floor at ALL TEN widths with 100% of the ink under it — the ground
     behind these captions is rgb(100, 71, 49) at every width, so one value
     serves them all. The arithmetic and the instrument agree to three decimals
     here: 0.62 -> 3.877 computed, 3.88 measured.
     ⚠ Stage 58 (7 Sep): 0.75 -> 0.88. 0.75 measured 4.87:1 against a 4.5
     floor — a margin of +0.37, and a value that lands that close to a floor
     falls off it the next time anything near it changes. 0.88 measures
     5.92-6.00:1, a margin of +1.42 at the worst width.
     ⚠ WHY NOT HIGHER: the card title above is `var(--cream)` at full strength,
     so raising this walks the sub-caption toward the title's exact colour.
     ΔE76 between the two is 6.94 at 0.88 and still clearly a step; by 0.92 it
     is 4.62 and closing, and at 1.00 there is no step at all. 0.88 is the most
     margin this can carry while the hierarchy is still carried by colour as
     well as by size and weight. */
  color: rgba(242, 236, 227, 0.88);
  margin-top: 3px;
}
.projects .swipe {
  font-size: 0.58rem;
  letter-spacing: 0.26em;
  text-transform: uppercase;
  /* ⚠ Stage 55, sweep finding 2. WAS 0.5 — the lowest fixed colour on the
     site, 3.06:1 at 9.3px, and 9.3px is SMALL TEXT so the floor is 4.5, not
     3. Same ground as the caption above, so the same value.
     ⚠ The old 0.62/0.50 hierarchy between this and the card caption cannot
     survive the floor — both had to come up past it. Size still separates
     them: this is 9.3px against the caption's 13.6-16.8px.
     ⚠ Stage 58 (7 Sep): 0.75 -> 0.88, for the same reason and to the same
     value. 0.75 measured 4.80:1, a margin of +0.30; 0.88 measures 5.92:1.
     ⚠ At device_scale_factor 1 this label has only about four pixels that
     reach solid coverage — 9.28px at 0.26em tracking — so its figure is read
     off a four-pixel footprint and the count is reported with it. A real
     phone renders it at 3x, where the solid body is large. */
  color: rgba(242, 236, 227, 0.88);
  margin-top: 14px;
}
/* Stage 23: the 404 page. Every value here is taken from a rule that already
   exists — the pill is .projects .work-btn's own border, radius, tracking and
   hover verbatim, the heading is .h-section, the ground is .section's — so the
   page looks like the site rather than something new. These four selectors
   match NOTHING on the six real pages (proven by the stage 23 selector count),
   so nothing else can move. */
.nf-sec { min-height: 100svh; display: flex; align-items: center; }
.nf-sec .inner { max-width: 640px; margin: 0 auto; text-align: center; }
.nf-code {
  font-family: 'Instrument Sans', sans-serif;
  font-size: 0.78rem; font-weight: 500;
  letter-spacing: 0.18em; text-transform: uppercase;
  color: rgba(242, 236, 227, 0.55);
  margin-bottom: 1rem;
}
.nf-sec .body-copy { margin: 1.1rem auto 0; }
.nf-links { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin-top: 2rem; }
.nf-links a {
  display: inline-block;
  /* Stage 24: the pill's own 36px, untouched at every width down to 360px,
     then easing to a 22px floor. Measured: at 320 the widest label this page
     can carry ("Back to the home page", 203.9px of tracked capitals) needs
     249.9px inside a 272px row at 22px padding, and overflowed the document
     by 30px at 36px. No new breakpoint — the taper is continuous, and at and
     above 360px the clamp's upper bound holds the shipped 36px exactly. */
  padding: 15px clamp(22px, calc(36px - 360px + 100vw), 36px);
  border: 1px solid rgba(242, 236, 227, 0.4);
  border-radius: var(--radius-pill);
  color: var(--cream);
  text-decoration: none;
  font-size: 0.78rem; font-weight: 500;
  letter-spacing: 0.18em; text-transform: uppercase;
  transition: border-color 0.3s var(--ease), color 0.3s var(--ease), box-shadow 0.3s var(--ease);
}
.nf-links a:hover, .nf-links a:focus-visible {
  color: var(--copper-lit);
  border-color: rgba(232, 167, 101, 0.5);
  box-shadow: 0 0 18px -4px rgba(255, 217, 163, 0.45);
}
.nf-call { font-size: 0.92rem; color: rgba(242, 236, 227, 0.72); margin-top: 1.8rem; }
.nf-call a { color: var(--cream); }

/* Stage 24. Everything below is scoped to .nf- and matches nothing on the six
   real pages. The enquiry button on this page is the SHARED .cta, unchanged —
   only its position is set here, and position is not one of §62's three levers
   (label text, type size, flare peak opacity), all of which stay untouched. */
.nf-cta { margin-top: 1.6rem; }
/* the report line: a step quieter than the pills, at .nf-call's own weight,
   because pressing it is a courtesy and not a route out of the page. The size
   and colour live on the ROW and the button takes them with font/color:
   inherit, so there is one place to change them and the line cannot shift
   when the button's own label changes length. */
.nf-report { margin-top: 1.1rem; font-size: 0.92rem; color: rgba(242, 236, 227, 0.72); }
/* the row ships [hidden] and notfound.js reveals it, so a visitor without
   JavaScript never sees a button that could not send anything */
.nf-report[hidden] { display: none; }
.nf-report-btn {
  appearance: none; -webkit-appearance: none;
  background: none; border: 0; padding: 0; margin: 0;
  font: inherit; color: inherit;
  text-decoration: underline;
  text-underline-offset: 4px;
  text-decoration-color: rgba(242, 236, 227, 0.4);
  cursor: pointer;
  transition: color 0.3s var(--ease), text-decoration-color 0.3s var(--ease);
}
.nf-report-btn:hover, .nf-report-btn:focus-visible {
  color: var(--copper-lit);
  text-decoration-color: rgba(232, 167, 101, 0.6);
}
/* spent for this page load: still focusable and still announced, but plainly
   not a button any more (see notfound.js on why not [disabled]) */
.nf-report-btn[aria-disabled="true"] {
  cursor: default;
  color: rgba(242, 236, 227, 0.5);
  text-decoration-color: transparent;
}
.nf-report-btn[aria-disabled="true"]:hover { color: rgba(242, 236, 227, 0.5); }

/* Stage 24 (Andrea, 4 Sep): the confirmation is a WINDOW over the page. A
   native <dialog>, so the backdrop, the Escape key, the focus trap and making
   the page behind inert are the browser's, not ours. Only the skin is here.
   None of these selectors match anything on the six real pages — there is no
   other <dialog> element on the site. */
.nf-dialog {
  /* inset + margin:auto is what centres a modal; both are stated rather than
     inherited from the UA sheet, so the panel does not depend on it */
  position: fixed; inset: 0; margin: auto;
  width: min(30rem, calc(100vw - 2.4rem));
  height: fit-content;
  padding: 2.6rem 2.4rem 2.2rem;
  /* .16 -> .40, measured: at .16 the panel's edge read 1.76:1 against the
     dimmed page behind it, which is not enough for the one line that says
     where the window stops. .36 cleared the 3:1 non-text floor in both
     directions but landed at exactly 3.00 against the panel's own face —
     on the floor with no margin, which §60's practice does not accept. At
     .40 it is 3.87 against the dimmed page and 3.43 against the face. The
     panel face itself cannot do this job: lifting it far enough to reach
     3:1 against the backdrop turns it grey and costs the title contrast. */
  border: 1px solid rgba(242, 236, 227, 0.40);
  border-radius: var(--radius-card);
  /* a step up off --canvas so the panel reads as being ABOVE the page rather
     than cut out of it; the shadow does the rest */
  background: #1C1815;
  color: var(--cream);
  text-align: center;
  box-shadow: 0 34px 90px -34px rgba(0, 0, 0, 0.9);
}
.nf-dialog::backdrop { background: rgba(9, 8, 7, 0.74); }
.nf-dialog-title {
  font-family: 'Instrument Sans', 'Schibsted Grotesk', sans-serif;
  font-size: 1.5rem; font-weight: 500;
  line-height: 1.1; letter-spacing: -0.02em;
  margin: 0;
}
.nf-dialog-copy {
  margin: 0.9rem auto 0;
  max-width: 34ch;
  font-size: 0.98rem; line-height: 1.55;
  color: rgba(242, 236, 227, 0.82);
}
.nf-dialog-copy a { color: var(--cream); }
.nf-dialog-x {
  position: absolute; top: 10px; right: 10px;
  width: 44px; height: 44px;          /* a 44px target, not a 16px glyph */
  display: flex; align-items: center; justify-content: center;
  appearance: none; -webkit-appearance: none;
  background: none; border: 0; padding: 0;
  border-radius: var(--radius-pill);
  color: rgba(242, 236, 227, 0.72);
  cursor: pointer;
  transition: color 0.3s var(--ease), background-color 0.3s var(--ease);
}
.nf-dialog-x svg { width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; }
.nf-dialog-x:hover, .nf-dialog-x:focus-visible {
  color: var(--cream);
  background: rgba(242, 236, 227, 0.08);
}

.projects .work-btn {
  display: inline-block;
  margin-top: 26px;
  padding: 15px 36px;
  /* ⚠ STAGE 64, ITEM B. 0.4 -> 0.55, and the ground is the reason. Measured in
     Chromium against the PAINTED background rather than taken from a token: on
     the warm #654832 ground this button sits on at phone widths, cream at 0.4
     composites to rgb(157,138,121) and the ring reads 2.51:1 against a 3.0
     non-text floor -- the one unambiguous resting-state failure the stage 59
     sweep found. On the dark grounds the same 0.4 already read 3.4:1, which is
     why it passed everywhere else and failed only here.
     0.55 composites to rgb(179,162,147) = 3.37:1 on that worst ground and
     5.46:1 on --canvas. 0.5 would have cleared it at 3.07:1 with no margin.
     ⚠ Scoped to `.projects .work-btn`: 404's Back link and `.sw-more` already
     measure 3.4:1 and are untouched. */
  border: 1px solid rgba(242, 236, 227, 0.55);
  border-radius: var(--radius-pill);
  color: var(--cream);
  text-decoration: none;
  font-size: 0.78rem; font-weight: 500;
  letter-spacing: 0.18em; text-transform: uppercase;
  transition: border-color 0.3s var(--ease), color 0.3s var(--ease), box-shadow 0.3s var(--ease);
}
.projects .work-btn:hover, .projects .work-btn:focus-visible {
  color: var(--copper-lit);
  border-color: rgba(232, 167, 101, 0.5);
  box-shadow: 0 0 18px -4px rgba(255, 217, 163, 0.45);
}
@media (min-width: 1024px) {
  .projects .card { flex: 0 0 31.5%; max-width: 31.5%; }
}

/* ---- STAGE 2 (31 Aug): desktop rail scrollbar ----
   A mouse cannot scroll the rail sideways, so above 1024 a slim scrollbar
   sits under the cards (built by main.js) and replaces the SWIPE label.
   Mobile keeps SWIPE and renders none of this: the element is display:none
   below 1024 and every visual rule lives inside the desktop query. */
@media (min-width: 1024px) {
  .projects .swipe { display: none; }   /* the scrollbar replaces it here */
  /* Stage-3 geometry (Andrea): shorter than the rail, slightly thicker,
     growing on hover. The outer box RESERVES 18px and is the hit target;
     .rail-inner carries the visible track and thumb at 6px and grows to
     9px (scaleY 1.5, plus a touch of width) INSIDE the reserved box, so
     nothing around it ever moves — transform only, on the site ease. */
  .rail-scroll {
    position: relative;
    width: clamp(220px, 24vw, 380px);
    height: 18px;
    margin: 4px auto 0;
    cursor: pointer;
  }
  .rail-inner {
    position: absolute;
    inset: 0;
    transition: transform 0.2s var(--ease);
  }
  .rail-scroll:hover .rail-inner,
  .rail-scroll:focus-visible .rail-inner,
  .rail-scroll.dragging .rail-inner { transform: scaleY(1.5) scaleX(1.05); }
  .rail-inner::before {
    content: '';
    position: absolute; left: 0; right: 0; top: 50%;
    height: 6px; margin-top: -3px;
    background: var(--hairline);
    border-radius: 3px;
  }
  .rail-scroll:focus-visible { outline: 2px solid var(--cream); outline-offset: 6px; border-radius: 4px; }
  .rail-thumb {
    position: absolute; left: 0; top: 50%;
    height: 6px; margin-top: -3px;
    border-radius: 3px;
    /* the --glow warmth the progress light uses — never copper, which is
       Enquire's alone in the resting UI */
    background: rgba(255, 217, 163, 0.28);
    overflow: hidden;
    transition: background-color 0.3s var(--ease);
  }
  .rail-scroll:hover .rail-thumb,
  .rail-scroll.dragging .rail-thumb,
  .rail-scroll:focus-visible .rail-thumb { background: rgba(255, 217, 163, 0.42); }
  /* The travelling light: .eq-progress .fill::after's treatment — the same
     90px transparent->glow->transparent gradient, the same 2.6s linear
     rhythm — carried on TRANSFORM: the precedent animates `left`, and an
     animated layout property must not return (BUILD-LOG 60/61). --tw is
     the thumb's own width, written by syncBar in main.js, so the light
     traverses the whole thumb whatever size layout gives it. */
  .rail-thumb i {
    position: absolute; top: 0; bottom: 0; left: 0; width: 90px;
    background: linear-gradient(90deg, transparent, var(--glow), transparent);
    opacity: 0.8;
    transform: translateX(-90px);
    animation: rail-light 2.6s linear infinite;
  }
}
@keyframes rail-light {
  0% { transform: translateX(-90px); }
  100% { transform: translateX(var(--tw, 600px)); }
}
@media (max-width: 1023px) {
  .rail-scroll { display: none; }       /* desktop-only control; mobile keeps SWIPE */
}
@media (prefers-reduced-motion: reduce) {
  /* the light rests off-thumb, exactly as the eq precedent parks its own;
     the thumb itself still tracks and drives the rail. The hover growth
     is instant rather than animated, and the control stays operable. */
  .rail-thumb i { animation: none; }
  .rail-inner { transition: none; }
  .bar-nav a, .bar-nav a::after { transition: none; }
  /* stage 10d: the capsule appears in place — no travel, no stretch (the
     capsule is kept over the underline: same information, no motion; the
     underline remains the no-JS fallback only). The bar Enquire's rim
     holds still at the .cta's own parked angle. */
  .nav-caps { transition: none; }
  .nav-caps.travel i { animation: none; }
  .bar-nav a.bar-cta { animation: none; --p3-ang: 40deg; }
  /* stage 10e-b / 11j: the phone's hover keeps its states (bright ink,
     rim on, glyph at full) and drops the transitions */
  .hero-tel, .hero-tel-glyph, .hero-tel::before,
  .hero-tel .wave-panel.wave-light { transition: none; }
}

/* ---- the people ---- */
/* Lighter section (29 Aug, Andrea: not red): warm plaster #EFE8DC, ink
   text; the trio cards go translucent pastels of the /about/ founder
   grounds — softer, lighter, still one colour each. */
.people { --sec-bg: #EFE8DC; --sec-fg: var(--ink); --prev-bg: #654832; }
/* Compacted to the prototype trio (28 Aug, round 3): three equal columns in
   one line, 12px gap, 26px top margin, 3/4 frames at 14px radius with a
   hairline border. The per-founder grounds, the Andrea / Massimo / Marco
   order, names and titles below, and the Meet the studio link all stay. */
.people .trio {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
  margin-top: 26px;
}
@media (min-width: 1024px) {
  .people .trio { max-width: 940px; margin-left: auto; margin-right: auto; }
}
.person { text-align: center; }
/* Each portrait carries its own ground from the hero sky palette (F2, 28 Aug),
   none repeating, all distinct from the section's #5A322E. The portraits have
   feathered transparent edges, so they blend into the card colour; name and
   title sit below the card on the section ground, where cream keeps full
   contrast at small sizes. */
.person .portrait {
  position: relative;
  width: 100%;
  margin: 0;
  aspect-ratio: 3 / 4;
  border-radius: 14px;
  border: 1px solid rgba(43, 31, 30, 0.14);
  display: flex;
  align-items: flex-end;
}
.person .card-massimo { background: rgba(222, 197, 144, 0.55); }  /* straw  */
.person .card-marco   { background: rgba(186, 197, 158, 0.55); }  /* sage   */
.person .card-andrea  { background: rgba(173, 190, 201, 0.55); }  /* blue-grey */
.person .portrait::before {
  content: '';
  position: absolute;
  left: 6%; right: 6%; top: 12%; bottom: 4%;
  background: radial-gradient(ellipse at 50% 68%,
    rgba(255, 217, 163, 0.30) 0%,
    rgba(255, 190, 130, 0.12) 46%,
    transparent 74%);
  filter: blur(22px);
}
.person .portrait::after {
  content: '';
  position: absolute;
  left: 10%; right: 10%; bottom: -10px; height: 40px;
  background: radial-gradient(ellipse at 50% 0%,
    rgba(255, 217, 163, 0.34) 0%, rgba(255, 217, 163, 0.10) 45%, transparent 72%);
  filter: blur(14px);
}
.person .portrait { display: flex; text-decoration: none; }
.person .portrait img { position: relative; z-index: 2; width: 100%; height: auto; }
/* deep links (homepage trio -> /about/#massimo etc.) land clear of the
   fixed bar; on .proj so portfolio anchors get the same clearance */
.proj { scroll-margin-top: calc(var(--bar-h) + 28px); }
/* stage 11d item 4 (Andrea: "that luminance effect... highlights all the
   edges of the rectangle box around our portraits"): a light travelling
   the card's whole rectangle — the site's rim construction (masked conic
   on var(--p3-ang), the shared p3-orbit 9s lap) following the 14px
   radius. §53 governs the palette: on this LIGHT section copper's mid
   tones measure 2.51:1, so this ring's travelling light lives at the
   DARK end of the warm family — a deep bronze head on a near-ink arc —
   every stop sized to clear the 3:1 NON-TEXT floor against both the pale
   card grounds and the section ground (swept every 15 degrees, all three
   cards, §90). The trigger is .person (Andrea named hovering the NAME),
   with :focus-within for keyboard; the portrait stays the only clickable
   thing. Desktop-only by the hover media type; nothing paints at rest. */
@media (hover: hover) and (pointer: fine) {
  .person .portrait .p-ring {
    /* OUTSIDE the card edge, on the uniform section ground: at inset
       -1px the band sat on the photograph's own boundary, and where
       dark image content reached the edge through the feather a dark
       ring had no contrast (measured 0.36:1 on Andrea's card at 255°).
       Two px out, every degree of the lap faces the same pale ground. */
    position: absolute; inset: -5px; z-index: 3;
    border-radius: 18px;
    padding: 3px;
    background: conic-gradient(from var(--p3-ang),
      rgba(43, 31, 30, 0.9) 0deg, rgba(43, 31, 30, 0.9) 236deg,
      #6E3C16 290deg, #9C5A1F 316deg, #6E3C16 334deg, rgba(43, 31, 30, 0.9) 360deg);
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    mask-composite: exclude;
    opacity: 0;
    transition: opacity 0.3s var(--ease);
    pointer-events: none;
  }
  .person:hover .p-ring, .person:focus-within .p-ring {
    opacity: 1;
    animation: p3-orbit 9s linear infinite;
  }
  /* the quiet lift that reads with it — transform only */
  .person .portrait { transition: transform 0.35s var(--ease); }
  .person:hover .portrait, .person:focus-within .portrait { transform: translateY(-3px); }
  /* stage 11k item 4: the step-1 option under the pointer lights its
     EDGE — the ring defined with the tile, revealed here only, so a
     coarse pointer never sees it and mobile keeps §21 C1's tap bloom
     untouched. No pointer tracking: a state switch, zero JS, nothing
     runs when no pointer is over the grid. */
  .eq-step1-tiles .eq-tile::after { transition: opacity 0.3s var(--ease); }
  .eq-step1-tiles .eq-tile:hover::after,
  .eq-step1-tiles .eq-tile:focus-visible::after { opacity: 1; }
  /* 14: a SELECTED tile carries the travelling rim; the hover adds nothing */
  .eq-step1-tiles .eq-tile.sel:hover::after,
  .eq-step1-tiles .eq-tile.sel:focus-visible::after { opacity: 0; }
}
.person h3 {
  margin-top: 10px;
  font-family: 'Instrument Sans', sans-serif;
  font-size: 0.72rem; font-weight: 500; letter-spacing: -0.01em;
}
.person .role {
  margin-top: 2px;
  font-size: 0.58rem; font-weight: 500;
  letter-spacing: 0.1em; text-transform: uppercase;
  line-height: 1.4;
  color: rgba(43, 31, 30, 0.75);
}
.people .about-link {
  display: inline-block;
  margin-top: clamp(2.5rem, 8vw, 4rem);
  color: var(--ink);
  font-size: 0.78rem; font-weight: 500;
  letter-spacing: 0.18em; text-transform: uppercase;
  text-decoration: none;
  border-bottom: 1px solid rgba(43, 31, 30, 0.4);
  padding-bottom: 7px;
  transition: color 0.3s var(--ease), border-color 0.3s var(--ease);
}
.people .about-link:hover, .people .about-link:focus-visible { color: var(--copper-lit); border-color: var(--copper-lit); }

/* ---- enquire block ---- */
.enquire-block { --sec-bg: var(--canvas); --sec-fg: var(--cream); --prev-bg: #EFE8DC; text-align: center; }
.enquire-block .h-section { margin-bottom: 14px; }
.enquire-block .enq-sub {
  color: rgba(242, 236, 227, 0.78);
  max-width: 34ch;
  margin: 0 auto clamp(1.6rem, 5vw, 2.2rem);
}
.enquire-block .inner { display: flex; flex-direction: column; align-items: center; }

/* ---- footer ----
   Centred (I1), with the lockup at a size where the wordmark actually reads
   and clear air before the icon row (the round-1 collision was the 44px
   lockup's wordmark sitting against the icons). The two small-print
   footnotes deliberately stay left-aligned: multi-line legal text centred
   goes ragged on both edges — flagged to Andrea rather than centred. */
.site-footer {
  position: relative;
  background: var(--canvas);
  color: var(--cream);
  padding: clamp(3rem, 10vw, 5rem) 1.5rem clamp(2.5rem, 8vw, 4rem);
  text-align: center;
}
/* The old 90px ::before band here is deliberately GONE (28 Aug, F5): it was
   a solid canvas-on-canvas overlay doing nothing visually, but as a
   positioned pseudo it painted ABOVE the footer's in-flow content by CSS
   paint order — a standing hazard over anything near the footer's top. */
.site-footer .inner {
  max-width: 1440px; margin: 0 auto;
  display: flex; flex-direction: column; gap: 2.2rem;
  align-items: center;
}
.site-footer .foot-col { display: flex; flex-direction: column; align-items: center; gap: 1.6rem; }
.site-footer .foot-logo { display: inline-block; }
.site-footer .foot-logo { margin-bottom: 10px; }
.site-footer .foot-logo img {
  height: 84px; width: auto; margin: 0 auto;
  /* The mark's dark elements need lift off the near-black canvas. The glow
     is deliberately TIGHT (was 16px): the wide halo spilled past the
     wordmark onto the icon row one gap below and read as something
     rendering on top of the Instagram and TikTok marks (F5). */
  filter: drop-shadow(0 0 8px rgba(255, 217, 163, 0.26));
}
.foot-social { display: flex; align-items: center; justify-content: center; gap: 26px; }
.foot-social a {
  display: inline-flex;
  color: var(--cream);
  opacity: 0.85;
  transition: opacity 0.3s var(--ease), color 0.3s var(--ease);
}
.foot-social a:hover, .foot-social a:focus-visible { opacity: 1; color: var(--copper-lit); }
.foot-social svg { width: 22px; height: 22px; fill: currentColor; }
.foot-contact { display: flex; flex-direction: column; align-items: center; gap: 10px; font-size: 0.92rem; }
.foot-contact a { color: var(--cream); text-decoration: none; }
.foot-contact a:hover, .foot-contact a:focus-visible { color: var(--copper-lit); }
.foot-notes { color: var(--cream-muted); max-width: 62ch; display: flex; flex-direction: column; gap: 8px; text-align: left; }
.foot-company { color: var(--cream-muted); font-size: 0.7rem; letter-spacing: 0.04em; }
/* ============ STAGE 80 ====================================================
   The company's legal line (Companies Regulations 2015, reg. 25). Each part
   between the middots is an inline-block, so the line wraps at the middots
   first; the address part is too long for one line at 320, so inside it each
   name is nowrap and it wraps only between names, never mid-name. */
.foot-company > span { display: inline-block; }
.foot-company > span > span { white-space: nowrap; }
/* /enquire/ and /enquire/sent/ have no site footer: the line sits after
   <main>, below every 100svh section, so it never enters the flow's layout
   or comes between a step and its Continue / Back row. */
.eq-company {
  position: relative; z-index: 1;
  margin: 0; padding: 1.25rem 1.5rem calc(1.25rem + env(safe-area-inset-bottom, 0px));
  background: var(--canvas); text-align: center;
}
/* ============ STAGE 62 ====================================================
   The footer's privacy link. Sits beside the company line and takes its size,
   but NOT its muted ink: --cream-muted on --canvas is 7.04:1 and would pass,
   yet a link people are meant to find should read as a link, so it keeps the
   full --cream at 16.17:1 with a copper hover at 9.17:1. */
.foot-legal { font-size: 0.7rem; letter-spacing: 0.04em; margin-top: 0.5rem; }
.foot-legal a { color: var(--cream); text-decoration: underline; text-underline-offset: 3px; }
.foot-legal a:hover, .foot-legal a:focus-visible { color: var(--copper-lit); }

/* The Article 13 line at the point of submission. ⚠ QUIET BY SIZE, NEVER BY
   CONTRAST -- the stage 30A rule, as applied to stage 61's junk-folder line.
   Full --cream ink at 16.17:1; it is smaller, not dimmer. It sits inside the
   form's grid above .eq-nav, so it cannot overlap or obscure the send button
   at any width.
   ⚠ COLOUR IS INHERITED, NOT NAMED. The form sits on the stage 11l LIGHT
   ground (#F4EADA with color: var(--ink)); this line first shipped as
   var(--cream) and measured 1.01:1 in Chromium — cream on cream, invisible.
   `inherit` takes the ground's own ink whichever ground it lands on, so the
   same rule is correct on the light form and would stay correct if the
   section ever went dark again. */
.eq-privacy { font-size: 0.82rem; line-height: 1.5; max-width: 52ch; color: inherit; }
.eq-privacy a { color: inherit; text-decoration: underline; text-underline-offset: 3px; }
.eq-privacy a:hover, .eq-privacy a:focus-visible { color: var(--eq-focus, var(--copper-lit)); }

/* ---- the legal page ------------------------------------------------------
   The first long-form text page on the site. It REUSES .section, .inner,
   .h-section and .body-copy (which already carries the 62ch measure) and adds
   only the two heading levels and the list, because nothing else here is
   text-heavy enough to have needed them. No new colour tokens. */
/* ⚠ STAGE 66 — 720px -> 674px, and this is a CENTRING change, not a measure
   change. .body-copy caps at 62ch, which is 634.1px once the font-size clamp
   tops out at 1.15rem (18.4px) from about 438px upward. The inner's content box
   was 680px, so the paragraphs sat left inside it and left 45.9px of air on the
   right while the headings spanned the full 680 -- two alignments in one column.
   674 = 634 measure + the 2 x 1.25rem padding, so the content box now EQUALS
   the measure: headings and paragraphs share both edges and the column sits in
   the middle of the page.
   ⚠ The measure itself is untouched -- 634px before and after. Nothing below
   674px of available width changes at all, which is every phone.
   ⚠ COUPLING: if .legal .body-copy's font-size or its 62ch cap ever changes,
   this number has to change with it. Both are in this file, a few rules apart. */
.legal .inner { max-width: 674px; margin: 0 auto; padding-inline: 1.25rem; }

/* ============ STAGE 65 — reading typography ===============================
   /privacy is the first page on this site anyone actually READS. Every other
   page is short display lines over photographs, so .section, .inner and
   .body-copy gave it a measure and never gave it a rhythm.
   ⚠ SPACE ONLY. Not one word, no colour, no line-height, no measure. */

/* ⚠ 1. THE h1 WAS BEHIND THE BAR. Measured: the bar is POSITION: FIXED and
   64px tall at every width, and .legal's padding-top was 44px from 320 all the
   way to 1023 -- so the heading's top edge sat 20px ABOVE the bar's painted
   bottom edge, i.e. underneath it. Because the bar is fixed it occupies no
   layout, so a margin would simply be covered by it and only padding on the
   section can clear it. 64 + 2.5rem leaves 40px of air.
   ⚠ Below 1024 only: from 1024 the section already pads 130-148px, which is
   66-84px of clearance, and that is not what Andrea is looking at. */
@media (max-width: 1023.98px) {
  .legal { padding-top: calc(var(--bar-h) + 2.5rem); }
}

/* ⚠ 2. THE PARAGRAPHS WERE TOO CLOSE — this is "the wording is too attached".
   The gap was 1rem = 16px against a 26.2px line height: 0.61 of a line, so
   paragraphs read as one block. One line height is the target and 1.6em IS
   one line height here, because the leading is 1.6. No <br> anywhere. */
.legal .body-copy, .legal .legal-list { margin-bottom: 1.6em; }

/* ⚠ 3. SPACE ABOVE A HEADING IS ROUGHLY TWICE THE SPACE BELOW IT, which is
   what binds a heading to the text it introduces instead of leaving it
   floating between two blocks. Above/below was 48/9.6 on h2 and 22.4/8 on h3;
   both pass, but in absolute terms the h3s were tight on a page this long.
   ⚠ Margins COLLAPSE with the preceding paragraph's 1.6em, so the rendered
   gap is the larger of the two, not the sum. */
.legal h1 { margin-bottom: 2.25rem; }
.legal .legal-h2 {
  font-family: 'Instrument Sans', 'Schibsted Grotesk', sans-serif;
  font-size: clamp(1.35rem, 4.6vw, 1.7rem); font-weight: 500; line-height: 1.2;
  letter-spacing: -0.01em; margin: 3.75rem 0 0.75rem; padding-top: 1.75rem;
  border-top: 1px solid var(--hairline);
}
.legal .legal-h3 {
  font-family: 'Instrument Sans', 'Schibsted Grotesk', sans-serif;
  font-size: clamp(1.05rem, 3.6vw, 1.2rem); font-weight: 500; line-height: 1.3;
  margin: 2.75rem 0 0.65rem;
}
.legal .legal-h2 + .legal-h3 { margin-top: 1.4rem; }
/* (stage 65 sets the paragraph gap above; this rule kept for the strong rule below) */
.legal .body-copy strong { font-weight: 600; }
.legal .legal-list { max-width: 62ch; margin-top: 0; padding-left: 1.1rem; }
.legal .legal-list li { margin-bottom: 0.55rem; line-height: 1.6;
  font-size: clamp(1rem, 4.2vw, 1.15rem); }
/* ⚠ gate 8: a long address or URL must never widen the page on a 320px
   screen. Anything inline that cannot wrap is allowed to break instead. */
.legal a { color: var(--cream); text-decoration: underline; text-underline-offset: 3px;
  overflow-wrap: anywhere; }
.legal a:hover, .legal a:focus-visible { color: var(--copper-lit); }
/* Stage 81: the visit-counting switch is a real <button> (it changes state
   here, it goes nowhere) dressed exactly as the links above. */
.legal .link-button { font: inherit; line-height: inherit; color: var(--cream);
  background: none; border: 0; padding: 0; margin: 0; text-align: left; cursor: pointer;
  text-decoration: underline; text-underline-offset: 3px; overflow-wrap: anywhere; }
.legal .link-button:hover, .legal .link-button:focus-visible { color: var(--copper-lit); }
@media (min-width: 1024px) {
  .site-footer { text-align: left; }
  .site-footer .inner { flex-direction: row; flex-wrap: wrap; justify-content: space-between; align-items: flex-start; }
  .site-footer .foot-col { align-items: flex-start; }
  .foot-contact { align-items: flex-start; }
}

/* ---- STAGE 3b (31 Aug): footer socials on their own row; glow off on desktop ----
   The marks left the first .foot-col in the markup (a five-file edit — the
   footer is duplicated verbatim, §43) so that on desktop they can span the
   footer as their own centred row. MOBILE GEOMETRY IS REPRODUCED EXACTLY:
   as .inner's own child the block receives the inner's 2.2rem gap where it
   used to get the column's 1.6rem, so it pulls back by the 0.6rem
   difference and every footer element measures where it always did. */
@media (max-width: 1023px) {
  .site-footer .inner > .foot-social { margin-top: -0.6rem; }
}
@media (min-width: 1024px) {
  /* A: the lockup's warm glow comes off — Andrea's call, taken knowing the
     §16 H cost (the mark's shaded near-black faces merge into the canvas;
     the measured figure is in the stage-3b record). Mobile keeps it: that
     decision has not been taken. */
  .site-footer .foot-logo img { filter: none; }
  /* Stage 3c: the four items share ONE row. At 1024 the rigid columns
     (875px + three 2.2rem gaps) overflow the 942px content box and a wrap
     container wraps instead of shrinking — so the desktop row is nowrap
     and the columns may shrink, which lets the notes PARAGRAPH re-wrap
     onto more lines. Type size untouched. */
  .site-footer .inner { flex-wrap: nowrap; }
  .site-footer .foot-col { min-width: 0; }
  /* B (revised, stage 3c): all four items on ONE row — lockup, marks,
     contact, company — in DOM order, content-width, space-between doing
     the distribution. Sizes are /portfolio/'s (§53): 34x34 marks in 48x48
     tap targets — nothing new invented. Accessible names, hrefs,
     target/rel and the hover mechanic are exactly as they were.
     Stage 11d (Andrea): the marks drop to the EMAIL's line — the contact
     block's second line, not its first. Measured, not calculated: at the
     old -15px the glyph centres sat level with the phone (15585.3 vs
     15584.3 page-y at 1440) and the email's centre was 25.0px lower
     (15610.3), so the offset moves by exactly that: -15px + 25px. Mobile
     is untouched — its own -0.6rem rule below stands, byte-identical. */
  .site-footer .inner > .foot-social {
    margin-top: 13px;
  }
  .site-footer .inner > .foot-social a {
    width: 48px; height: 48px;
    align-items: center; justify-content: center;
  }
  .site-footer .inner > .foot-social svg { width: 34px; height: 34px; }
}
/* ---- STAGE 84 (Andrea, 27 Sep): the desktop footer uses the whole width ----
   Stage 80's company line sat in the last column. As one long paragraph it
   made that column about 840px wide, left no free space, and so the four
   items packed to the left with the right of the page empty (and at 1024 the
   columns shrank, the lockup included). Here the row is a grid: the last
   column's box goes (display: contents) and its children are placed in it.
   The lockup, the marks and the contact block stay in tracks 1-3, spanning
   rows 1-2. /'s disclosure note and the privacy link are stacked in track 4,
   with the column's 1.6rem gap and the link's own 0.5rem. The company line
   gets a row of its own (row 3) across all four tracks, the footer's 2.2rem
   below them. Its inline size is contained, so its length never sizes a
   track, and space-between spreads the tracks edge to edge as before Stage 80.
   Every word of the line, its order and its type stay as they were. Only at
   1024 and up: below that the footer is its centred stack, byte-identical. */
@media (min-width: 1024px) {
  .site-footer .inner {
    display: grid;
    grid-template-columns: repeat(4, auto);
    justify-content: space-between;
    align-items: start;
    gap: 0 2.2rem;
  }
  .site-footer .inner > :not(:last-child) { grid-row: 1 / span 2; }
  .site-footer .inner > .foot-col { min-width: auto; }   /* a track never shrinks under its content (3c's min-width: 0 was for the flex row) */
  .site-footer .inner > .foot-col:last-child { display: contents; }
  .site-footer .foot-notes { grid-column: 4; grid-row: 1; }
  .site-footer .foot-legal { grid-column: 4; grid-row: 1; margin-top: 0; }
  .site-footer .foot-notes ~ .foot-legal { grid-row: 2; margin-top: calc(1.6rem + 0.5rem); }   /* the column's gap and the link's own margin */
  .site-footer .foot-company { grid-column: 1 / -1; grid-row: 3; margin-top: 2.2rem; contain: inline-size; }   /* 2.2rem: .inner's own gap */
}

/* ---- reduced motion ---- */
@media (prefers-reduced-motion: reduce) {
  /* both loops stop, but the button must still read as an object with light
     in it: the ring freezes where copper->white is visible, the bloom holds
     at reduced opacity and the flare holds low and static. */
  /* stage 11d: the founder ring never appears and the card never lifts */
  .person .portrait .p-ring { display: none; }
  .person .portrait, .person:hover .portrait, .person:focus-within .portrait { transition: none; transform: none; }
  .cta-wrap, .cta::after { animation: none; }
  .cta-wrap { --p3-ang: 40deg; }
  .cta-wrap::after { opacity: .38; }
  .cta-wrap[data-cta="light"]::after { opacity: .28; }
  .cta::after { --p3-fx: -12%; --p3-fo: .12; }
  .cta, .cta-wrap::before { transition: none; }   /* the stage-10b hover stays as a state change */
  .hero-cue i { animation: none; }
  .reveal, .js-reveal .reveal { opacity: 1; transform: none; transition: none; }
  .cap, .design-lines, .design-lines .l2,
  .panel .p-phase, .panel .p-spec li, .panel .hb-line, .panel .hb-rule, .panel .p-body { transition: none; }
  /* stage 10g: the mosaic's light never turns and the zoom is a plain
     state under reduced motion; the expanded view opens without animation
     (the reduced desktop path never builds the panels, so none of this is
     normally reachable there — recorded) */
  .hs2-cap { transition: none; }
  /* ⚠ stage 47: under reduced motion the expanded view has NO growth and NO
     backdrop fade -- it appears and disappears. hero.js primes nothing either,
     so the card can never be caught mid-flight. */
  .sw-view, .sw-view.on { transition: none; }
  .sw-view-box { transition: none; }
  .wave-dark, .wave-light { background: none; color: inherit; }
  .bar, .bar-logo .logo-full, .bar-logo .logo-mark { transition: none; }
  .eq-btn, .eq-chip, .eq-tile, .eq-tile::after { transition: none; }
  .eq-btn:hover, .eq-chip:hover { transform: none; }
}

/* ================= /what-we-do/ ================= */

/* opening: dark, image-led */
.wwd-open {
  position: relative;
  background: var(--canvas);
  color: var(--cream);
  padding-bottom: 44px;  /* the page-wide 44px rhythm (B1) */
}
.wwd-open .open-img { position: relative; }
.wwd-open .open-img img {
  width: 100%; height: auto;
  /* deeper bottom fade than the standard blend, so the display lines sit on
     near-canvas rather than on the furniture */
  -webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 11%, #000 64%, transparent 100%);
  mask-image: linear-gradient(180deg, transparent 0%, #000 11%, #000 64%, transparent 100%);
}
.wwd-open .open-lines {
  position: absolute;
  left: 1.5rem; right: 1.5rem; bottom: 1%;
  z-index: 2;
  text-align: center;  /* W2: matches everything above the steps run */
}
.wwd-open .open-lines .display {
  display: block;
  font-size: clamp(2.4rem, 9.5vw, 7.5rem);
  white-space: nowrap;
  color: var(--cream);
  /* Stage 26b. The 40px cue is KEPT — it is the depth cue the opening was
     designed with. Added to it are two tight, ZERO-OFFSET layers: stacking
     identical shadows multiplies density without introducing a hard edge, so
     the halo travels with the letterform instead of reading as a box.
     ⚠ This fixes a LIVE defect, not a hypothetical one. Measured by §59
     footprint sampling, the shipped lines were BELOW 4.5:1 at five of six
     desktop widths — 2.91 / 2.90 / 3.05 / 3.28 / 4.31, clearing only at 1920
     (5.28) — because at narrower widths the block reaches up out of the
     image's mask fade onto the lit table and lamp. With these two layers:
     5.79 / 6.23 / 6.83 / 7.37 / 7.53 / 7.89, zero glyph pixels below the
     floor at any width. The weakest passing configuration; 8px was measured
     against 10px, 12px and 14px and is the least that clears. */
  text-shadow: 0 6px 40px rgba(18, 16, 14, 0.55),
               0 0 8px rgba(18, 16, 14, 0.55),
               0 0 8px rgba(18, 16, 14, 0.55);
}
.wwd-open .open-copy { padding: clamp(2rem, 7vw, 4rem) 1.5rem 0; max-width: 1440px; margin: 0 auto; }
.wwd-open h1 {
  font-family: 'Instrument Sans', sans-serif;
  font-size: clamp(1.9rem, 7vw, 4rem);
  font-weight: 500; line-height: 1.05; letter-spacing: -0.02em;
  max-width: 24ch;
}
.wwd-open .open-copy .body-copy { margin-top: 1.4rem; color: var(--cream); }
/* Item 2 (29 Aug): the opening paragraphs both carried .reveal and both had a
   0s delay, so they arrived TOGETHER rather than in sequence. The class was
   never missing - the stagger was. This is the same 0.06s step the menu links
   use, applied to the second paragraph only, so it lands after the first.
   Nothing else about the line changes: same fade, same 0.7s, same curve. */
.wwd-open .open-copy .body-copy + .body-copy { transition-delay: 0.06s; }
.wwd-open .open-copy .body-copy + .body-copy { margin-top: 1rem; }

/* ============ Stage 26g: the opening reads before you scroll ==============
   The halo layer is display:none here and switched on only at >=1024, so the
   phone layout below is the shipped one, untouched. */
.wwd-open .open-halo { display: none; }

@media (min-width: 1024px) {
  /* The photograph is width:100% on a 4:3 file, so its rendered height is
     0.75 x viewport width — 75vw. Offsets are expressed against THAT, so the
     text stays locked to the composition rather than to the viewport.

     ⚠ THE GROUP IS PULLED UP so all three blocks sit inside the first
     viewport (Andrea: "everything needs to move more up... we can't see
     everything"). The binding width is 1024x768, where the photograph is
     exactly as tall as the viewport and the group has the least room; and
     1920x1080, where only the top 75% of the photograph is above the fold.
     The gap that gave up the most is the display-lines-to-subtitle one, which
     Andrea named: it was 29% of the image and is now 4%. */
  /* ---- Stage 26j: THE TWO GAPS ARE EQUAL, and the group lifts only where
         the fold is actually tight. ----
     Andrea: "the same gap we have between the main title and the subtitle,
     and the same gap between the subtitle and the paragraph below." Both are
     now --gap = 3vw, which is 4% of the photograph (the photograph is 75vw
     tall, and 4% of that is 3vw).

     ⚠ THE LIFT IS KEYED ON THE FOLD, NOT ON A WIDTH. The group's offset
     scales with vw while the fold is vh, so the binding case is a viewport
     that is wide but short — 1280x800 today, but equally a window dragged to
     1400x760. A `@media (width: 1280px)` rule would miss that window
     entirely, so --overrun measures the actual overrun against 100lvh and
     --lift spends it, capped at 16px. On every viewport with room, --overrun
     is negative and the clamp returns 0px: nothing moves. */
  .wwd-open {
    --group-top: 8.25vw;                                   /* 11% of the photograph */
    --lines-h: calc(1.9 * clamp(2.4rem, 9.5vw, 7.5rem));   /* two display lines */
    --gap: 3vw;                                            /* 4% of the photograph */
    /* the h1, both paragraphs and the 1rem between them. A measured
       constant — 382.3px as shipped, less the 7.4px gap B it contained, which
       --gap now replaces. It is the one term CSS cannot derive, because it is
       the height of real wrapped text. It is used ONLY inside --overrun, and
       --lift is clamped to 0-16px, so an error in it can never move the group
       by more than 16px or clip anything. Re-measure it if this copy changes. */
    --copy-h: 375px;
    /* the 24px fold floor plus 2px, because the lift is spent in subpixel
       layout that rounds against us: aiming at exactly 24 landed at 23.9. */
    --fold-margin: 26px;
    --overrun: calc(var(--group-top) + var(--lines-h) + 2 * var(--gap)
                    + var(--copy-h) + var(--fold-margin) - 100lvh);
    --lift: clamp(0px, var(--overrun), 16px);
  }
  .wwd-open .open-lines { top: calc(var(--group-top) - var(--lift)); bottom: auto; }
  .wwd-open > .open-copy {
    position: absolute; left: 0; right: 0;
    /* the group's top, plus the display lines' own height (two lines at the
       0.95 line-height they render), plus the 4% gap. Derived, not magic. */
    top: calc(var(--group-top) - var(--lift) + var(--lines-h) + var(--gap));
    z-index: 2; padding-top: 0;
  }
  /* ⚠ gap B is not the margin: .wave-light gives the h1 `margin-bottom:
     -0.16em`, which collapses into this margin and was shrinking a 1.1rem
     margin to a rendered 7.4px. The second term cancels exactly that, so the
     RENDERED gap is --gap, matching gap A. Derived from the h1's own
     font-size, so it tracks if that size ever changes.
     The second paragraph keeps its 1rem: `.body-copy + .body-copy` is four
     classes to this rule's three and still wins. */
  .wwd-open > .open-copy .body-copy {
    margin-top: calc(var(--gap) + 0.16 * clamp(1.9rem, 7vw, 4rem));
  }
  /* the dark ground below the photograph collapses */
  .wwd-open { padding-bottom: 0; }

  /* ---- the halo: the subtitle's own level ----
     ⚠ CORRECTED IN 48B. This block said "the display lines and the paragraphs
     have no duplicate in the DOM at all", which stopped being true at §141 on
     5 Sep: THE PARAGRAPHS HAVE A DUPLICATE and it is theirs, at 20px x4, in the
     STAGE 48B block far below. The DISPLAY LINES still have none. What is
     subtitle-only is this 14px x2 LEVEL, not the mechanism.
     Blur 14px x2 is exactly the level the display lines carried in 26b and 26f:
     subtle, no perceptible edge. It does NOT
     reach 4.5:1 and is not expected to; 26e's arithmetic settles that the
     copper's darkest stop (luminance 0.294) needs a ground <= 0.026 against
     plaster at 0.45, so the only passing shade is the black mass Andrea
     rejected twice. Reported in BUILD-LOG 120, not chased. */
  .wwd-open .open-halo {
    display: block;
    position: absolute; inset: 0;
    z-index: 1; pointer-events: none;
  }
  .wwd-open .open-halo .open-copy {
    position: absolute; left: 0; right: 0;
    /* the same expression as the real layer, --lift included, so the shade
       stays registered under the letters at every viewport */
    top: calc(var(--group-top) - var(--lift) + var(--lines-h) + var(--gap));
    padding-top: 0;
  }
  /* the halo paints one flat dark colour — it is a shape, not type.
     -webkit-text-fill-color also overrides the stand-in's inherited fill.
     NO REAL TEXT COLOUR IS TOUCHED BY ANY OF THIS. */
  .wwd-open .open-halo,
  .wwd-open .open-halo * {
    color: rgba(18, 16, 14, 1) !important;
    -webkit-text-fill-color: rgba(18, 16, 14, 1) !important;
    text-shadow: none !important;
    background: none !important;
  }
  /* the stand-in carries the heading's exact typography so the shade wraps on
     the same words. ⚠ .wave-light adds padding-bottom .16em / margin-bottom
     -.16em to the real h1 (it stops background-clip:text clipping
     descenders); without the same pair the stand-in is 10.2px short. */
  /* ---- Stage 26j: THE BOTTOM FADE, 36% -> 20% ----
     Andrea's ruling. The base rule fades from `#000 64%` to transparent at
     100% — 36% of the photograph's height, 276 to 518px across the six
     desktop widths. That depth was authored for the display lines when they
     sat at `bottom: 1%`, so they would land on near-canvas rather than on the
     furniture. 26g moved them to the top of the photograph and the reason
     went with them; what is left is a long, empty fade that 26h measured as
     still-visible photograph (dE 32 against canvas at the last paragraph,
     ~12 three-quarters of the way down), not dead pixels.

     ⚠ THIS IS NOT A CROP. 26h proved a crop unreachable: the fade starts at
     `0.64 x height`, so shortening the image drags the fade's start UPWARD
     into the text, away from the target at every width. The image's rendered
     height and width are untouched; only the fade's depth changes, and the
     photograph therefore ends sooner without a pixel being cut.

     ⚠ 20%, NOT the site's 11% standard: Andrea chose the halfway figure so
     this photograph keeps a softer ending than the rest of the site.

     ⚠ DESKTOP ONLY, and not merely because the stage is desktop-only. Below
     1024px the display lines are STILL at `bottom: 1%` — 26g's repositioning
     never applied to phones — so down there the 36% fade is still doing the
     exact job it was written for. Unscoping this would drop the phone's
     display lines onto the furniture: the defect the depth exists to prevent.

     ⚠ The paragraphs sit inside this fade, so a shallower fade shows more
     photograph beneath them and their contrast gets WORSE. Andrea has ruled
     that block an accepted exception (BUILD-LOG 120); it is measured and
     reported, not fixed. */
  .wwd-open .open-img img {
    -webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 11%, #000 80%, transparent 100%);
    mask-image: linear-gradient(180deg, transparent 0%, #000 11%, #000 80%, transparent 100%);
  }

  .wwd-open .wwd-title-shape {
    font-family: 'Instrument Sans', sans-serif;
    font-size: clamp(1.9rem, 7vw, 4rem);
    font-weight: 500; line-height: 1.05; letter-spacing: -0.02em;
    max-width: 24ch; margin: 0 auto;
    padding-bottom: 0.16em;
    margin-bottom: -0.16em;
    filter: blur(14px)
            drop-shadow(0 0 0 rgba(18,16,14,0.85))
            drop-shadow(0 0 0 rgba(18,16,14,0.85));
  }
}

/* Service grids — boxed cards (28 Aug). Each card is a typographic box on
   its own ground, and the grounds carry REAL colour: greens, blues, yellows,
   reds. Scoped amendment to the 26 Aug palette decision: that finding was
   about cool hues fighting the warm photography; these boxes carry no
   photography, so it does not apply here. Every photographic section on the
   site stays in the warm family. Every card ground is contrast-verified
   >= 4.5:1 for its heading, line AND sub-options text. */
.svc { }
.svc .grid {
  display: grid;
  gap: 14px;
  margin-top: 26px;
}
.svc-card {
  position: relative;
  padding: 1.5rem 1.3rem 1.7rem;
  border-radius: 18px;
  border: 1px solid rgba(255, 255, 255, 0.16);
  background: var(--card-bg, var(--surface));
  color: var(--card-fg, var(--cream));
  isolation: isolate;
}
.svc-card .wash { display: none; }
.svc-card::after {
  content: '';
  position: absolute; left: 12%; right: 12%; bottom: -8px; height: 28px;
  background: radial-gradient(ellipse at 50% 0%,
    rgba(255, 217, 163, 0.24) 0%, transparent 70%);
  filter: blur(12px);
  pointer-events: none;
}
.svc-card h3 {
  font-family: 'Instrument Sans', sans-serif;
  font-size: 1.35rem; font-weight: 500; letter-spacing: -0.015em;
}
.svc-card .svc-line {
  margin-top: 0.55rem;
  font-size: 0.98rem; line-height: 1.5;
  color: inherit;
  opacity: 0.94;
}
.svc-card .svc-subs {
  margin-top: 0.9rem;
  font-size: 0.72rem; font-weight: 500;
  letter-spacing: 0.08em; text-transform: uppercase;
  line-height: 1.9;
  opacity: 0.86; /* contrast-verified at this alpha on every card ground */
}
.svc-card .svc-built {
  display: inline-block;
  margin-top: 1rem;
  font-size: 0.7rem; font-weight: 500;
  letter-spacing: 0.14em; text-transform: uppercase;
  text-decoration: none;
  color: inherit;
  border-bottom: 1px solid currentColor;
  padding-bottom: 4px;
  transition: opacity 0.3s var(--ease);
}
.svc-card .svc-built:hover, .svc-card .svc-built:focus-visible { opacity: 0.8; }

/* G1 (28 Aug): the page's upper run reads centred — the opening copy, the
   grid headings, the whole-home block and the layers intro. The boxed
   cards' internal text stays left: centred lists inside small boxes go
   ragged. The steps run and everything after keep their own alignment. */
.wwd-open .open-copy { text-align: center; }
.wwd-open h1 { margin-left: auto; margin-right: auto; }
.wwd-open .open-copy .body-copy { margin-left: auto; margin-right: auto; }
.svc .inner, .whole-home .inner, .layers .inner { text-align: center; }
.svc-card, .layer { text-align: left; }
.whole-home .body-copy, .layers .inner > .body-copy { margin-left: auto; margin-right: auto; }

/* Stage 88: One thing, done properly now sits above Inside, so Inside blends from its green */
.svc-inside { --sec-bg: #EAE2D6; --sec-fg: var(--ink); --prev-bg: #33403A; }
/* softened tints (28 Aug, W1): the inside cards are pastels of their hues
   over the light section ground and carry INK text; the outside cards are
   deep muted tints over the dark ground with cream. Every composited value
   is contrast-verified >= 4.5:1 at every text size. */
.svc-inside .svc-card { --card-fg: var(--ink); border-color: rgba(43, 31, 30, 0.16); }
.svc-outside { --sec-bg: #3B2716; --sec-fg: var(--cream); --prev-bg: #EAE2D6; }
@media (min-width: 640px) { .svc .grid { grid-template-columns: 1fr 1fr; } }
/* stage 11e item 3 (Andrea: short final rows sit hard left): above 1024
   the grids become CENTRED FLEX WRAP — the same mechanism as the steps
   (item 2). Each card's basis is the grid column's own arithmetic —
   (100% minus the gaps) over the column count — so every card box is
   byte-identical to the old grid's (gate-measured at four widths); full
   rows fill exactly as before and only a short final row gains the
   centring. Card interiors stay left-aligned (§19 G1); the sixteen
   grounds and their order are untouched (§19 A2, §20 W1). */
@media (min-width: 1024px) {
  .svc .grid { display: flex; flex-wrap: wrap; justify-content: center; }
  /* Stage 73d (Andrea): every grid is four-wide, so the folded four are one
     full row and an opened six is 4 + 2 with the two centred. Outside was
     three-wide until this stage. */
  .svc .grid > .svc-card { flex: 0 0 calc((100% - 3 * 14px) / 4); }
}

/* the whole home */
/* Stage 73: the single-job grid sits between Outside and The whole home. Its
   ground is a deeper cousin of the bridge green (#2C3A2E) so the enquiry route
   and this section rhyme; the whole-home seam now blends from it. Four-wide
   above 1024 like every grid (73d). The intro is a SUBTITLE, not a paragraph
   (Andrea, 73d: "way smaller"): one line of small muted text, because Inside
   and Outside carry none and this section must not outweigh them. */
/* Stage 88: moved up to follow the intro directly, so it blends from the canvas (Inside's old neighbour) */
.svc-single { --sec-bg: #33403A; --sec-fg: var(--cream); --prev-bg: var(--canvas); }
/* 73f: Inside sits under "What we work on" at the same size as Outside and One
   thing; the rhythm token keeps it off the title above it. */
.svc-inside .svc-inside-h { margin-top: var(--intra-gap); }
.svc-single .svc-single-intro {
  max-width: 44ch; margin: 0.7rem auto 0;
  font-size: 0.92rem; line-height: 1.5;
  color: rgba(242, 236, 227, 0.8);   /* 6.4:1 on #33403A */
  text-wrap: balance;
}
.whole-home { --sec-bg: #EFE8DC; --sec-fg: var(--ink); --prev-bg: #3B2716; }   /* Stage 88: Outside is above it again */

/* Stage 73 / 73d / 73e: THE FOLD, below 1024 only (Andrea, 73e: "desktop should
   show the whole tiles"). A grid wwd.js has marked .is-folded shows its first
   four cards; the control after it (also script-made, so a page without script
   simply shows every card) opens the rest IN PLACE -- the grid's height is
   animated from the folded to the open measurement, so nothing above the
   control moves and the screen does not jump. "Show fewer" is the mirror
   (Stage 82d): the box shrinks over the extra cards, which keep their layout
   (so the four that stay never stretch), and the page follows it up frame by
   frame so the control stays under the visitor's finger. The hidden cards keep their
   .reveal and are still observed, so opening fades them up. Above 1024 neither
   the fold nor the control exists.
   THE CONTROL IS THE SITE'S UNDERLINE LINK -- the homepage's "See what we do"
   (.wwd-link), Andrea's reference picture -- made a button: uppercase, 0.18em,
   a hairline that runs past the label on both sides, in the section's own
   ink, copper-lit on hover. NOT the copper pill (73d tried it; ruled out the
   same day: the pill is Enquire's). It stands clear of the last row. */
@media (max-width: 1023px) {
  .svc .grid.is-folded > .svc-card:nth-child(n + 5) { display: none; }
}
.svc .grid.is-animating { overflow: hidden; transition: height 0.65s var(--ease); }
.svc .grid.is-closing { overflow: hidden; }   /* 82d: the close is drawn frame by frame by wwd.js */
.svc-more {
  display: inline-block;
  margin-top: clamp(2.4rem, 8vw, 3.4rem);
  padding: 0 1rem 7px;
  border: 0; border-bottom: 1px solid currentColor; border-radius: 0;
  background: none;
  color: inherit;
  font: 500 0.78rem/1.2 'Schibsted Grotesk', sans-serif;
  letter-spacing: 0.18em; text-transform: uppercase;
  cursor: pointer;
  transition: color 0.3s var(--ease), border-color 0.3s var(--ease), opacity 0.3s var(--ease);
}
.svc-inside .svc-more { border-bottom-color: rgba(43, 31, 30, 0.45); }
.svc-outside .svc-more, .svc-single .svc-more { border-bottom-color: rgba(242, 236, 227, 0.4); }
.svc-more:hover, .svc-more:focus-visible { color: var(--copper-lit); border-bottom-color: var(--copper-lit); }
.svc-inside .svc-more:hover, .svc-inside .svc-more:focus-visible { color: #8A4F1F; border-bottom-color: #8A4F1F; }
.svc-more:focus-visible { outline: 2px solid currentColor; outline-offset: 5px; }
@media (min-width: 1024px) { .svc-more { display: none; } }
@media (prefers-reduced-motion: reduce) {
  .svc .grid.is-animating { transition: none; }
}

/* Three layers band — boxed (28 Aug): each layer is a card, the image
   filling the card's top edge to edge (the biggest the image box can be
   inside a box), copy beneath. The push-in amendment applies unchanged. */
.layers { --sec-bg: var(--canvas); --sec-fg: var(--cream); --prev-bg: #EFE8DC; }
.layers .layer-list {
  display: grid;
  gap: 14px;
}
@media (min-width: 1024px) { .layers .layer-list { grid-template-columns: 1fr 1fr 1fr; align-items: start; } }
.layer {
  background: var(--surface);
  border: 1px solid var(--hairline);
  border-radius: 18px;
  padding: 0 1.2rem 1.5rem;
  overflow: hidden;
}
.layer .layer-img { border-radius: 18px 18px 0 0; overflow: hidden; margin: 0 -1.2rem; }
.layer .layer-img img {
  width: 100%; height: auto;
  transform: scale(1);
  will-change: transform;
}
/* 27 Aug amendment: push-in origins, set per image so the named subject
   cannot leave frame. Verified numerically at 1.04; margins in BUILD-LOG. */
.layer-lighting .layer-img img  { transform-origin: 50% 0%; }
.layer-smart .layer-img img     { transform-origin: 100% 100%; }
.layer-joinery .layer-img img   { transform-origin: 50% 50%; }
.layer h3 {
  margin-top: 1.3rem;
  font-family: 'Instrument Sans', sans-serif;
  font-size: 1.45rem; font-weight: 500; letter-spacing: -0.015em;
}
.layer p { margin-top: 0.7rem; font-size: 0.98rem; line-height: 1.6; max-width: 52ch; color: rgba(242, 236, 227, 0.88); }

/* The seven steps. The guarantee carries no number (28 Aug, D2): it is a
   different kind of thing, not step eight. */
.steps { --sec-bg: var(--canvas); --sec-fg: var(--cream); --prev-bg: var(--canvas); }
.steps .step-list {
  display: grid;
  gap: 1.8rem;
  margin-top: 26px;
}
/* the numbers sit in round circles (28 Aug, D1) */
.step .step-n {
  width: 36px; height: 36px;
  display: flex; align-items: center; justify-content: center;
  border-radius: 999px;
  border: 1px solid rgba(232, 167, 101, 0.55);
  background: rgba(255, 217, 163, 0.06);
  box-shadow: 0 0 18px -6px rgba(255, 217, 163, 0.4);
  font-size: 0.8rem; font-weight: 500;
  color: var(--copper-lit);
}
.step h3 {
  margin-top: 0.5rem;
  font-family: 'Instrument Sans', sans-serif;
  font-size: 1.3rem; font-weight: 500; letter-spacing: -0.015em;
}
.step p { margin-top: 0.7rem; font-size: 0.95rem; line-height: 1.6; max-width: 52ch; color: rgba(242, 236, 227, 0.85); }
/* stage 11e item 2 (Andrea, on a mouse with no horizontal scroll): the
   hidden-scrollbar track measured 2668px inside a 1392px container —
   four of seven steps unreachable. Replaced with the same centred flex
   wrap as the service grids: steps wrap downward, as many per row as
   fit comfortably (four at 1440, three at 1024), any short final row
   centred. Order and numbering 1-7 untouched; the 36px copper-hairline
   circles untouched; the guarantee stays unnumbered (§19 D1/D2).
   Mobile keeps its single-column list. */
@media (min-width: 1024px) {
  .steps .step-track { margin-top: clamp(2.5rem, 8vw, 4rem); }
  .steps .step-list {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: 2.2rem 3rem;
    margin-top: 0;
  }
  .step { flex: 1 1 260px; border-top: 1px solid var(--hairline); padding-top: 1.4rem; max-width: 340px; }
}

/* before/after */
.ba-sec { text-align: left; }
.ba-sec { text-align: center; }   /* item 3 (29 Aug): captions centred */
.ba-sec .ba-label {
  font-family: 'Instrument Sans', sans-serif;
  font-size: clamp(1.3rem, 4.5vw, 1.8rem);
  font-weight: 500; letter-spacing: -0.015em;
  margin-bottom: 1.4rem;
}
.ba-sec.ba-pair1 { --sec-bg: #68472E; --sec-fg: var(--cream); --prev-bg: var(--canvas); }
.ba-sec.ba-pair2 { --sec-bg: #1D220E; --sec-fg: var(--cream); --prev-bg: #68472E; }
.ba {
  position: relative;
  border-radius: var(--radius-card);
  overflow: hidden;
  touch-action: pan-y;
  cursor: ew-resize;
  max-width: 900px;
  margin-inline: auto;   /* item 3: centre the slider once max-width binds */
}
.ba-pair2 .ba { max-width: 520px; }
.ba img { width: 100%; height: auto; display: block; -webkit-user-drag: none; user-select: none; }
.ba .ba-after { position: absolute; inset: 0; }
.ba .ba-after img { width: 100%; height: 100%; }
.ba[data-mode="dissolve"] .ba-after { opacity: 0; }
.ba .ba-divider {
  position: absolute; top: 0; bottom: 0; left: 0;
  width: 1px;
  background: rgba(242, 236, 227, 0.65);
  pointer-events: none;
}
.ba .ba-divider::after {
  content: '';
  position: absolute; top: 8%; bottom: 8%; left: -7px; width: 15px;
  background: radial-gradient(ellipse at 50% 50%,
    rgba(255, 217, 163, 0.4) 0%, rgba(255, 217, 163, 0.12) 55%, transparent 80%);
  filter: blur(6px);
}
.ba[data-mode="dissolve"] .ba-divider {
  top: auto; left: 0 !important; height: 2px; bottom: -14px; width: 0;
  border-radius: 2px;
  background: rgba(255, 217, 163, 0.55);
}
.ba[data-mode="dissolve"] .ba-divider::after { inset: -5px -6px; top: -4px; bottom: -4px; }
.ba .ba-handle {
  position: absolute; inset: 0;
  background: none; border: 0; cursor: ew-resize;
}
.ba .ba-handle:focus-visible { outline: 2px solid var(--copper-lit); outline-offset: -2px; border-radius: var(--radius-card); }
.ba .ba-hint {
  position: absolute; left: 50%; bottom: 12px; transform: translateX(-50%);
  font-size: 0.62rem; font-weight: 500;
  letter-spacing: 0.24em; text-transform: uppercase;
  color: rgba(242, 236, 227, 0.85);
  text-shadow: 0 2px 14px rgba(18, 16, 14, 0.6);
  pointer-events: none;
  transition: opacity 0.7s var(--ease);
}
.ba .ba-hint.gone { opacity: 0; }

/* the guarantee: full colour at strength */
.guarantee {
  --sec-bg: #5A322E; --sec-fg: var(--cream); --prev-bg: #1D220E;
  overflow: hidden;
}
.guarantee .inner { position: relative; }
.guarantee::after {
  content: '';
  position: absolute;
  left: -10%; right: -10%; bottom: -30%; height: 90%;
  background: radial-gradient(ellipse at 50% 100%,
    rgba(201, 131, 90, 0.55) 0%, rgba(167, 95, 72, 0.25) 45%, transparent 75%);
  filter: blur(30px);
  /* stage 11e item 1 (the seam under "We stand behind it", §70 family):
     the bloom's ellipse centres BELOW the section, so the clipped bottom
     edge was the bloom's brightest visible line — the ground there was
     NOT the declared #5A322E, and the enquire band restarting from that
     hex painted a hard step at every width. No hex changes: the mask
     fades the bloom to zero AT the section's own bottom edge, whose
     position in this box is geometry, not judgement — the box spans
     0.4H to 1.3H of the section, so the edge sits at (1.0-0.4)/0.9 =
     66.7% of the box. The glow keeps its character behind the copy and
     dies before the boundary; #5A322E is then truly what the band
     starts from. */
  -webkit-mask-image: linear-gradient(180deg, #000 34%, transparent 66.7%);
  mask-image: linear-gradient(180deg, #000 34%, transparent 66.7%);
  pointer-events: none;
}
.guarantee .g-head {
  font-family: 'Instrument Sans', sans-serif;
  font-size: clamp(1.9rem, 7vw, 4rem);
  font-weight: 500; line-height: 1.05; letter-spacing: -0.02em;
}
.guarantee .g-body { margin-top: 1.4rem; position: relative; z-index: 2; }
/* Stage 25 item 3: the section below the two before/after sliders reads
   central. Centring only — the narrowing was asked for on /about/, not here,
   and .guarantee's own measured floor is 680px (at 660 its paragraph goes
   from three lines to four), recorded in case it is wanted later. The
   paragraph carries .body-copy's 62ch cap, so its box is narrower than the
   column and needs its own horizontal centring; margin-inline touches no
   vertical value. */
.guarantee .inner { text-align: center; }
.guarantee .g-body { margin-inline: auto; }

/* ================= /portfolio/ ================= */

.pf-intro {
  background: var(--canvas);
  color: var(--cream);
  /* Headroom reduced (29 Aug, items 1 and 4). .pf-intro is shared by
     /portfolio/ and /about/ ONLY - the homepage does not use it - and both
     pages asked for the same reduction, so it is one value, not two. */
  padding: calc(var(--bar-h) + clamp(1.75rem, 6vw, 4rem)) 1.5rem clamp(1.5rem, 5vw, 3rem);
}
.pf-intro .inner { max-width: 1440px; margin: 0 auto; }
/* /about/ ONLY (30 Aug, Andrea: the gap under the headline is still too big).
   The gap he means is NOT inside .pf-intro - on /about/ that section holds the
   h1 and nothing else. It spans two sections, and measured at 320/375/390/430
   it is 82.7 / 82.7 / 82.6 / 81.9px made of .pf-intro's own 24px bottom
   padding, #the-studio's 44px top padding and the first paragraph's 20.8px
   margin. Both sections carry the SAME ground (#12100E), so the whole of it
   reads as one gap under the headline with no boundary to justify it.
   Halved with two levers, each scoped so nothing else moves: this class is on
   /about/'s intro alone, and #the-studio is one section on one page.
   /portfolio/ shares .pf-intro and .proj and is untouched - its own headline
   gap is 17.8px because its first section opens on a photograph. */
.pf-intro-about { padding-bottom: 0.5rem; }
#the-studio { padding-top: 20px; }
/* /about/ ONLY (30 Aug, item 2). The gap between the garden photograph and
   "The people behind it" measured 116.8px at every width, and it is exactly
   #handle's 44px bottom padding + #people's 44px top padding + the hook's
   28.8px margin. ⚠ ALL THREE VALUES ARE SHARED: .proj's 44px is the site-wide
   rhythm on all 7 sections here and all 12 on /portfolio/, and .proj-hook's
   margin is every hook on both pages. So the change is scoped to #people, one
   section on one page, and #handle keeps its own rhythm untouched.
   After: 60.0px, 51% of before. */
#people { padding-top: 16px; }
#people .proj-hook { margin-top: 0; }
/* And the same argument below it (30 Aug, stage 18 item 3). Since the people
   heading took Massimo's ground, #people and #massimo are ONE continuous
   colour field, so the 88px between the last line of copy and the top of the
   photograph - #people's 44px bottom padding plus #massimo's 44px top padding
   - reads as one unexplained gap rather than a boundary. Halved and a bit,
   to 36px. Both values are the shared 44px site rhythm, so the change is
   scoped to these two sections on this one page; /portfolio/ and the other
   five .proj sections here keep theirs. */
#people { padding-bottom: 18px; }
#massimo { padding-top: 18px; }
.pf-intro h1 {
  font-family: 'Instrument Sans', sans-serif;
  font-size: clamp(2.4rem, 10vw, 7rem);
  font-weight: 500; line-height: 0.98; letter-spacing: -0.03em;
}

/* each project: its own solid ground; the seam above it is the div.seam in
   its markup (Stage 82b, see the block below and the foot of this file) */
.proj {
  position: relative;
  background: var(--proj-ground);
  color: var(--proj-fg);
  /* the site-wide 44px rhythm (28 Aug, P3; was clamp(4rem, 14vw, 8rem) —
     64px per edge on a phone, 128px on desktop) */
  padding: 44px 1.5rem;
}
/* Stage 82b: the scroll-driven cross-fade (body.pf-xfade, --pf-feather, the
   pf-flat runs, the compact paddings that went with it) is retired: Andrea
   chose the seams over it (25 Sep). Every .proj keeps its own solid ground,
   and between two different grounds sits a seam (.seam-host, at the foot of
   this file), whose paddings replace the engine's. What remains here is the
   straight band a browser WITHOUT mask images falls back to, the same shape
   as .section::before, from --prev-bg (now on every project's own style
   attribute, written by scripts/build-portfolio.py) to the project's ground.
   The seam rules hide it wherever a curve is drawn. */
.proj::before {
  content: '';
  position: absolute; left: 0; right: 0; top: 0;
  height: clamp(56px, 10vw, 120px);
  background: linear-gradient(180deg, var(--prev-bg, var(--proj-ground)), var(--proj-ground));
  pointer-events: none;
}
/* Item 6 (29 Aug): the hard line before "You know how you want it to feel".
   NOT a leftover ground, and the declared values DO agree - the enquire block
   says --prev-bg:#D8DFE4 and the last founder's ground IS #D8DFE4. The seam is
   a phase problem: portfolio.js centres its final fade on the last project's
   BOTTOM EDGE, so by the time the enquire block begins the interpolated ground
   has already travelled about half way to canvas (measured #757779 at the
   join), and the block's band then restarts from the full light colour and
   paints a light step back in - a 68.5 luminance jump.
   The band is kept exactly as it is, length and character untouched; only its
   STARTING colour is corrected to what is actually on screen at that point
   while the cross-fade is running. The static #D8DFE4 remains for the no-JS
   and reduced-motion paths, where there is no interpolation and the band
   really does start from the founder's ground. Scoped to an enquire block
   directly following a cross-faded project, which is /about/ only: on
   /portfolio/ the social section sits between them and its band is correct. */
/* the no-JS / reduced-motion value: there the band really does start from the
   last founder's ground. Moved off the element's inline style so the
   cross-fade rule below can override it in the cascade. */
/* The band's start colour. With NO engine (no JS, reduced motion) the last
   project really is #D8DFE4 right up to the block's edge, so this static value
   is simply correct there and stays. With the engine running, portfolio.js
   overwrites --prev-bg on this element every tick with the ground it has just
   interpolated — see "ONE CLOCK" there. The old .pf-xfade override was a
   second fixed hex (#757779) chosen to match at one scroll offset; swept
   across every offset it left a 3.39:1 line at the block's top edge, so it is
   gone rather than re-valued a third time. */
.proj + .enquire-block { --prev-bg: #D8DFE4; }
.proj .inner { position: relative; max-width: 1440px; margin: 0 auto; }
.proj .proj-img img { width: 100%; height: auto; }
/* Item 5 (29 Aug): breathing room between the end of the copy and the top of
   the photograph. Scoped to a figure that FOLLOWS its copy block, which is
   only /about/'s two scroll images - on /portfolio/ and on the founder bands
   the figure comes first, so neither is affected. The img-blend fade itself
   is untouched. */
.proj .proj-copy + .proj-img { margin-top: clamp(2rem, 7vw, 3.5rem); }
/* P2 (28 Aug): the photographs run edge to edge on mobile — the figure
   breaks out of the section gutter; the img-blend top/bottom fade still
   dissolves each image into its ground at full width. Wider, therefore
   taller at the same aspect: a size change, no crop, no zoom. */
@media (max-width: 1023px) {
  .proj .proj-img { margin-left: -1.5rem; margin-right: -1.5rem; }
}
.proj .proj-hook {
  font-family: 'Instrument Sans', sans-serif;
  font-size: clamp(1.7rem, 6vw, 3.2rem);
  font-weight: 500; line-height: 1.08; letter-spacing: -0.02em;
  margin-top: 1.8rem;
}
.proj .proj-title, .pf-intro .proj-title {
  margin-top: 0.9rem;
  font-size: 0.78rem; font-weight: 500;
  letter-spacing: 0.16em; text-transform: uppercase;
  opacity: 0.85;
}
.proj .proj-rule {
  width: 64px; height: 1px;
  margin-top: 1.1rem;
  background: var(--proj-accent);
  opacity: 0.9;
}
.proj .proj-body { margin-top: 1.3rem; }
.proj-light .proj-body { color: #4A3A2E; }
.proj-dark .proj-body { color: rgba(242, 236, 227, 0.88); }

@media (min-width: 1024px) {
  .proj .inner {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: clamp(3rem, 6vw, 6rem);
    align-items: center;
  }
  .proj .proj-hook { margin-top: 0; }
  .proj.img-right .proj-img { order: 2; }
  .proj.img-right .proj-copy { order: 1; }
  /* landscape images run wider; portrait narrower, at their own aspect */
  .proj .proj-img img { max-height: 86vh; width: auto; max-width: 100%; margin: 0 auto; }
}

/* ---- STAGE 1: shared desktop rules (31 Aug) ----
   Desktop-only. Nothing here is reachable below 1024px; the mobile baseline
   is byte-identical (gate-checked against scripts/homepage-baseline-ec2fe1d.json).

   A. Fade-band clearance, the .ct-call pattern generalised: the ::before band
   is clamp(56px, 10vw, 120px) — 120px on every desktop width — while the 44px
   rhythm was tuned against the 56px mobile band, so first lines rendered
   inside the gradient. Padding derives FROM the band, never a fixed number.
   CASCADE CONTRACT: this block sits BEFORE every section that derives its own
   top padding (.ct-speak at bar+44, .ct-call at the identical band+28), so at
   equal specificity those later rules keep winning; /about/'s #people 16px /
   #massimo 18px ID overrides outrank it regardless of order. Do not move this
   block below the /contact/ rules.

   C. Gutters (Andrea: "a little bit larger, but not too large"): the 1.5rem
   mobile gutter widens; .inner keeps its 1440px max-width. .proj keeps its
   own 1.5rem gutter — only its top padding was in scope. */
@media (min-width: 1024px) {
  .section {
    padding-top: calc(clamp(56px, 10vw, 120px) + 28px);
    padding-left: clamp(2rem, 4vw, 4.5rem);
    padding-right: clamp(2rem, 4vw, 4.5rem);
  }
  .proj { padding-top: calc(clamp(56px, 10vw, 120px) + 28px); }
  /* 11i item 2 (Andrea: tighten Layers -> How it happens). The steps
     section's band-derived top padding existed to house the §70 gradient,
     but .steps blends canvas INTO canvas (--prev-bg == --sec-bg) — the
     band is invisible and the 130-148px pad was empty ground. 48px here;
     the (invisible) band, both grounds and the --prev-bg chain are
     untouched. Later in source than the .section rule above, so it wins
     at equal specificity — the cascade contract this block documents. */
  .steps { padding-top: 48px; }

  /* B. Centring: the max-width:1023 block centres these three; there was no
     desktop counterpart, so above 1024 they fell back to left alignment.
     Card and list interiors stay left-aligned (BUILD-LOG 19 G1). */
  .wwd .wwd-copy { text-align: center; }
  .wwd .wwd-copy .body-copy { margin-left: auto; margin-right: auto; }
  .projects .inner { text-align: center; }
  .people .inner { text-align: center; }

  /* D. Desktop type scale, four named targets only — sized for a phone and
     never rescaled. NOT touched: .cta (the §60/§62 contrast measurement
     depends on its label size), hero captions, .p-* panel type (stage 7),
     anything under .eq-. */
  .projects .card b { font-size: clamp(1.05rem, 1.25vw, 1.3rem); }
  .projects .card i { font-size: clamp(0.85rem, 1vw, 1.05rem); }
  .person h3 { font-size: clamp(0.95rem, 1.1vw, 1.2rem); }
  .person .role { font-size: clamp(0.68rem, 0.8vw, 0.85rem); }
  .enquire-block .enq-sub { font-size: clamp(1.15rem, 1.25vw, 1.45rem); max-width: 52ch; /* 34ch was set for 11px type; mobile keeps it */ }
  .wwd .wwd-copy .body-copy { font-size: clamp(1.1rem, 1.2vw, 1.35rem); }

  /* E. Two-line headings, the mobile mechanism mirrored (29 Aug spans): the
     break is CSS and the joining punctuation lives in .ht-sep, so hiding it
     drops the comma / full stops with the break. Copy is one source. */
  .ht-sep { display: none; }
  .ht-a { display: block; }
}

/* ================= /about/ ================= */
/* The page is scaffolded on /portfolio/'s .proj sections VERBATIM (29 Aug,
   Andrea's instruction after three judder rounds): same wrapper, same reveal
   structure, same observer wiring, same 44px rhythm, same blend mechanism
   (static ground + tint band; portfolio.js cross-fade when JS runs). Only
   what About has and portfolio does not is styled below. Do not grow this
   block back into a parallel scaffold. */

/* a text-only .proj (no figure): the desktop two-column grid assumes an
   image, so the copy keeps a single centred column instead */
@media (min-width: 1024px) {
  /* Stage 25, items 1 and 2: 720 -> 660. ⚠ 660 is a MEASURED FLOOR, not a
     preference. At 640 #design's second paragraph goes from two lines to
     three, and at 600 the first goes from four to five — either would push
     #people, the three founders and everything below them down the page,
     and this stage moves nothing vertically. Verified line-count-identical
     at 1024 / 1280 / 1440 / 1680 / 1920. A comfortable ~60-character
     measure needs ~600px and costs those two lines; it belongs to the
     measured stage with the rest of /about/'s vertical work. */
  .proj.no-img .inner { display: block; max-width: 660px; }
  /* ⚠ STAGE 29 item 5, and stage 25 wrote the note that sends it here: "a
     comfortable ~60-character measure needs ~600px and costs those two lines;
     it belongs to the measured stage with the rest of /about/'s vertical work."
     This is that stage. 600px, and the two lines it costs are paid for many
     times over by the padding above. .proj.no-img exists on /about/ ONLY --
     zero matches on /portfolio/ -- so the selector needs no further scoping,
     and .guarantee's own 680px floor is a different rule and untouched. */
  body:not(.pf-compact) .proj.no-img .inner { max-width: 600px; }   /* Stage 82b: was scoped to the cross-fade engine's class; /about/ is the only page with .proj.no-img */
}
/* The column was ALREADY dead-centre on the page at every desktop width —
   measured 152|720|152 at 1024, 360|720|360 at 1440, 600|720|600 at 1920.
   What read as "offset to the left" was the TEXT alignment inside it. So
   this is the whole of the centring, and it changes no box height: text-align
   cannot re-wrap a line. Global, not desktop-only, because I1 (28 Aug) says
   the page's blocks read centred on mobile and these two sections were the
   exception that rule never reached. .ab-quiet-link is inline-block, so its
   border-bottom is its own 210px width and centres with the text — the rule
   matches the link rather than running the column's width. */
.proj.no-img .proj-copy { text-align: center; }

/* the founder portraits: side blends are baked into the image files (so no
   img-blend mask), the waist crop keeps its soft bottom edge (canon-website
   8.6), and the grounding underglow stays - the one piece of founder
   presentation the scaffold does not carry. The elliptical stage glow stays
   retired (BUILD-LOG 23). */
.proj-img.stage { position: relative; isolation: isolate; }
.proj-img.stage img {
  position: relative; z-index: 1;
  -webkit-mask-image: linear-gradient(180deg, #000 88%, transparent 99%);
  mask-image: linear-gradient(180deg, #000 88%, transparent 99%);
}
.proj-img.stage::after {
  content: '';
  position: absolute;
  left: 12%; right: 12%; bottom: -12px; height: 44px;
  z-index: 2;
  background: radial-gradient(ellipse at 50% 0%,
    rgba(255, 217, 163, 0.5) 0%, rgba(255, 217, 163, 0.14) 45%, transparent 72%);
  filter: blur(14px);
  pointer-events: none;
}

/* ================= STAGE 29: /about/'s rhythm =============================
   Andrea, 4 September: "the spacing I meant to be a little shorter was the
   spacing between the sections -- the headroom is a little too much now. That
   needs reducing on both mobile and desktop."

   ⚠ SCOPE. `body.pf-xfade:not(.pf-compact)` is /about/ AND ONLY /about/:
   portfolio.js adds .pf-xfade to both pages, and /portfolio/ carries
   .pf-compact in its own markup. Stage 15 leaked onto this page through the
   shared engine class and stage 17 had to scope it back out; this selector is
   written to make that impossible in the other direction. The ID rules below
   -- #the-studio, #people, #massimo -- are (1,0,0) and keep winning, so 11a's
   and 18's per-section work is untouched by construction.

   ⚠ WHAT THE 148px WAS. `.proj`'s desktop top padding is
   calc(clamp(56px,10vw,120px) + 28px), derived FROM the ::before band so first
   lines never render inside the gradient. On this page that band is
   `display: none` -- .pf-xfade .proj::before -- so 120px of the 148 is
   clearance for something never drawn. That is the same finding, and the same
   fix, as 11c on /portfolio/ (48px) and 11i on .steps (48px, "the band is
   invisible and the 130-148px pad was empty ground"). /about/ was simply never
   asked about; it is now.

   ⚠ WHAT THIS DOES TO THE FADES, measured before it was written. On this page
   the fade IS the feather -- 10% of each section's OWN height -- and stage 29's
   Phase 1 model is:
       rendered seam depth  ~=  0.90 x (feather_above + feather_below)
   which reproduces §107's recorded 157px for the fade below "See the before and
   after" to the pixel at 320. Shortening a section shortens its feather, so
   every seam gets shorter with this padding and NO feather value is touched.
   The headroom for that was measured first: every seam runs at 0.33-1.03 dE per
   row against §106's ~2.2 JND-per-row line. */
/* Stage 82b: the paddings at every change of ground on this page are the
   seams' (.seam-host / .seam-above, at the foot of this file); #the-studio,
   #people and #massimo keep their own ID rules where no seam sits. The
   engine-scoped 28/48/40px values that stood here are gone with the engine. */
/* ⚠ Andrea: the h1 is "a bit too big". SCOPED TO /about/'s intro -- .pf-intro h1
   is shared with /portfolio/, whose headline was not in scope and does not
   move. The ladder keeps the same shape one step down: 2.4 -> 2.1rem floor,
   10 -> 8.4vw, 7 -> 5.4rem cap. */
.pf-intro-about h1 { font-size: clamp(2.1rem, 8.4vw, 5.4rem); }

/* quiet in-page link (See the before and after) */
.ab-quiet-link {
  display: inline-block;
  margin-top: 1.8rem;
  color: inherit;
  font-size: 0.74rem; font-weight: 500;
  letter-spacing: 0.16em; text-transform: uppercase;
  text-decoration: none;
  border-bottom: 1px solid currentColor;
  padding-bottom: 5px;
  opacity: 0.8;
  transition: opacity 0.3s var(--ease);
}
.ab-quiet-link:hover, .ab-quiet-link:focus-visible { opacity: 1; }

/* the thank-you screen's way home (29 Aug): a quiet cream pill under the
   copy — never copper, that is Enquire's alone */
/* Stage 22 ruling 2: the honeypot's box. Taken off-screen rather than
   display:none or visibility:hidden — a naive bot skips those and fills
   this. It is aria-hidden and tabindex="-1" in the markup, so it is never
   announced and never focusable; this rule only removes it from sight. */
.eq-hp {
  position: absolute;
  left: -9999px;
  top: auto;
  width: 1px; height: 1px;
  overflow: hidden;
}
/* Stage 22 ruling 3: the reference on the confirmation screen, a step
   quieter than the body copy it sits under. */
.eq-ref {
  margin: 1.1rem auto 0;
  font-size: 0.9rem;
  color: rgba(242, 236, 227, 0.72);
  position: relative;
}
.eq-ref b {
  font-weight: 500;
  letter-spacing: 0.06em;
  color: rgba(242, 236, 227, 0.92);
}
.eq-done .eq-home-link {
  display: inline-block;
  margin-top: 2.2rem;
  padding: 14px 34px;
  border: 1px solid rgba(242, 236, 227, 0.5);
  border-radius: var(--radius-pill);
  color: var(--cream);
  text-decoration: none;
  font-size: 0.76rem; font-weight: 500;
  letter-spacing: 0.18em; text-transform: uppercase;
  transition: border-color 0.3s var(--ease);
}
.eq-done .eq-home-link:hover, .eq-done .eq-home-link:focus-visible { border-color: var(--cream); }

/* /portfolio/ social section (29 Aug). Ground is project 09's warm cream from
   the JSON palette; light, so it keeps the dark/light alternation between
   project 12 (dark) and the Enquire block (canvas, dark). Icons are the
   footer's own marks at a larger size - never platform brand colours, since
   copper is the only resting accent and the footer on this page carries the
   same two marks. */
.pf-social { text-align: center; }
.pf-social .body-copy { color: rgba(43, 31, 30, 0.88); max-width: 34em; margin: 1.2rem auto 0; }
.soc-row { display: flex; align-items: center; justify-content: center; gap: 22px; margin-top: 2rem; }
.soc-btn {
  display: inline-flex; align-items: center; justify-content: center;
  width: 48px; height: 48px;          /* tap target, >= 44x44 */
  color: var(--ink);
  opacity: 0.85;
  transition: opacity 0.3s var(--ease);
}
.soc-btn:hover, .soc-btn:focus-visible { opacity: 1; }
.soc-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; border-radius: var(--radius-pill); }
.soc-btn svg { width: 34px; height: 34px; fill: currentColor; }

/* ================= /contact/ ================= */
/* The phone-first page (29 Aug): the number is the strongest thing on it.
   Grounds from the What We Do box palette — warm sand, deep garden green,
   sage (NO red anywhere: the ember first version came out same day on
   Andrea's instruction) —
   blending through the standard section bands; NOT charcoal, NOT the
   enquiry scene (that image already carries the bridge and the thank-you).
   All text >= 4.5:1 composited; worst measured value in BUILD-LOG 33. */
.ct-speak {
  --sec-bg: #C0AB94; --sec-fg: var(--ink); --prev-bg: #C0AB94;
  /* 15 item 1: the gap under the bar, reduced SLIGHTLY (44 -> 36px);
     item 2: the room below it is the headroom token */
  padding-top: calc(var(--bar-h) + 36px);
  padding-bottom: var(--headroom);
  text-align: center;
}
.ct-speak .h-section { margin-bottom: var(--intra-gap); }   /* 15: the intra token, value unchanged */
.ct-speak .body-copy { color: rgba(43, 31, 30, 0.88); max-width: 34em; margin: 0 auto; }

/* Item 7 (29 Aug): this IS the fade-band case - .ct-call is a .section, its
   ::before band is clamp(56px, 10vw, 120px), and its 44px top padding put
   "CALL THE STUDIO" inside the gradient. Padding is now derived from the band
   itself plus 28px, so the label always lands clear of it whatever the band
   computes to at a given width. The band's own length and character are
   unchanged. */
.ct-call {
  --sec-bg: #394233; --sec-fg: var(--cream); --prev-bg: #C0AB94; text-align: center;
  /* 15: the band is the fade token and the padding derives from it plus
     the headroom — the first line lands clear of the band at every width */
  padding-top: calc(var(--fade-depth) + var(--headroom));
  padding-bottom: var(--headroom);
}
.ct-call::before { height: var(--fade-depth); }
.ct-label {
  font-size: 0.72rem; font-weight: 500;
  letter-spacing: 0.18em; text-transform: uppercase;
  color: rgba(242, 236, 227, 0.85);
  margin-bottom: 0.9rem;
}
.ct-label2 { margin-top: 3rem; }
.ct-row { display: flex; align-items: center; justify-content: center; gap: 16px; }
.ct-glyph { width: 30px; height: 30px; flex: none; color: rgba(242, 236, 227, 0.9); }
.ct-glyph-s { width: 21px; height: 21px; }
.ct-tel {
  font-family: 'Instrument Sans', sans-serif;
  font-size: clamp(2.2rem, 10.5vw, 4.4rem);
  font-weight: 500; letter-spacing: -0.02em; line-height: 1;
  color: var(--cream);
  text-decoration: none;
}
.ct-tel:focus-visible, .ct-mail:focus-visible { outline: 2px solid var(--cream); outline-offset: 4px; }
.ct-hours { margin-top: 0.9rem; font-size: 0.95rem; color: rgba(242, 236, 227, 0.85); }
.ct-mail {
  font-size: 1.05rem; font-weight: 500;
  color: var(--cream);
  text-decoration: none;
  border-bottom: 1px solid rgba(242, 236, 227, 0.45);
  padding-bottom: 3px;
}
.ct-mail:hover { border-color: var(--cream); }
.ct-area { margin-top: 1.1rem; font-size: 0.9rem; color: rgba(242, 236, 227, 0.75); }

.ct-flow {
  --sec-bg: #A7B2A6; --sec-fg: var(--ink); --prev-bg: #394233;
  text-align: center;
  /* 15: was the bare 44px with a 56px band on a phone — the heading's
     first line sat 12px inside the band; now fade + headroom, clear */
  padding-top: calc(var(--fade-depth) + var(--headroom));
}
.ct-flow::before { height: var(--fade-depth); }
.ct-flow .h-section { margin-bottom: 1rem; }
/* 15 item 4: centred on desktop as on mobile — the 62ch measure box was
   left-anchored in the centred .inner above 1024 (the I1 / "B. Centring"
   counterpart that /contact/ never had; not a measure-column decision: the
   measure stays 62ch, only the box centres) */
.ct-flow .body-copy { color: rgba(43, 31, 30, 0.88); margin-bottom: 1.6rem; margin-left: auto; margin-right: auto; }
/* hand the last ground to the canvas footer with a fade, not a line —
   the footer's own ::before stayed retired per F5 (28 Aug), so the blend
   lives on this section's bottom edge instead */
.ct-flow::after {
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: 0;
  /* 15: the fade token WITH THIS SEAM'S FLOOR (was clamp(56px, 10vw,
     100px)). Sage -> canvas is a ΔE 68 step, the page's largest: the
     ladder (§106) read 2.02 ΔE per row at 50px and 2.49 at 45px — past
     one JND per row, where a fade begins to read as a band — so the
     phone floor for this seam is 50px; the desktop token (72px) is above
     it. The two mid-page seams (ΔE ~50) stay under the JND down to 28px
     and take the bare token. This is the clearance budget's per-seam
     floor rule, applied: max(token, floor). */
  height: max(var(--fade-depth), 50px);
  background: linear-gradient(180deg, transparent, var(--canvas));
  pointer-events: none;
}
.ct-flow { padding-bottom: calc(var(--headroom) + max(var(--fade-depth), 50px)); }   /* 15: the CTA clears the fade by the headroom (was 44px + clamp(40px, 7vw, 72px)) */

/* ================= /enquire/ ================= */

/* Stage 11m: the dusk scene retires with its asset — the bookends carry
   the GREEN the homepage already runs (#2C3A2E = Bespoke Home Office's
   ground and GROUND_STOPS[3], the bedroom's film ground) under the same
   wave, dark derivation mode. The text keeps its soft dark shadow — it
   sits over moving crests now instead of a photograph. */
.eq-landing {
  min-height: 100svh;
  position: relative;
  display: flex; flex-direction: column; justify-content: center;
  background: #2C3A2E;
  color: var(--cream);
  padding: calc(var(--bar-h) + 2rem) 1.5rem 4rem;
  text-align: center;
}
.eq-landing .inner { max-width: 720px; margin: 0 auto; width: 100%; position: relative; z-index: 1; }
.eq-landing h1, .eq-landing .eq-sub, .eq-done h2, .eq-done .eq-done-title, .eq-done .body-copy {
  text-shadow: 0 3px 20px rgba(18, 16, 14, 0.5);
}
.eq-landing h1 {
  font-family: 'Instrument Sans', sans-serif;
  font-size: clamp(2.2rem, 9vw, 4.6rem);
  font-weight: 500; line-height: 1.04; letter-spacing: -0.03em;
}
.eq-landing .eq-sub {
  margin-top: 1rem;
  font-family: 'Instrument Sans', sans-serif;
  font-size: clamp(1.15rem, 4.6vw, 2.3rem);
  font-weight: 500; letter-spacing: -0.02em;
  color: rgba(242, 236, 227, 0.85);
}
.eq-landing .cta-wrap { margin-top: clamp(2.2rem, 7vw, 3.4rem); }
.eq-standard-link {
  display: inline-block;
  margin-top: 2.2rem;
  /* Stage 13: re-laddered LIGHTER, rendered, before the bookend wave grew
     (§102). The §77 cell — 4.88:1 at 0.8 alpha against 11m's crests —
     read 4.41-4.47:1 against the fog-17 crest (ground p95 under the
     footprint 0.085); the ladder 0.8 / 0.9 / 1.0 rendered 4.44 / 5.27 /
     6.12:1 (two draws). Full cream ships: 7:1 is NOT reachable rendered
     with any colour of the family (cream itself is the top rung, its
     0.74rem uppercase core rendering at L 0.771), so the headroom under
     the link is 6.1:1's, and the bookend wave is set against that. The
     hover keeps its lift in the border alone (0.35 -> cream). */
  color: var(--cream);
  font-size: 0.74rem; font-weight: 500;
  letter-spacing: 0.14em; text-transform: uppercase;
  text-decoration: none;
  border-bottom: 1px solid rgba(242, 236, 227, 0.35);
  padding-bottom: 5px;
  transition: color 0.3s var(--ease), border-color 0.3s var(--ease);
}
.eq-standard-link:hover, .eq-standard-link:focus-visible { color: var(--cream); border-color: var(--cream); }

/* ============ STAGE 73 — THE BRIDGE SAYS ITS TERMS =========================
   Andrea (19 Sep): the door was "a little bit boring and it might be a stopper".
   Three additions, copper untouched, no card (§22 stands), no new treatment
   under the text: an eyebrow carrying the desktop hero's scale line, a longer
   subline, and the three facts beneath the standard-form link.

   CONTRAST. Everything here sits on the bookend wave's crests, measured in
   §102/§13 at 6.12:1 for full cream and 5.27:1 at 0.9 alpha; 0.8 rendered
   4.44:1 and FAILED. So: sentences are full cream, the small labels are 0.9,
   nothing under 0.9 carries a word. The ticks are decorative (aria-hidden)
   and may sit lower. */
.eq-eyebrow {
  margin-bottom: clamp(1rem, 3.5vw, 1.5rem);
  font: 500 0.72rem/1.55 'Schibsted Grotesk', sans-serif;
  letter-spacing: 0.2em; text-transform: uppercase;
  color: rgba(242, 236, 227, 0.9);
  text-shadow: 0 3px 20px rgba(18, 16, 14, 0.5);
}
.eq-eyebrow .eq-sep { display: none; }
@media (min-width: 1024px) {
  .eq-eyebrow .eq-sep { display: inline; }
  .eq-eyebrow .eq-br { display: none; }
}
.eq-landing .eq-sub { max-width: 30ch; margin-left: auto; margin-right: auto; text-wrap: balance; }

.eq-facts { margin-top: clamp(2.6rem, 9vw, 3.6rem); position: relative; }
.eq-facts ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 1.1rem; }
.eq-facts li {
  font-family: 'Instrument Sans', sans-serif;
  font-weight: 500; font-size: clamp(1rem, 4vw, 1.12rem); line-height: 1.35;
  letter-spacing: -0.01em; text-wrap: balance;
  text-shadow: 0 3px 20px rgba(18, 16, 14, 0.5);
}
.eq-facts li small {
  display: block; margin-bottom: 0.4rem;
  font: 500 0.66rem/1 'Schibsted Grotesk', sans-serif;
  letter-spacing: 0.16em; text-transform: uppercase;
  color: rgba(242, 236, 227, 0.9);
}
.eq-ticks, .eq-pause { display: none; }

/* below 1024, once enquire.js has confirmed script AND motion: the cycle.
   Items stack in the same box (opacity only, so every one stays readable to
   assistive tech); the labels go, the sentence is the whole message; the
   active tick FILLS over the reading time like a story bar, finished ticks
   stay full, and the third's completion resets all three. The fill duration
   is one value shared with the script: --eq-dwell. */
@media (max-width: 1023px) {
  /* 73h: the block is a swipe surface. pan-y lets the page scroll vertically
     while a horizontal drag belongs to the cycle; the padding widens the
     target without moving the text (the margin gives the padding back). */
  .eq-cycling .eq-facts {
    --eq-dwell: 5s;
    /* 73i: out, then in, each carrying a slide. The distance is exactly the
       landing's own side padding (1.5rem), so a parked sentence reaches the
       viewport edge and never passes it; overflow-x: clip below is the second
       guarantee, for the §63 no-horizontal-overflow rule. --eq-dir is set by
       the script: 1 = forward (in from the right, out to the left), -1 = back. */
    --eq-slide: 22px; --eq-dir: 1;
    --eq-out: 0.34s; --eq-in-delay: 0.36s; --eq-in: 0.42s;
    overflow-x: clip;
    margin-top: calc(clamp(2.6rem, 9vw, 3.6rem) - 0.6rem);
    padding: 0.6rem 0;
    touch-action: pan-y;
    -webkit-user-select: none; user-select: none;
    cursor: grab;
  }
  /* a deliberate swipe answers at once: the same fade out, then in, run fast */
  .eq-cycling .eq-facts.eq-fast { --eq-out: 0.16s; --eq-in-delay: 0.18s; --eq-in: 0.22s; }
  /* Stage 77: the three share ONE grid cell, so the list is as tall as the
     tallest sentence from the first paint, before any script has measured
     (they were absolute, and the list sat at the 1.4em floor until
     enquire.js sized it: the bars jumped). align-items: start keeps each item
     at its own height, which is what enquire.js's fit() still measures. */
  .eq-cycling .eq-facts ul { display: grid; align-items: start; position: relative; min-height: 1.4em; }
  /* 73g (Andrea): NOT a crossfade. The outgoing sentence fades out fully,
     THEN the incoming one fades in -- the incoming transition is delayed by
     exactly the outgoing duration. */
  /* parked on the side it will arrive from */
  .eq-cycling .eq-facts li {
    grid-area: 1 / 1;
    padding: 0 0.5rem;
    opacity: 0;
    transform: translateX(calc(var(--eq-slide) * var(--eq-dir)));
    transition: opacity var(--eq-out) var(--ease), transform var(--eq-out) var(--ease);
  }
  /* the one leaving: away to the other side, fading as it goes */
  .eq-cycling .eq-facts li.out {
    opacity: 0;
    transform: translateX(calc(var(--eq-slide) * var(--eq-dir) * -1));
  }
  /* the one arriving: in from its parked side, at full strength on landing,
     starting only once the one before it has gone */
  .eq-cycling .eq-facts li.on {
    opacity: 1; transform: none;
    transition: opacity var(--eq-in) var(--ease) var(--eq-in-delay),
                transform var(--eq-in) var(--ease) var(--eq-in-delay);
  }
  .eq-cycling .eq-facts li small { display: none; }
  .eq-cycling .eq-ticks { display: flex; justify-content: center; gap: 0.5rem; margin-top: 1.1rem; }
  .eq-cycling .eq-ticks i {
    position: relative; display: block; width: 28px; height: 2px; border-radius: 1px;
    background: rgba(242, 236, 227, 0.26); overflow: hidden;
  }
  .eq-cycling .eq-ticks i::after {
    content: ''; position: absolute; inset: 0; width: 0;
    background: rgba(242, 236, 227, 0.9);
  }
  .eq-cycling .eq-ticks i.done::after { width: 100%; }
  .eq-cycling .eq-ticks i.on::after { animation: eq-tick-fill var(--eq-dwell) linear forwards; }
  /* while a finger is down the bar holds, so the fill never runs out from
     under a gesture */
  .eq-cycling .eq-facts.eq-held .eq-ticks i.on::after,
  .eq-cycling .eq-facts.eq-paused .eq-ticks i.on::after { animation-play-state: paused; }
  /* Stage 77: when enquire.js has to START the cycle over a page already
     painted stacked (the head script's 6s failsafe gave up first), the
     switch is instant: no sentence fades or slides across another */
  .eq-cycling .eq-facts.eq-instant li { transition: none; }
  /* Stage 77: pause / play. A 44x44 target whose centre sits on the bars'
     line, just right of them; absolute, so the bars stay exactly where they
     were and nothing below moves. The facts' bottom padding (0.6rem) puts
     the 2px bars' centre 0.6rem + 1px above the box's bottom edge.
     body.eq-cycling, not html: the head script's early class paints the
     message and the bars, but the button appears only once enquire.js has
     set the body class, i.e. once pressing it does something. */
  body.eq-cycling .eq-pause {
    display: flex; align-items: center; justify-content: center;
    position: absolute; width: 44px; height: 44px;
    left: calc(50% + 50px + 4px); bottom: calc(0.6rem + 1px - 22px);
    padding: 0; border: 0; border-radius: var(--radius-pill);
    background: none; color: rgba(242, 236, 227, 0.9); cursor: pointer;
  }
  .eq-cycling .eq-pause:focus-visible { outline: 2px solid var(--cream); outline-offset: -8px; }
  .eq-pause svg { display: block; fill: currentColor; stroke: currentColor; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }
  .eq-pause .eq-i-pause { fill: none; }
  .eq-pause .eq-i-play,
  .eq-facts.eq-paused .eq-pause .eq-i-pause { display: none; }
  .eq-facts.eq-paused .eq-pause .eq-i-play { display: block; }
}
@keyframes eq-tick-fill { from { width: 0; } to { width: 100%; } }

@media (min-width: 1024px) {
  .eq-facts ul { grid-template-columns: 1fr 1fr 1fr; gap: 0; max-width: 720px; margin: 0 auto; }
  .eq-facts li { padding: 0 1.4rem; border-left: 1px solid rgba(242, 236, 227, 0.22); font-size: 1.05rem; }
  .eq-facts li:first-child { border-left: 0; }
}

/* progress: a hairline across the top with a light travelling along the fill */
.eq-progress {
  position: fixed; top: 0; left: 0; right: 0;
  height: 2px; z-index: 95;
  background: var(--hairline);
  opacity: 0; transition: opacity 0.5s var(--ease);
}
.eq-active .eq-progress { opacity: 1; }
.eq-progress .fill {
  position: absolute; left: 0; top: 0; bottom: 0;
  width: calc(var(--eqp, 0) * 100%);
  background: rgba(232, 167, 101, 0.55);
  transition: width 0.7s var(--ease);
  overflow: hidden;
}
.eq-progress .fill::after {
  content: '';
  position: absolute; top: 0; bottom: 0; width: 90px;
  background: linear-gradient(90deg, transparent, var(--glow), transparent);
  animation: eq-travel 2.6s linear infinite;
}
@keyframes eq-travel { 0% { left: -90px; } 100% { left: 100%; } }

/* flow shell */
.eq-flow { display: none; }
.eq-active .eq-landing, .eq-active .eq-standard { display: none; }
.eq-active .eq-flow {
  display: flex; flex-direction: column;
  min-height: 100svh;
  padding: calc(var(--bar-h) + 1.5rem) 1.5rem 3.5rem;
  background: var(--canvas); color: var(--cream);
}
.eq-flow .inner {
  max-width: 720px; margin: 0 auto; width: 100%; flex: 1;
  display: flex; flex-direction: column;
  position: relative; z-index: 1;          /* above the ambient layers below */
}

/* --- flow atmosphere (30 Aug) -------------------------------------------
   Stage 11m: the 78s ::before underglow is GONE — REPLACED by the wave,
   not layered under it (the 10c .pin-ambient reasoning: two ambient
   systems over one another is not a richer effect). What remains is

   ::after, GRAIN - static, no animation, ever. An inline feTurbulence
   desaturated to grey, ~330 bytes, tiled at 180px. It is what makes a dark
   ground read as a material rather than a flat fill. No mix-blend-mode: a
   blend here would have to re-composite against the drifting radial beneath
   it on every frame, which is exactly the per-frame cost this stage forbids.
   Plain alpha composites once and then holds. */
.eq-active .eq-flow { position: relative; isolation: isolate; }
.eq-flow::after {
  content: '';
  position: fixed; inset: 0;
  pointer-events: none;
  z-index: 0;
}
.eq-flow::after {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23g)'/%3E%3C/svg%3E");
  background-size: 180px 180px;
  /* 11m: 0.045 measured ~0.8 of an 8-bit step on the light ground (2-3
     on dark) — invisible. Raised to 0.10 (measured stdev 1.14, ~half the
     dark-side weight): the same L 0.70 trough budget the wave spends
     caps it — full dark-weight grain alone would breach the budget and
     disturb the text cells, so it stops here; reported per the brief. */
  opacity: 0.10;
}
.eq-step {
  opacity: 0; transform: translateY(20px);
  transition: opacity 0.65s var(--ease), transform 0.65s var(--ease);
  display: none;
  flex: 1; flex-direction: column;
}
.eq-step.on { display: flex; opacity: 1; transform: none; }
.eq-step.leaving { opacity: 0; transform: translateY(-16px); }
/* C2 (28 Aug): the step headings read centred, matching the rest of the
   site; the answer buttons keep left-aligned text inside (they are read as
   a list), chips rows centre. */
.eq-echo {
  font-size: 0.72rem; font-weight: 500;
  letter-spacing: 0.16em; text-transform: uppercase;
  /* 11l: #8A4F1F, the light-CTA rim's copper (Andrea's ruling — the echo
     stays warmth, not body copy). 11m re-decides it one step darker for
     the wave's L 0.71 trough: #8A4F1F rendered 4.75:1 at the trough,
     #82491D 5.16:1 — same copper, one step down the same ladder. */
  color: #82491D;
  min-height: 1.1em;
  margin-bottom: 0.9rem;
  text-align: center;
}
/* ⚠ STAGE 34: no visible ring on the question, by :focus-visible SEMANTICS and
   never by blanket suppression. The heading is focused programmatically on
   every step change so a keyboard or screen-reader user is told the screen
   changed; a ring on a heading nobody clicked reads as a rendering fault to
   everyone else. `:focus:not(:focus-visible)` suppresses ONLY the non-keyboard
   case -- which is the only case this heading can ever be in, since tabindex -1
   keeps it out of the Tab order -- and leaves every real control's own
   :focus-visible ring exactly as it was. It is a belt for the engines that
   still paint a ring on programmatic focus of a tabindex -1 element. */
/* ============ STAGE 64, ITEM A ============================================
   THE RECTANGLE ANDREA SEES AROUND THE STEP HEADING. It is a focus ring, and
   the rule below was already here and was NOT enough.

   Stage 34 (commit 587c660, 5 September) made the flow's focus() call actually
   take: before that it ran while the step was still display:none, so it never
   moved focus at all and no ring was possible. So Andrea is right that recent
   work caused it -- the call is old, but it only started working a week ago.

   ⚠ THE STANDING-LIST ENTRY "`.focus()` NEVER MATCHES `:focus-visible`" IS
   WRONG AS A UNIVERSAL CLAIM, and this is the evidence. In Chromium after a
   pointer tap the heading matches :focus and NOT :focus-visible, so the rule
   below suppresses it and Chromium shows nothing -- which is why it reproduces
   on neither engine available here. Andrea sees a ring on iPad Safari and
   Android Chrome, and the only way a ring can be drawn while that rule exists
   is that :focus-visible DOES match there. The heuristic is per-engine and the
   claim cannot be relied on.

   So the suppression no longer depends on which heuristic an engine uses.
   ⚠ SCOPE: `.eq-q[tabindex="-1"]` and nothing else. That element is a heading,
   is never a tab stop, and is only ever focused by the script. NO blanket
   `outline: none` -- a rule at any scope that reached interactive elements
   would silently destroy keyboard navigation across the whole site. */
.eq-q:focus:not(:focus-visible) { outline: none; }
.eq-q[tabindex="-1"]:focus, .eq-q[tabindex="-1"]:focus-visible { outline: none; }
.eq-q {
  font-family: 'Instrument Sans', sans-serif;
  font-size: clamp(1.7rem, 6.6vw, 3rem);
  font-weight: 500; line-height: 1.08; letter-spacing: -0.02em;
  margin-bottom: clamp(1.6rem, 5vw, 2.4rem);
  text-align: center;
}
.eq-note { color: var(--eq-muted, var(--cream-muted)); font-size: 0.85rem; margin: -1rem 0 1.4rem; text-align: center; }
/* stage 11f item 10: the image steps' small text grows at EVERY width
   (Andrea); the general .eq-note grows on desktop only — the mobile size
   he called "actually good" stands. All rem, never px, so the user's own
   font scale is respected. */
.eq-note.eq-note-lg { font-size: 1rem; }

/* buttons */
.eq-opts { display: grid; gap: 12px; align-content: start; }
.eq-opts.cols2 { grid-template-columns: 1fr 1fr; }
/* D1: the two chip groups on the visit step breathe apart properly */
.eq-group-label {
  font-size: 0.72rem; font-weight: 500;
  letter-spacing: 0.18em; text-transform: uppercase;
  color: var(--eq-muted, var(--cream-muted));
  margin: 1rem 0 0.8rem;
  text-align: center;
}
.eq-chips + .eq-group-label { margin-top: 2.6rem; }
/* Resting state (28 Aug, C1): warm glass, per canon's own button language.
   A layered surface: soft cream gradient over the surface tone, a hairline
   with a lit top edge, and a deep tinted shadow that lifts the button off
   the canvas. No copper (Enquire-only), nothing loops; motion lives only in
   the interaction (hover lift, the approved press and bloom). */
.eq-btn {
  position: relative;
  text-align: left;
  background:
    linear-gradient(150deg, rgba(242, 236, 227, 0.09), rgba(242, 236, 227, 0.025) 62%),
    var(--eq-face, var(--surface));
  border: 1px solid rgba(242, 236, 227, 0.22);
  border-radius: 12px;
  color: var(--cream);
  font: 500 0.98rem/1.4 'Schibsted Grotesk', sans-serif;
  padding: 15px 18px;
  cursor: pointer;
  box-shadow:
    inset 0 1px 0 rgba(255, 240, 220, 0.14),
    0 12px 28px -20px rgba(0, 0, 0, 0.8);
  transition: background 0.35s var(--ease), border-color 0.25s var(--ease),
              transform 0.2s var(--ease), box-shadow 0.25s var(--ease);
  overflow: visible;
}
.eq-btn:hover {
  border-color: rgba(232, 167, 101, 0.4);
  transform: translateY(-1px);
  box-shadow:
    inset 0 1px 0 rgba(255, 240, 220, 0.18),
    0 14px 30px -18px rgba(0, 0, 0, 0.8);
}
.eq-btn:focus-visible { outline: 2px solid var(--eq-focus, var(--copper-lit)); outline-offset: 2px; }
.eq-btn:active { transform: scale(0.985); }
.eq-btn.sel {
  background: var(--eq-face-hi, var(--surface-hi));
  border-color: rgba(255, 217, 163, 0.5);
  box-shadow: inset 0 0 0 1px rgba(255, 217, 163, 0.22);
}
.eq-btn .bloom {
  position: absolute;
  width: 20px; height: 20px;
  border-radius: 50%;
  background: radial-gradient(circle, rgba(255, 217, 163, 0.65) 0%, transparent 70%);
  transform: translate(-50%, -50%) scale(0.4);
  opacity: 0;
  pointer-events: none;
}
.eq-btn .bloom.go { animation: eq-bloom 0.8s var(--ease) forwards; }
@keyframes eq-bloom {
  0% { opacity: 0.95; transform: translate(-50%, -50%) scale(0.4); }
  100% { opacity: 0; transform: translate(-50%, -50%) scale(7); }
}
.eq-btn.sel::after {
  content: '';
  position: absolute; left: 10%; right: 10%; bottom: -10px; height: 26px;
  background: radial-gradient(ellipse at 50% 0%, rgba(255, 217, 163, 0.30), transparent 70%);
  filter: blur(10px);
  pointer-events: none;
}

/* image tiles */
.eq-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.eq-tile {
  position: relative;
  border: 1px solid rgba(242, 236, 227, 0.2);
  border-radius: var(--radius-card);
  overflow: hidden;
  background: var(--eq-face, var(--surface));
  cursor: pointer;
  padding: 0;
  box-shadow: inset 0 1px 0 rgba(255, 240, 220, 0.12), 0 14px 30px -22px rgba(0, 0, 0, 0.8);
  transition: border-color 0.25s var(--ease), transform 0.2s var(--ease);
}
.eq-tile:hover { border-color: rgba(232, 167, 101, 0.4); }
.eq-tile:active { transform: scale(0.985); }
.eq-tile:focus-visible { outline: 2px solid var(--eq-focus, var(--copper-lit)); outline-offset: 2px; }
.eq-tile.sel { border-color: rgba(255, 217, 163, 0.6); box-shadow: 0 0 0 1px rgba(255, 217, 163, 0.35), 0 0 26px -6px rgba(255, 217, 163, 0.4); }
/* ---- Stage 14 / 14b: the selected state has to be obvious (Andrea, from
   a live phone screenshot: "you don't really see that you've selected it
   unless you remember that you clicked on it"). The selected option
   carries the site's own travelling rim — .cta::before's idiom, the
   masked conic gradient lapping the perimeter — on BOTH components, the
   text bars and the image tiles. The bloom stays exactly as it is
   (Andrea's ruling, 2 Sep: "keep it, it's very barely visible anyway");
   the rim is added over it.

   14b, Andrea's second ruling: the rim is CENTRED ON THE BOX EDGE and
   grows both ways — "in between the lines … a little bit more to the
   inside and a little bit more to the outside". The colours cannot
   cross the edge with the geometry: the inward half sits over the dark
   face (Y 0.045, 0.080 where the bloom lights it) and must be LIGHT;
   the outward half sits over the cream page and the post-13 wave
   (trough Y 0.692) and must be DARK — a one-colour rim needs L >= 0.244
   and L <= 0.197 at once (§103). So: TWO COAXIAL LAPS SHARING ONE ANGLE,
   drawn by one pseudo-element — its transparent border carries the
   outward lap (border-box background, px -2..0 of the element, the
   edge line included) and its padding carries the inward lap
   (padding-box background, px +1..+3); both conic gradients read the
   same --p3-sel-ang, so the highlight is continuous across the full
   6px. Growth: 2px outward, 3px inward, both widths, from the 1px
   border at rest.

   Colours, option A (§104): the inward lap's quiet band and its lap
   lead-in are #DB975D (Y 0.379 — 3.3:1 over the bloom-lit face, the
   brief's rendered floor of 3.2 met), the highlight the brand lap's own
   #E8A25C -> #FFF6EA -> #8FB8DE. The outward lap is the keyline's
   family: quiet #2B1F1E (Y 0.017) with a lead-in of #653916 and a peak
   of #9D653A (Y 0.165 — 3.45:1 at the wave's trough), so it laps too
   rather than sitting as a flat line. The element's own border also
   turns to #2B1F1E on selection, under the pseudo. Thicker than 11k's
   2px hover ring; the hover adds nothing on a selected option. The
   inward lap ends 3px inside the edge — the label's ink starts 17px
   (bars) / 10px (tiles) further in, measured; the rim is never the
   label's ground (the 11i/11j trap, delta 0). ONE animation, on the
   active step container; N paint consumers. */
.eq-step.on { animation: p3-sel-orbit 9s linear infinite; }
/* ---- Stage 28: THE RIM IS NOW A REUSABLE CLASS, `.p3-rim`. ----
   The enquiry simplification is queued behind this stage and will restructure
   parts of this flow, so nothing here is wired to today's markup: any element
   that carries `.p3-rim` gets the rim, and sets `--p3-rim-radius` to match its
   own corner. The three option types that carry it today are listed alongside
   so their rendering is byte-for-byte what shipped -- 14b's geometry, colours
   and angle are NOT re-derived, only re-pointed. */
.p3-rim { position: relative; }
.eq-btn.sel, .eq-btn.sel:hover, .eq-tile.sel, .eq-tile.sel:hover { border-color: #2B1F1E; }
.p3-rim::before,
.eq-btn.sel::before, .eq-tile.sel::before, .eq-chip.sel::before, .ew-cell.sel::before {
  content: '';
  position: absolute; inset: -3px; z-index: 1;   /* from the PADDING box: 1px border + 2px out */
  border: 3px solid transparent;          /* the outward lap: px -2..0, the edge line included */
  padding: 3px;                            /* the inward lap: px +1..+3 */
  border-radius: var(--p3-rim-radius, 14px);
  background:
    conic-gradient(from var(--p3-sel-ang),
      #DB975D 0deg, #DB975D 236deg, #DB975D 290deg, #E8A25C 316deg, #FFF6EA 334deg, #8FB8DE 352deg, #DB975D 360deg) padding-box,
    conic-gradient(from var(--p3-sel-ang),
      #2B1F1E 0deg, #2B1F1E 236deg, #653916 290deg, #9D653A 316deg, #9D653A 334deg, #653916 352deg, #2B1F1E 360deg) border-box;
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  pointer-events: none;
}
/* each component names its own corner; the values are exactly what 14b shipped */
.eq-tile.sel { --p3-rim-radius: calc(var(--radius-card) + 2px); }
.eq-chip.sel { --p3-rim-radius: var(--radius-pill); }
/* 14b: the tile's corner clip moves from the tile to its image so the
   outward lap can leave the box — the tile itself no longer clips */
.eq-tile { overflow: visible; }
.eq-tile .t-img { border-radius: var(--radius-card) var(--radius-card) 0 0; }
.eq-tile .t-img { display: block; width: 100%; aspect-ratio: 1; overflow: hidden; }
/* ^ width made DEFINITE at 11f-b: with the 11f max-height caps in place,
   an AUTO width let CSS aspect-ratio TRANSFER the height cap into a
   max-width (capped-height x ratio), and the box itself narrowed to the
   left portion of the tile — the image was faithfully covering a shrunk
   box. A definite width ends the transfer; the caps now cap height only
   and cover fills the full tile again. */
.eq-tile .t-img > div { width: 100%; height: 100%; background-size: cover; transition: background-position 1.2s var(--ease), background-size 1.2s var(--ease); }
/* Step 1 carries three 1254x522 photographs of its own (29 Aug), so the frame
   matches the asset at every width - 2.40:1 - which is what lets all three
   tiles, the heading, the labels and the progress bar share one mobile screen
   without scrolling. */
.eq-step1-tiles .eq-tile .t-img { aspect-ratio: 1254 / 522; }
/* stage 11f item 6 — a KNOWING REVERSAL of §63's rule (recorded §92):
   Andrea asked for the fit to be SOLVED, so the step's furniture now
   sizes from the height available — the photographs and gaps give way
   first and the type never shrinks. On tall screens the caps never
   bind and the §63 frame stands as it was. */
.eq-tiles { gap: min(14px, 1.8svh); }
@media (max-width: 1023px) {
  /* the caps are linear in the viewport's height — chosen so they never
     bind on a tall phone (390x844 keeps its natural tiles) and shrink
     exactly enough to bring the step's foot above the fold at 375x667
     and 360x640. At 320x568 the arithmetic cannot close without absurd
     slivers or smaller type — reported, per the stage's stop clause. */
  .eq-step1-tiles .eq-tile .t-img { max-height: calc(36svh - 169px); }
  .eq-tiles:not(.eq-step1-tiles) .eq-tile .t-img { max-height: calc(41.2svh - 215px); }  /* 184 -> 215 at stage 30A: this cap IS the step's furniture allowance,
     and 30A adds furniture -- the toggle in the helper line's slot and the
     'Not sure yet' block. §92 records the reversal as knowing and accepted:
     the photographs and gaps give way first and THE TYPE NEVER SHRINKS.
     215 also clears the -10.4px this step had been shipping with at 320
     since before this stage. */
}
.eq-tile img { width: 100%; height: 100%; object-fit: cover; }
.eq-tile .t-label {
  display: block;
  padding: 11px 13px;
  /* stage 11f item 10: 0.74rem -> 0.85rem, both widths (both .eq-tile
     steps share this component; §90-era sizing discipline: rem only) */
  font-size: 0.85rem; font-weight: 500;
  letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--cream);
  text-align: left;
}
/* Two-line minimum so a wrapping label cannot make its tile taller than its
   neighbours and leave the row ragged (29 Aug). The image above is a fixed
   ratio, so the label was the only variable. 1.2em is this font's computed
   `normal` line-height (14.21px at 11.84px, measured) x2 lines, plus the 22px
   of existing vertical padding - reproducing the 50.4px a two-line label
   already occupies. Font size, padding, letter-spacing and alignment are
   unchanged; flex only centres a one-line label in the two-line box.

   ⚠ SCOPED AWAY FROM STEP 1, and the measurement is why. Step 1 is a SINGLE
   column on mobile, so its three tiles have no neighbours in a row and cannot
   go ragged; measured, none of its labels wraps at any width, one line and
   36px at 320/375/390/430. Applying the minimum there anyway added 14.4px per
   tile, 43.2px over three, and step 1 had only 11.4px of headroom at 320 - it
   broke the one-screen acceptance test, content running to 721.8px against a
   690px viewport. Excluding step 1 costs nothing it needed and keeps that
   test passing. */
.eq-tiles:not(.eq-step1-tiles) .eq-tile .t-label {
  display: flex;
  align-items: center;
  min-height: calc(2 * 1.2em + 22px);
}

.eq-step1-tiles { grid-template-columns: 1fr; }
@media (min-width: 640px) { .eq-step1-tiles { grid-template-columns: 1fr 1fr 1fr; } }
/* stage 11k item 4: the desktop hover ring — 11j's mask-composite border
   trick on the tile itself, a warm ring in the outer 2px, clear of the
   overlaid label's ink (measured: label-footprint ground delta with the
   ring on vs off is ZERO at every width — the ring never becomes the
   label's ground; the 11i lesson, one component along). Defined here at
   opacity 0 — nothing at rest, nothing on touch; the hover+fine block
   reveals it, opacity the only animated property. */
.eq-step1-tiles .eq-tile::after {
  content: '';
  position: absolute; inset: 0; border-radius: inherit;
  padding: 2px;
  background: linear-gradient(105deg, #F2DCC0, #E8BE8C 50%, #F2DCC0);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask-composite: exclude;
  pointer-events: none;
  opacity: 0;
}
.eq-disclose {
  margin-top: 1.1rem;
  color: var(--eq-muted, var(--cream-muted));
  font-size: 0.8rem;   /* stage 11f item 10: grew with the tile titles */
}

/* chips */
.eq-chips { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }

/* D2: photograph previews, up to five (Stage 79; three before), each with a remove control */
.eq-ph-grid { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; justify-content: center; }
.eq-ph {
  position: relative;
  width: 136px; height: 136px;
  border-radius: 12px;
  overflow: hidden;
  border: 1px solid rgba(242, 236, 227, 0.22);
  background: var(--eq-face, var(--surface));
}
/* Stage 79 (Andrea, 25 Sep): on phones five previews sit 3 + 2, never
   2 + 2 + 1. Below 768 a preview is a third of the row less the two 10px
   gaps (about 107px at 390, 97px at 360), square, at EVERY count from one to
   five, so a preview never changes size as photos are added. The row is
   capped at three 136px previews, so a wider phone never fits four and a
   preview never grows past the desktop size. The 0.1px keeps three a row
   when the widths round. 768 and up is unchanged: 136px, one row.
   ⚠ width: 100% is load-bearing. In the flow the grid is a flex item of the
   step's column, and auto side margins switch off its stretch: without an
   explicit width the grid shrinks to its images, and a small photo, or a
   HEIC the browser cannot show, gets a sliver of a preview. */
@media (max-width: 767.98px) {
  .eq-ph-grid { width: 100%; max-width: calc(3 * 136px + 20px); margin-left: auto; margin-right: auto; }
  .eq-ph { width: calc((100% - 20.1px) / 3); height: auto; aspect-ratio: 1; }
}
.eq-ph img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* HEIC on browsers that cannot decode it: the frame stands in for the image */
.eq-ph.ph-plain img { display: none; }
.eq-ph.ph-plain::before {
  content: 'Photo';
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  font-size: 0.62rem; font-weight: 500;
  letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--eq-muted-onface, var(--cream-muted));
}
.eq-ph .ph-remove {
  position: absolute; top: 5px; right: 5px;
  width: 24px; height: 24px;
  border-radius: 999px;
  border: 0;
  background: rgba(18, 16, 14, 0.78);
  color: var(--cream);
  font-size: 15px; line-height: 1;
  display: flex; align-items: center; justify-content: center;
  cursor: pointer;
}
.eq-ph .ph-remove:focus-visible { outline: 2px solid var(--copper-lit); outline-offset: 1px; }
.eq-upload.full { opacity: 0.55; cursor: default; }
/* Stage 79: "More to show us?" under the previews at the limit, on both
   routes. It IS an .eq-note -- the step's own helper line, same size, ink and
   opacity -- so only the margin and the wrap are set here. Empty below the
   limit (a live region stays rendered), and an empty one takes no room. */
.eq-note.eq-ph-more { margin: 0; text-wrap: balance; }
.eq-note.eq-ph-more:not(:empty) { margin-top: 12px; }
/* the flow's nav glides by the line's height rather than jumping (enquire.js,
   photos()); the same token and easing as the Continue row's own settle */
.eq-photos .eq-nav { transition: transform var(--eq-nav-ms, 240ms) var(--ease); }
.eq-chip {
  border: 1px solid rgba(242, 236, 227, 0.22);
  background:
    linear-gradient(150deg, rgba(242, 236, 227, 0.09), rgba(242, 236, 227, 0.025) 62%),
    var(--eq-face, var(--surface));
  color: var(--cream);
  border-radius: var(--radius-pill);
  padding: 10px 18px;
  font: 500 0.85rem/1 'Schibsted Grotesk', sans-serif;
  cursor: pointer;
  box-shadow: inset 0 1px 0 rgba(255, 240, 220, 0.13);
  transition: background 0.35s var(--ease), border-color 0.25s var(--ease), transform 0.2s var(--ease);
}
.eq-chip:hover { border-color: rgba(232, 167, 101, 0.4); transform: translateY(-1px); }
.eq-chip:active { transform: scale(0.96); }
.eq-chip:focus-visible { outline: 2px solid var(--eq-focus, var(--copper-lit)); outline-offset: 2px; }
.eq-chip.sel { background: var(--eq-face-hi, var(--surface-hi)); border-color: rgba(255, 217, 163, 0.5); }

/* form fields */
.eq-form { display: grid; gap: 16px; }
.eq-field label {
  display: block;
  font-size: 0.72rem; font-weight: 500;
  letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--eq-muted, var(--cream-muted));
  margin-bottom: 7px;
}
.eq-field input, .eq-field textarea {
  width: 100%;
  background: var(--eq-face, var(--surface));
  border: 1px solid var(--hairline);
  border-radius: 2px;
  color: var(--cream);
  font: 400 1rem/1.5 'Schibsted Grotesk', sans-serif;
  padding: 13px 14px;
}
.eq-field input:focus-visible, .eq-field textarea:focus-visible {
  outline: none;
  border-color: rgba(232, 167, 101, 0.7);
  box-shadow: 0 0 0 1px rgba(232, 167, 101, 0.35);
}
.eq-field .f-error { color: var(--eq-error, #E8A765);   /* 11l: #8A4F1F on the light ground via --eq-error */ font-size: 0.78rem; margin-top: 6px; min-height: 1em; }
/* the contact-preference question breathes like the visit step's groups */
.eq-contactpref { margin-top: 1rem; }
.eq-contactpref label { margin-bottom: 16px; }
.eq-upload {
  border: 1px dashed rgba(242, 236, 227, 0.25);
  border-radius: var(--radius-card);
  background: var(--eq-face, var(--surface));
  padding: 26px 18px;
  text-align: center;
  color: var(--eq-muted-onface, var(--cream-muted));
  cursor: pointer;
  transition: border-color 0.3s var(--ease), color 0.3s var(--ease);
}
.eq-upload:hover, .eq-upload:focus-visible { border-color: rgba(232, 167, 101, 0.5); color: var(--cream); }
.eq-upload input { display: none; }
.eq-upload .u-count { display: block; margin-top: 6px; font-size: 0.78rem; color: var(--copper-lit); }

/* step footer nav */
.eq-nav {
  margin-top: auto;
  padding-top: 2rem;
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
}
.eq-back {
  background: none; border: 0; cursor: pointer;
  color: var(--eq-muted, var(--cream-muted));
  font: 500 0.74rem/1 'Schibsted Grotesk', sans-serif;
  letter-spacing: 0.14em; text-transform: uppercase;
  padding: 10px 0;
  /* ⚠ Stage 32D item 2: TRANSFORM, never height or margin. The layout change
     lands in one frame and this animates the difference away on the compositor,
     so no frame of the settle costs a relayout. enquire.js measures Back before
     and after the change and puts the difference back as a translateY. */
  transition: color 0.3s var(--ease), transform var(--eq-nav-ms, 240ms) var(--ease);
}
.eq-back:hover, .eq-back:focus-visible { color: var(--eq-back-hi, var(--cream)); }
.eq-continue {
  border: 1px solid rgba(255, 217, 163, 0.35);
  background: var(--eq-face-hi, var(--surface-hi));
  color: var(--cream);
  border-radius: var(--radius-pill);
  padding: 14px 34px;
  font: 600 0.72rem/1 'Schibsted Grotesk', sans-serif;
  letter-spacing: 0.18em; text-transform: uppercase;
  cursor: pointer;
  /* ⚠ Stage 32D item 3: the appearance is OPACITY ONLY -- no transform, no
     layout, no size change -- on the same token as Back's glide, so the row's
     height is constant for every frame after the first. */
  transition: border-color 0.35s var(--ease), transform 0.2s var(--ease), opacity var(--eq-nav-ms, 240ms) var(--ease);
}
.eq-continue:disabled { opacity: 0.55; cursor: default; }   /* 11m: .35 washed
     out on the light ground — present-but-inactive, not an absence */
.eq-continue:not(:disabled):hover { border-color: rgba(255, 217, 163, 0.6); }
.eq-continue:focus-visible { outline: 2px solid var(--eq-focus, var(--copper-lit)); outline-offset: 2px; }
.eq-continue:active { transform: scale(0.97); }

/* standard form + done */
.eq-standard, .eq-done {
  min-height: 100svh;
  position: relative;   /* 11m: hosts the wave canvas */
  background: var(--canvas); color: var(--cream);
  padding: calc(var(--bar-h) + 3rem) 1.5rem 4rem;
}
/* ---- stage 11l: the flow and the standard form go LIGHT ----------------
   Andrea: "like an off-white, more like a creamy effect — not really
   white." Ground laddered against the finished text set (§99): #F4EADA
   ships — the whole dark set gains margin as the ground lightens, and
   the weakest cell (the #8A4F1F echo, 5.48:1 analytic) is the binding
   constraint, not taste. The landing and the thank-you are the 11m
   BOOKENDS and keep their dusk scene; the 78s underglow ::before and
   the static grain ::after stay exactly as they are until 11m's wave.
   The scoped tokens below carry item 3: component faces stay DARK but
   softer than the near-black --surface (#3B2E2B = the warm family's
   ink mixed 8% toward #F2DCC0, 3.6x lighter than #1A1613; binding cell
   cream-muted upload text at 4.83:1 on the face), and the muted ink
   is the light-ground twin of cream-muted. The analytic ladder said
   the 30% mix; the RENDERED ladder overruled it — at the smallest
   usage (the 0.8rem disclosure) anti-aliasing lightens the core and
   the 30% mix read 4.21:1, under the floor. #53453E (the 20% mix)
   ships: worst rendered cell 5.44:1, and beside the ink headings its
   small uppercase letterforms still read a clear step quieter. */
.eq-active .eq-flow, .eq-standard {
  background: #F4EADA;
  color: var(--ink);
  --eq-face: #3B2E2B;
  --eq-face-hi: #473935;
  --eq-muted: #53453E;
  /* the derived states: focus keeps #8A4F1F (a 3:1 non-text indicator —
     4.6:1 even at the wave trough); the error line renders at
     0.78rem/400 where anti-aliasing lightens every copper ~0.03 L, and
     11m's trough re-ladder lands it at #653916 (4.98:1 at the L 0.71
     trough, 5.48:1 at its footprint's worst phase; #5F3612 is the
     copper boundary and stays in reserve); Back brightens to full ink */
  --eq-error: #653916;
  --eq-focus: #8A4F1F;
  --eq-back-hi: var(--ink);
  /* muted text ON the dark faces: cream-muted's 1rem/400 rendering on
     the softer #3B2E2B face read 4.39:1 (analytic said 4.83 — the same
     anti-aliasing lesson as the muted ink) — pulled 40% toward cream */
  --eq-muted-onface: #C5BCAA;
  /* ⚠ Stage 32D, items 2 and 3. ONE duration and ONE easing for the nav's whole
     settle: Back's glide and Continue's fade must read as a single motion, not
     two events that happen to overlap. Quick -- this is furniture settling, not
     an entrance. It also becomes the duration of Continue's existing
     disabled/enabled opacity change, which was 0.3s and is now this; that is a
     60ms speed-up of a fade nobody has ever timed, and it is the price of the
     two halves sharing one token rather than drifting apart. */
  --eq-nav-ms: 240ms;
}
.eq-standard .inner, .eq-done .inner { max-width: 720px; margin: 0 auto; position: relative; z-index: 1; }
/* Stage 77: the standard form's title is an <h2> under the bridge's <h1>, so
   its look is carried by the class. `.eq-standard h1` stays ONLY for the
   no-JavaScript failure page that functions/api/enquiry.js serves, whose
   markup is out of scope here. */
.eq-std-title, .eq-standard h1 {
  font-family: 'Instrument Sans', sans-serif;
  font-size: clamp(1.9rem, 7vw, 3.4rem);
  font-weight: 500; letter-spacing: -0.02em;
  margin-bottom: 2rem;
}
/* E1: the thank-you screen shares the bridge's scene and glass, centred,
   with the heading noticeably bigger */
.eq-done { display: none; }
body.eq-finished .eq-done {
  min-height: 100svh;
  position: relative;
  display: flex; flex-direction: column; justify-content: center;
  background: #2C3A2E;   /* 11m: the green bookend, same wave as the bridge */
  color: var(--cream);
  text-align: center;
}
.eq-done h2, .eq-done .eq-done-title {
  font-family: 'Instrument Sans', sans-serif;
  font-size: clamp(2.8rem, 12vw, 5.5rem);
  font-weight: 500; line-height: 1.02; letter-spacing: -0.03em;
  margin-bottom: 1.4rem;
}
.eq-done .body-copy { margin: 0 auto; color: rgba(242, 236, 227, 0.92); position: relative; }
/* stage 45: the confirmation now carries a second line (the acknowledgement
   email), and the rule above sets margin: 0, so the pair needs its own gap.
   Scoped to .eq-done — no other .body-copy on the site can see it. */
.eq-done .body-copy + .body-copy { margin-top: 1.1rem; }
/* ============ STAGE 61 ====================================================
   The junk-folder aside. ⚠ QUIET BY SIZE, NEVER BY CONTRAST -- the stage 30A
   rule holds: dimming this would put it under 4.5:1, and the people who need
   to read it are exactly the people it exists for. It keeps the full ink of
   the paragraph above it and is simply smaller. */
.eq-done .eq-junk { font-size: 0.88rem; margin-top: 0.85rem; }

/* The "did you mean" line. Built by enquire.js, so it never exists without
   JavaScript -- which is correct, because the suggestion is client-only.
   ⚠ It is NOT an error: it takes the field's own ink, not --eq-error, so it
   cannot read as a rejection. The control is a real button (keyboard
   reachable, announced) that looks like the address it is offering. */
.eq-field .f-suggest { font-size: 0.78rem; margin-top: 4px; color: inherit; }
.eq-field .f-suggest-fix {
  font: inherit; color: inherit; background: none; border: 0; padding: 0;
  text-decoration: underline; text-underline-offset: 2px; cursor: pointer;
}
.eq-field .f-suggest-fix:hover, .eq-field .f-suggest-fix:focus-visible { opacity: 0.82; }
.eq-done h2, .eq-done .eq-done-title { position: relative; }
/* The mask (Andrea, 28 Aug): a soft radial vignette deepening the scene
   behind the text block — feathered light falloff, not a panel — with the
   signature warm underglow beneath the heading. It frames Thank you
   without reintroducing a card. */
.eq-done .inner { position: relative; z-index: 1; padding: 2rem 0.5rem; }
.eq-done .inner::before {
  content: '';
  position: absolute; inset: -16% -24%;
  background: radial-gradient(ellipse 62% 58% at 50% 44%,
    rgba(18, 16, 14, 0.55) 0%, rgba(18, 16, 14, 0.3) 52%, transparent 74%);
  pointer-events: none;
}
/* the signature underglow, warmed up for the dark scene */
.eq-done h2.underglow::after, .eq-done .eq-done-title.underglow::after {
  left: 16%; right: 16%; bottom: -16px; height: 46px;
  background: radial-gradient(ellipse at 50% 0%,
    rgba(255, 217, 163, 0.5) 0%,
    rgba(255, 217, 163, 0.16) 48%,
    transparent 74%);
}
body.eq-finished .eq-flow, body.eq-finished .eq-landing, body.eq-finished .eq-standard { display: none !important; }
/* NOTE: no display:block here — an earlier stale override of the flex rule
   above was why the thank-you content sat at the top instead of centring */

@media (min-width: 1024px) {
  .eq-tiles { gap: 18px; }
  .eq-note { font-size: 1rem; }   /* stage 11f item 10: desktop only */
  /* 11l (Andrea, live, on the light flow): the single-column option
     lists ran the whole 720px column — "too large ... too much dead
     space" right of short labels. Capped and centred; the .cols2 grids
     already halve their buttons and stay as they are. Mobile keeps its
     natural full-column buttons. */
  .eq-opts:not(.cols2) { max-width: 420px; width: 100%; margin-left: auto; margin-right: auto; }
  /* stage 11k item 8: the opening question's three options STACK on
     desktop and size from the height actually available — the 3-across
     row wasted more room than any other step (226x94 images in a 720px
     column with a void above Back). Each tile is now a full-width photo
     bar with its label BELOW the photograph as on mobile, centred
     (Andrea, live on the first cut: not on the picture — "below the
     tile ... but possibly central"). The label strips cost height, so
     THIS STEP's own spacing gives way with them (the .eq-s1 scope the
     builder adds — no other step moves): the image height derives from
     the viewport minus the step's furniture. The rem term carries the
     type-driven parts, so a larger user font scale shrinks the
     PHOTOGRAPHS, never the type (11f's rule); the px term carries the
     bar, offsets, gaps and nav. aspect-ratio: auto — the height is set
     directly on a definite-width box, so the 11f transferred-sizing
     trap cannot arise (nothing here is a max-height against an
     aspect-ratio), and object-fit: cover crops the 2.40:1 asset to the
     bar. Mobile (<1024) renders untouched. */
  .eq-s1 h2 { margin-bottom: 1.2rem; }
  .eq-s1 .eq-note { margin-bottom: 1rem; }
  .eq-s1 .eq-tiles { gap: 12px; }
  .eq-s1 .eq-disclose { margin-top: 0.8rem; }
  /* ---- Stage 14c (Andrea, from an iPad-landscape screenshot: the three
     photographs "cropped to thin letterbox strips"; ruling, twice: "they
     keep the same aspect ratio"). A KNOWING REVERSAL of 11k's height
     clamp above and of the 11k gate "everything above the fold at all
     six desktop widths" — withdrawn for the two image steps. The new
     order: (1) the photograph shows WHOLE at its natural ratio — the
     tile's ratio IS the image's, height a consequence of width; (2) the
     step may exceed the viewport and scroll; (3) one-screen fit is a
     report, not a gate.

     Three stacked, and the WIDTH comes from a height budget — what keeps
     it dynamic:  tile width = min(column, budget x natural ratio),  the
     budget a fraction of the SMALL viewport height (the iPad's toolbar is
     the whole reason). The budget is chosen so the third tile always
     BREAKS THE FOLD: a partly visible third option is the scroll cue, and
     it costs no new UI (§105 for the measurement and the value chosen).
     On a tall desktop the column caps the width; on the iPad the budget
     narrows the tiles — the images stay whole at both. NO max-height
     anywhere on the desktop path: the 11f transfer trap (a max-height on
     an aspect-ratio box with an auto width becoming a max-width) cannot
     arise — the width is definite, the height follows the ratio. */
  /* the budget: 40svh was measured first and never binds — 0.40 x 768 x
     2.40 = 738px > the 720px column — so the third tile fell entirely
     below the fold at 768/800/820/900-tall viewports (0% visible). 26svh
     is what makes it break the fold at every desktop size measured:
     52px of it at 768, 116px at 900, 176px on the iPad's 1024 (§105). */
  .eq-s1 { --s1-budget: 26svh; }
  .eq-step1-tiles { grid-template-columns: 1fr; justify-items: center; }
  .eq-step1-tiles .eq-tile { width: min(100%, calc(var(--s1-budget) * 1254 / 522)); }
  .eq-step1-tiles .eq-tile .t-img { aspect-ratio: 1254 / 522; height: auto; max-height: none; }
  .eq-step1-tiles .eq-tile .t-label { text-align: center; }
  /* ---- Stage 14c, the feel step: "all four side by side … keep the same
     aspect ratio, without reducing the image too much just so they're
     side by side. So we need to stretch out the margins left to right."
     The step's container widens beyond the 720px column (the .inner
     that holds an active .eq-feel step goes to 1180px — using the left
     and right space that sat unused) so four 1:1 tiles are as large as
     that width allows; the question, its echo and note, and the nav row
     keep the 720px column inside it, so only the photographs use the
     extra width. Scoped to THIS step, as .eq-s1 is: every other step
     keeps its margins — Andrea was explicit that they earn their place
     where few options are stacked.

     ⚠ RECORDED: the same component now has TWO desktop layouts, KNOWINGLY.
     11k made step 1 full-width stacked bars and argued that step 1 and
     the feel step share .eq-tile / .t-label / .eq-disclose, so sizing one
     and not the other makes one component two sizes. Andrea asked for
     that divergence (14c); do not "restore consistency". */
  .eq-flow .inner:has(.eq-step.on.eq-feel) { max-width: 1180px; }
  .eq-feel .eq-echo, .eq-feel .eq-q, .eq-feel .eq-note, .eq-feel .eq-nav {
    max-width: 720px; width: 100%; margin-left: auto; margin-right: auto;
  }
  .eq-feel .eq-tiles { grid-template-columns: repeat(4, 1fr); }
}
@media (prefers-reduced-motion: reduce) {
  .eq-progress .fill::after { animation: none; }
  .eq-btn .bloom.go { animation: none; }
  /* 14: selection is never signalled by motion alone — the rim stays,
     static, its highlight parked at the angle .cta parks its own */
  .eq-step.on { animation: none; --p3-sel-ang: 40deg; }
  /* stage 30A: the knob moves without animating; the state stays legible
     because it is carried by position and colour, not by the movement */
  .eq-switch-track, .eq-switch-knob { transition: none !important; }
  .eq-unsure:hover, .eq-unsure:active { transform: none; }
  /* stage 28: the standard form's own orbit gets the same static angle, so its
     rim is present and both halves are visible with zero drift */
  .eq-standard { animation: none; --p3-sel-ang: 40deg; }
  .eq-step, .eq-tile .t-img > div { transition: none; }
  /* ⚠ Stage 32D: no movement animation and no fade. Both states land instantly
     and correctly, and Back is never mid-travel. enquire.js also primes nothing
     under reduced motion, so this is the second of two independent guards; the
     colour transitions stay, because a colour is not motion. */
  .eq-back { transition: color 0.3s var(--ease); }
  .eq-continue { transition: border-color 0.35s var(--ease); }
}

/* ================= Stage 18: the rhythm adopted =========================
   /what-we-do/ and the homepage below the hero take §107's tokens. This
   block is LAST in the file and every selector below is a compound
   (0,2,0) against the shared `.section` rules' (0,1,0), so it wins on
   specificity and does not depend on source order.

   ⚠ SCOPING. `.section`'s own padding is NOT touched: /portfolio/'s
   .pf-social and .enquire-block and /contact/'s three sections all read
   it, and stage 15's leak onto /about/ through a shared class is the
   reason this block names page-unique classes instead. `.svc`,
   `.whole-home`, `.layers`, `.ba-sec`, `.guarantee` exist only on
   /what-we-do/; `.wwd`, `.projects`, `.people` only on the homepage; the
   two `.enquire-block` instances are reached by adjacency
   (`.guarantee +` for /what-we-do/, `.people +` for the homepage), the
   pattern `.proj + .enquire-block` already established for /about/.

   THE FLOOR PER SEAM comes from §107's inventory and is written as
   max(var(--fade-depth), <floor>) on the band of the section BELOW the
   seam — never by changing the token. Desktop floors: class A/B/C 72px,
   class D 84px. The desktop token is clamp(34px, 6vw, 72px), which is
   61.4px at 1024 and 70.8px at 1180, so the max() is what holds those
   two widths at their floor.

   ⚠ PHONES TAKE THE HEADROOM ONLY, and this is a reported limit, not an
   oversight (§108). §107's phone floors — B 56, C 68, D 80px — are at or
   ABOVE the 56px band these pages already ship, so re-pointing a phone
   band to its floor makes the page TALLER, and §106/§107's standing rule
   is to leave a seam at its floor and report rather than tune it. What
   IS safe on a phone is the previous section's padding-bottom: the
   `.section` band is TOP-ONLY (`::before { top: 0 }`), so a section's
   last line sits on its own solid ground and moving it 18px nearer the
   boundary changes no composited ground at all. Every phone band and
   every phone padding-top is therefore untouched, and each in-scope
   phone seam loses exactly 44 − 26 = 18px.

   NOT TOUCHED anywhere in this block: the hero → What We Do boundary
   (`.wwd`'s own padding-top and band — the film's bottom edge, out of
   scope), `.steps`'s 48px padding-top (11i item 2, an invisible band on
   canvas → canvas), and every enquire-block → footer boundary (a flat
   seam, ΔE 0, shared `.site-footer` on all six pages). */

/* ---- phones: the previous section's padding-bottom only ---- */
@media (max-width: 1023px) {
  .wwd-open,
  .section.svc, .section.whole-home, .section.layers, .section.steps,
  .section.ba-sec, .section.guarantee,
  .section.wwd, .section.projects, .section.people { padding-bottom: var(--headroom); }
}

/* ---- desktop: the full recipe, band + padding-top + padding-bottom ---- */
@media (min-width: 1024px) {
  .wwd-open,
  .section.svc, .section.whole-home, .section.layers, .section.steps,
  .section.ba-sec, .section.guarantee,
  .section.wwd, .section.projects, .section.people { padding-bottom: var(--headroom); }

  /* /what-we-do/, seam by seam (§107 class in the comment) */
  /* Stage 88: One thing (.svc-single) now sits between the intro and Inside. Its boundary from the intro is
     dark into dark, as its old one from Outside was, so it keeps the bare band; the D floor below now serves
     single → inside, dark into light like the intro → inside it was measured for */
  .section.svc-inside { padding-top: calc(max(var(--fade-depth), 84px) + var(--headroom)); }   /* single → inside, D */
  .section.svc-inside::before { height: max(var(--fade-depth), 84px); }
  .section.svc-outside { padding-top: calc(max(var(--fade-depth), 72px) + var(--headroom)); }  /* inside → outside, C */
  .section.svc-outside::before { height: max(var(--fade-depth), 72px); }
  .section.whole-home { padding-top: calc(max(var(--fade-depth), 72px) + var(--headroom)); }   /* outside → whole, C */
  .section.whole-home::before { height: max(var(--fade-depth), 72px); }
  .section.layers { padding-top: calc(max(var(--fade-depth), 84px) + var(--headroom)); }       /* whole → layers, D */
  .section.layers::before { height: max(var(--fade-depth), 84px); }
  .section.ba-pair1 { padding-top: calc(max(var(--fade-depth), 72px) + var(--headroom)); }     /* steps → pair 1, B */
  .section.ba-pair1::before { height: max(var(--fade-depth), 72px); }
  .section.ba-pair2 { padding-top: calc(max(var(--fade-depth), 72px) + var(--headroom)); }     /* pair 1 → pair 2, B */
  .section.ba-pair2::before { height: max(var(--fade-depth), 72px); }
  .section.guarantee { padding-top: calc(max(var(--fade-depth), 72px) + var(--headroom)); }    /* pair 2 → guarantee, B */
  .section.guarantee::before { height: max(var(--fade-depth), 72px); }
  .guarantee + .enquire-block { padding-top: calc(max(var(--fade-depth), 72px) + var(--headroom)); }  /* guarantee → enquire, B */
  .guarantee + .enquire-block::before { height: max(var(--fade-depth), 72px); }

  /* the homepage below the hero (the hero → wwd boundary is untouched) */
  .section.projects { padding-top: calc(max(var(--fade-depth), 72px) + var(--headroom)); }     /* wwd → projects, B */
  .section.projects::before { height: max(var(--fade-depth), 72px); }
  .section.people { padding-top: calc(max(var(--fade-depth), 72px) + var(--headroom)); }       /* projects → people, C */
  .section.people::before { height: max(var(--fade-depth), 72px); }
  .people + .enquire-block { padding-top: calc(max(var(--fade-depth), 84px) + var(--headroom)); }     /* people → enquire, D */
  .people + .enquire-block::before { height: max(var(--fade-depth), 84px); }
}

/* ============ Stage 27: the two before/after sliders, side by side =========
   Andrea (4 Sep): "from the carcass to the finished wall, and from groundwork
   to finished garden — on desktop they need to sit side by side. A single
   section where both animations sit, sharing the same background: the carcass
   one on the left, the garden one on the right. Ensure the margins are a
   little larger, and if the images need to be a little smaller, so be it."

   ⚠ BELOW 1024 THE WRAPPER GENERATES NO BOX. `display: contents` means the
   two sections stay exactly where they are in the phone's flow, with their own
   grounds, their own seam bands and their own padding — byte-for-byte what
   ships today. Merging them into one <section> would have cost the phone a
   ground, a band and the height of one section, and Andrea has said repeatedly
   that this page reads well on mobile and must not change there.

   ⚠ THE CAPTIONS ARE UNCHANGED — same element, same class, same type, same
   place above their own slider, one each, never merged into a shared row.
   Andrea, asked directly: "I wouldn't change the way the caption looks."

   Placed at the end of the sheet deliberately: the desktop rules below have to
   beat the §107 seam rows for .ba-pair1/.ba-pair2, which set the padding-top
   and band these cells must no longer carry. */
.ba-pair-wrap { display: contents; }

@media (min-width: 1024px) {
  /* ---- the wrapper becomes the section ----
     It carries the one surviving ground, the seam band and the vertical
     padding; the two <section>s become cells that paint nothing. What the
     visitor sees is a single section with a single background. */
  .ba-pair-wrap {
    display: grid;
    /* ⚠ DERIVED FROM THE TWO ASPECT RATIOS, so the two sliders come out the
       SAME HEIGHT rather than one towering over the other: the wall is
       1440x1080 (w/h 1.3333) and the garden 960x1281 (w/h 0.7494), so the
       columns are in the ratio 1.3333 / 0.7494 = 1.779 to 1. Equal columns
       would have left the portrait garden 257px taller than the wall at 1024. */
    grid-template-columns: 1.779fr 1fr;
    align-items: start;
    /* "ensure the margins are a little larger": this gutter is ON TOP of the
       1.5rem each cell already carries, so the trough between the two sliders
       is 48px + this, and the outer margin is 24px + the inline padding. */
    column-gap: clamp(40px, 5vw, 96px);
    /* ⚠ the whole horizontal inset lives HERE, and the cells' own 1.5rem is
       removed below. If the cells keep it, the 1.779 ratio applies to the
       COLUMN and not to the slider inside it: 24px of padding a side turns a
       1.779 column ratio into a 2.03 content ratio, and the equal heights the
       ratio exists to produce come out 386 against 338. */
    padding-inline: clamp(24px, 4vw, 96px);
    position: relative;
    background: #68472E;
    color: var(--cream);
    /* §107: the same class-B recipe the steps → pair 1 seam already used.
       max(token, floor) — the token is NOT edited. */
    padding-top: calc(max(var(--fade-depth), 72px) + var(--headroom));
    padding-bottom: var(--headroom);
  }
  /* the seam band moves to the wrapper: on the grid each section is only half
     the width, so pair 1's own ::before would have painted the seam across the
     left-hand column and stopped dead in the middle of the page. */
  .ba-pair-wrap::before {
    content: '';
    position: absolute; left: 0; right: 0; top: 0;
    height: max(var(--fade-depth), 72px);
    background: linear-gradient(180deg, var(--canvas), #68472E);
    pointer-events: none;
  }
  /* ---- the two sections become cells ----
     No ground, no band, no vertical padding: the wrapper has all three now.
     The horizontal 1.5rem gutter stays, so the sliders keep their inset. */
  .ba-pair-wrap > .ba-sec {
    background: none;
    padding: 0;   /* vertical AND horizontal: the wrapper carries both now */
  }
  .ba-pair-wrap > .ba-sec::before { display: none; }

  /* ⚠ STAGE 64, ITEM C. "the images do not align with each other... the bottom
     of each image should align on the same line and the top of each image
     should align on the same line."

     MEASURED FIRST. The two sliders are already the same HEIGHT here -- that is
     what the 1.779fr ratio above exists for, and at 1280 they came out 534.7
     against 534.7. The misalignment is 33.0px of vertical OFFSET, constant at
     1024, 1100, 1200, 1280 and 1366 and zero at 1440 and 1600. 33px is exactly
     one line of .ba-label: in the narrow right-hand column "From groundwork to
     finished garden" wraps to two lines while "From carcass to finished wall"
     stays on one, so the garden's slider starts a line lower. At 1440 the
     column is wide enough for one line and the offset disappears, which is why
     it looked fine on a large screen.

     So the two labels are put in a SHARED GRID ROW and the two sliders in
     another. The row sizes to the taller label, both sliders start on the same
     line, and because their heights already match their bottoms land together
     too. ⚠ subgrid rather than `display: contents`, deliberately: contents
     would remove the box of a <section aria-label="Before and after: ..."> and
     the landmark with it. Nothing here changes below 1024, where the two
     sections are stacked and alignment is not a question. */
  .ba-pair-wrap { grid-template-rows: auto auto; }
  .ba-pair-wrap > .ba-sec,
  .ba-pair-wrap > .ba-sec > .inner { display: grid; grid-row: span 2; grid-template-rows: subgrid; }
  /* ⚠ `.section > .inner` carries `margin: 0 auto`, and an auto inline margin
     on a GRID ITEM absorbs the free space instead of stretching it -- the
     moment .inner became a grid item it shrank to fit-content and the slider
     sat 9.6px outside it. Measured, not predicted. Zeroing the margin and the
     max-width restores the tip's geometry exactly (cell 802, inner 802, ba 802
     at 1440, left edge 57.6 on both trees). */
  .ba-pair-wrap > .ba-sec > .inner { margin-inline: 0; max-width: none; }

  /* ⚠ THE GUARANTEE NOW FOLLOWS THE BROWN, NOT THE GREEN. --prev-bg builds
     that section's seam gradient, so leaving it at #1D220E would have faded
     the boundary up into a colour that is no longer above it — a visible band
     where there is currently a clean transition. Desktop only: on the phone
     the green ground is still there, so the phone's value must not change. */
  .guarantee { --prev-bg: #68472E; }
}

/* ============ Stage 28: the rim reaches the chips ==========================
   Andrea, from the live flow: "On 'When could we come and see it?' those
   buttons are a little too small, and they do not have the same effect as the
   other buttons when you press them — the highlight, the rim… Same for the
   very last page, 'Almost there' — how would you like to get in touch."

   This is the same defect stage 14 was built to fix, on the two steps where it
   matters most. The rim itself is NOT rebuilt: `.eq-chip.sel` was added to
   14b's own rule above, so these chips lap on the same --p3-sel-ang, from the
   same single animation on the step container, with the same two coaxial laps.

   Placed at the end of the sheet on purpose: `.eq-chip.sel`'s shipped rule
   sits further up and would otherwise win the cascade on border-color. */

/* the pseudo-element is absolutely positioned, so the chip has to be its
   containing block — .eq-btn already was, .eq-chip never needed to be */
.eq-chip { position: relative; }

/* "a little too small". 10px/18px at 0.85rem rendered a 35.6px-tall control —
   BELOW the 44px minimum target size, which is a floor and not a target. This
   is the smallest step that clears it: 0.9rem on a 1 line-height is 14.4px of
   text, plus 2 x 14px padding, plus the 1px border top and bottom = 44.4px. */
.eq-chip {
  padding: 14px 22px;
  font-size: 0.9rem;
}

/* the chip's own border goes dark under the rim, exactly as .eq-btn.sel's
   does, so the rim reads as the edge rather than sitting outside a lit line.
   The lit face stays: --eq-face-hi is the selected background 14b measured
   the inward lap's 3.3:1 against. */
.eq-chip.sel, .eq-chip.sel:hover { border-color: #2B1F1E; }

/* ⚠ THE STANDARD FORM NEEDS ITS OWN CONTAINER-LEVEL ANGLE. Its contact chips
   are the same component, but #eqStandard is not inside .eq-step, so it never
   inherits the orbit and its rim would sit frozen at 0deg. One animation on
   the container, matching .eq-step.on — and the container is `hidden` unless
   the visitor chooses that route, so it costs nothing the rest of the time.
   ⚠ --p3-ang is NOT touched: this is --p3-sel-ang, 14's separate registered
   angle, and nothing .cta uses is involved. */
.eq-standard { animation: p3-sel-orbit 9s linear infinite; }

/* item 3: "Concept visualisations, illustrative only" — centred. It sits under
   the tile grid on two steps, both of which centre everything else. */
.eq-disclose { text-align: center; }

/* ============ Stage 30A: the interaction model ============================
   Andrea, from watching real people: "Some people just don't want to read the
   small writings. They will only read what the question is and what the
   options are. They will never read the small title."

   So every affordance here is a control or an option, never a sentence: the
   toggle stands IN the helper line's slot rather than above it, and skipping
   is an answer rather than a link. */

/* ---- "Not sure yet": subordinate by SIZE, WEIGHT and an outline face ----
   ⚠ NEVER by contrast. Dimming the label would push it under 4.5:1, and the
   people who need to read it are exactly the people it exists for. At rest it
   is an outline on the cream flow ground with FULL ink, one type step down
   from the option bars and lighter in weight.
   ⚠ It must not read as DISABLED. A disabled control loses its ink and its
   hover; this keeps the full-strength label, the pointer cursor, the copper
   hover border and the press transform. */
.eq-unsure-wrap { margin-top: 24px; }        /* larger than the 12px option gap:
                                                reads as "or, if none of these" */
.eq-unsure {
  display: block;
  width: 60%;                                 /* the brief's 55-65% of the bars */
  min-width: 180px;
  max-width: 340px;
  margin-inline: auto;
  min-height: 44px;                           /* ⚠ smaller is a visual-weight
                                                 decision, never a hit-area one */
  background: none;
  border: 1px solid rgba(18, 16, 14, 0.34);
  color: var(--ink);
  border-radius: var(--radius-pill);
  padding: 12px 20px;
  font: 400 0.88rem/1.2 'Schibsted Grotesk', sans-serif;   /* one step down, not two */
  text-align: center;
  box-shadow: none;
}
.eq-unsure:hover { border-color: var(--eq-focus, #8A4F1F); transform: translateY(-1px); }
.eq-unsure:active { transform: scale(0.98); }
/* selected: it fills, which is what makes 14b's inward lap valid -- that lap is
   measured against a DARK face and would have nothing to sit on over cream */
.eq-unsure.sel, .eq-unsure.sel:hover {
  background: var(--eq-face-hi, var(--surface-hi));
  color: var(--cream);
  border-color: #2B1F1E;
}
.eq-unsure.sel { --p3-rim-radius: var(--radius-pill); }

/* ---- "Choose more than one" ----
   A real checkbox with role="switch"; the <label> wraps the control and the
   words so the words are part of the tap target. Sits in the helper line's
   slot, so the net height change is about zero. */
/* ⚠ the margins are the FIRST place the fold is bought back, never the type
   and never the 44x44 box. The helper line this replaces carried
   `margin: -1rem 0 1.4rem`; the toggle takes the same -1rem pull and a tighter
   foot, which returns 19.2px of the 36.6px the taller control costs. */
.eq-multi { margin: -1rem 0 0.5rem; display: flex; justify-content: center; }
.eq-switch {
  position: relative;
  display: inline-flex; align-items: center; gap: 12px;
  min-height: 44px; padding: 6px 10px;        /* 44px tall including the words */
  cursor: pointer;
  border-radius: var(--radius-pill);
}
/* focusable, never display:none -- the input sits over its own track */
.eq-switch-input {
  position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
  width: 46px; height: 28px; margin: 0; opacity: 0; cursor: pointer;
}
.eq-switch-track {
  position: relative; flex: 0 0 auto;
  width: 46px; height: 28px; border-radius: 999px;
  background: none;
  border: 1.5px solid var(--eq-muted, #53453E);   /* 3:1+ non-text on the cream ground */
  transition: background 0.25s var(--ease), border-color 0.25s var(--ease);
}
.eq-switch-knob {
  position: absolute; top: 50%; left: 3px; transform: translateY(-50%);
  width: 19px; height: 19px; border-radius: 50%;
  background: var(--eq-muted, #53453E);
  transition: transform 0.25s var(--ease), background 0.25s var(--ease);
}
/* ⚠ COPPER, NOT GREEN -- green means something else on this site and the
   resting UI's only accent is the warm one. ⚠ NOT var(--copper): #C1793A on
   this cream flow ground measures 2.91:1, under the 3:1 non-text floor. The
   darker copper already laddered for this ground is used instead. */
.eq-switch-input:checked + .eq-switch-track {
  background: var(--eq-focus, #8A4F1F);
  border-color: var(--eq-focus, #8A4F1F);
}
.eq-switch-input:checked + .eq-switch-track .eq-switch-knob {
  background: var(--cream);
  transform: translateY(-50%) translateX(18px);
}
/* the state is carried by the knob's POSITION as well as its colour, so it is
   not colour-alone */
.eq-switch-label {
  font: 500 0.88rem/1.2 'Schibsted Grotesk', sans-serif;
  color: var(--ink);                            /* full strength, not muted */
}
.eq-switch-input:focus-visible + .eq-switch-track {
  outline: 2px solid var(--eq-focus, var(--copper-lit)); outline-offset: 3px;
}

/* ---- Stage 30A, the second source for the fold at 320 ----
   The toggle's own margins gave back 19.2px of the 36.6px it costs; the rest
   comes from the step's own spacing, never from type and never from the 44x44
   box. `.eq-nav`'s 2rem top padding is dead space above the Back row, and on a
   phone it is the cheapest 12px on the step. Desktop keeps its 2rem.
   ⚠ Steps whose foot is set by the flow container rather than by their content
   -- everything already sitting at +56.0px of spare -- are unmoved by this:
   their nav is pushed down by `margin-top: auto`, so shortening the content
   above it changes nothing. Verified by measurement, not assumed. */
@media (max-width: 1023px) {
  .eq-nav { padding-top: 1.25rem; }
}

/* ---- Stage 30A: the same 11f mechanism, now on DESKTOP for the feel step ----
   11f's caps are scoped `max-width: 1023px`, so desktop had no furniture
   allowance at all -- and 1280x800 is the wide-but-short case that this stage's
   added furniture pushed over: +43.9px of spare before, -4.0px after.

   DERIVED, not guessed. The four tiles sit in ONE row, so a cut of X px on the
   image is X px of fold. Measured need: 9px at 768 tall, 28px at 800 tall, none
   at 820 and above. Two points fix `calc(A*svh - B)` exactly -- A = 1, B = 548.5
   -- and B is carried to 552px so no cell lands on the 24px line itself:
     768 -> 216px cap, cut 12.5, spare 27.5
     800 -> 248px cap, cut 31.5, spare 27.5
     820 -> 268px cap, 267.5 natural, does not bind
     900 and above     -> does not bind at all
   §92 again: the photographs and the gaps give way, THE TYPE NEVER SHRINKS --
   the label stays 13.6px at every width, measured.
   ⚠ The width stays definite (`.t-img` is width:100%), so 11f-b's transfer of a
   height cap into a max-width through aspect-ratio cannot happen here. */
@media (min-width: 1024px) {
  .eq-feel .eq-tile .t-img { max-height: calc(100svh - 552px); }
}

/* ---- Stage 30B: the `what` step's phone spacing ----
   "Something else" makes this the longest option list in the flow: five subs at
   the widest spaces plus the new option, six 54px rows. At 320 that took the
   step from container-bound (+56.0px of spare) to +3.6px -- positive, but one
   font-rendering difference from failing, which 30A ruled is not good enough.
   21.4px comes back from spacing alone, in the order 30A set: 15px from the gap
   between options (12px -> 9px, five gaps) and 6.4px from the helper line's
   foot (1.4rem -> 1rem). ⚠ Never the type, and never the 54px buttons -- the
   options keep their full hit area and only the air between them narrows.
   Phones only; desktop keeps 12px and 1.4rem. */
@media (max-width: 1023px) {
  .eq-what .eq-opts { gap: 9px; }
  .eq-what .eq-note { margin-bottom: 1rem; }
}

/* ============ Stage 32B: the visit step's week grid =======================
   Andrea, from a phone screenshot: "I don't like the way these buttons are laid
   out... All in one line, stretching depending on the device. Monday to Sunday,
   shortened — Mon, Tue. And then AM, PM below. Like a table, but a nice table,
   within our style." And, ruling mid-build: "Should be smaller in mobile and
   all in one line. No empty boxes, am pm and eve on each box below the days of
   the week."

   ⚠ SEVEN COLUMNS AT EVERY WIDTH. The briefed fallback was to fold the week
   into Mon–Thu and Fri–Sun below 396px; Andrea rejected that in favour of one
   line with smaller cells, so the cells shrink and the week never breaks. There
   is no row-label column and there are no spacer cells: every box carries its
   own AM / PM / EVE.

   ⚠ IT READS AS A TABLE, NOT AS TWENTY-ONE LOOSE BUTTONS. The hairlines are
   SHARED: the grid's own ground shows through a 1px gap between opaque cells,
   so every rule is drawn once and none can double.

   ⚠ The 3px frame is not decoration. A selected cell carries .p3-rim, whose
   outward lap reaches 3px beyond the cell, so the frame is exactly that much
   and the rim is never clipped on a perimeter cell. */
.eq-week { margin-top: 14px; }
.eq-week-block {
  border: 1px solid rgba(242, 236, 227, 0.22);
  border-radius: 12px;
  background: var(--eq-face, var(--surface));
  padding: 3px;                       /* the rim's outward lap lives here */
}
.ew-grid {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 1px;                           /* the shared hairline, drawn once */
  background: rgba(242, 236, 227, 0.16);
  border-radius: 9px;
}
.ew-grid > * {
  background: var(--eq-face, var(--surface));
  min-height: 44px;
  display: flex; align-items: center; justify-content: center;
}
/* ⚠ THE BLOCK IS ROUNDED BY ITS FOUR CORNER CELLS, NOT BY `overflow: hidden`.
   An overflow clip on .ew-grid would cut a selected perimeter cell's outward
   lap -- the lap reaches 3px beyond the cell and 16 of the 21 cells sit on the
   perimeter -- so the corner cells carry the radius themselves. The gap applies
   only BETWEEN cells, so each corner cell's outer corner is exactly the grid's
   own; at the same 9px both arcs coincide and no hairline shows through. */
.ew-grid > :nth-child(1)  { border-radius: 9px 0 0 0; }
.ew-grid > :nth-child(7)  { border-radius: 0 9px 0 0; }
.ew-grid > :nth-child(15) { border-radius: 0 0 0 9px; }
.ew-grid > :nth-child(21) { border-radius: 0 0 9px 0; }
/* ⚠ THE DAY ROW SITS OUTSIDE THE TABLE (Andrea): "the entire row blends more
   with the background perhaps same colour no table around". Its own strip on
   the page's own ground -- no box, no hairlines, no cell backgrounds -- sharing
   the table's `repeat(7, 1fr)` so the columns line up exactly. Because it now
   sits on the CREAM flow ground its ink is --ink, not cream; quieter by weight
   and size, never by contrast. */
.ew-head {
  display: grid;
  grid-template-columns: repeat(7, 1fr);
  gap: 1px;                            /* the same 1px rhythm, so columns align */
  padding: 0 3px;                      /* matches the table's frame */
  margin-bottom: 6px;
}
.ew-day {
  font: 600 clamp(0.6rem, 2.4vw, 0.72rem)/1 'Schibsted Grotesk', sans-serif;
  letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--ink);
  display: flex; align-items: center; justify-content: center;
  min-height: 22px;                    /* a label, not a target */
}
.ew-cell {
  position: relative;                 /* the rim's containing block */
  border: 0;
  color: var(--cream);
  cursor: pointer;
  padding: 0;
  font: 500 clamp(0.56rem, 2.2vw, 0.68rem)/1 'Schibsted Grotesk', sans-serif;
  letter-spacing: 0.06em;
  transition: background 0.25s var(--ease);
}
.ew-cell:hover { background: rgba(242, 236, 227, 0.06); }
.ew-cell:focus-visible { outline: 2px solid var(--eq-focus, var(--copper-lit)); outline-offset: -3px; }
/* ⚠ .p3-rim REUSED, not re-derived: `.ew-cell.sel::before` is added to stage
   28's own rule, so 14b's two coaxial laps, their colours and the shared angle
   are the ones that paint. Only the corner is named here. A selected cell lifts
   above its neighbours so its outward lap draws over the shared hairline. */
.ew-cell.sel {
  background: var(--eq-face-hi, var(--surface-hi));
  z-index: 2;
  --p3-rim-radius: 8px;
}

/* ============ Stage 32C: the line that says the answers were kept ==========
   Andrea: "be told if possible I've saved the progress." One short, calm line
   above the question on the step a reload put them back on. It is not modal,
   it cannot be dismissed and nothing is gated on it: the flow behaves
   identically for a visitor who never notices it.

   ⚠ Ink: --eq-muted (#53453E) on the flow's own #F4EADA ground, the ladder the
   .eq-note already uses -- quieter by size and weight, never by contrast.
   ⚠ min-height holds the line's space before its text is set. The text arrives
   one frame late ON PURPOSE, because a live region has to be in the document
   before its content changes for a screen reader to announce it; reserving the
   height here is what stops that costing a layout shift. */
.eq-resumed {
  font-size: 0.78rem;
  line-height: 1.35;
  min-height: 1.35em;
  color: var(--eq-muted, var(--cream-muted));
  text-align: center;
  /* ⚠ the gap is 0.25rem, not the 0.55rem this was first built with: the line
     costs the step its own height plus this margin, and on a restored step at
     320 those four pixels are the difference between the nav row sitting on the
     fold and under it. The SIZE is not cut to buy more -- #53453E was laddered
     at this size, and a smaller face renders lighter. */
  margin: 0 0 0.25rem;
}

/* ============ STAGE 36: the sweep's certain fixes ==========================

   ---- M1: the standard form is reachable without JavaScript ---------------
   The bridge's "Use the standard form" link pointed at #standard, and no
   element with that id has ever existed on the page. With scripting alive
   enquire.js preventDefault()s the click and shows the section, so the fault
   was invisible; with scripting off the link did nothing at all -- and the
   standard form is precisely the escape hatch 30B built for someone who does
   not want the guided flow.

   The link now points at the real section, #eqStandard, and the rule below
   reveals it when the fragment selects it. It is scoped to html:NOT(.js-on),
   the class the head script adds synchronously before first paint, so:
     - JavaScript present  -> the class is there, this rule never matches, and
       30B's "every route goes through the bridge" holds even for a bookmark
       or a shared link carrying #eqStandard;
     - JavaScript absent   -> the class is never added, the anchor reveals the
       form and the browser scrolls to it.
   `hidden` is a UA-stylesheet display:none, so an author rule beats it at any
   specificity. The section's own display is block (it is a <section>).

   ⚠ WHAT THIS DOES NOT DO, stated plainly: the form still cannot be SENT
   without JavaScript. /api/enquiry reads a `payload` field carrying JSON and
   answers in JSON, so a native POST would need a server-rendered thank-you
   page and a second parse path -- a decision for Andrea, not a sweep fix.
   The form carries method="post" so that a native submit can never put a
   name, postcode, email address and telephone number into a URL and a log. */
html:not(.js-on) #eqStandard:target { display: block; }

/* ---- H2: a focus ring on the 62 controls that had only the browser's ------
   Stage 35 measured, by KEYBOARD Tab, 62 controls across the seven pages with
   no author focus indicator at all -- they fell back to Chromium's near-black
   `outline: auto`, which is not the site's and is not guaranteed on the dark
   footer. None of them had NO indicator; all of them had the wrong one.

   This is a FLOOR, not a new treatment. It reuses the nav bar's ring from
   stage 33 -- 2px solid var(--cream) -- and it is written with :where(), which
   carries ZERO specificity, so every one of the forty-odd bespoke rings
   already tuned in this file still wins over it. It only paints where nothing
   else does.

   ⚠ :focus-visible, never :focus: a mouse user sees nothing they did not ask
   for, a keyboard user always sees a ring. Nothing here suppresses an outline.

   Cream is right on every dark ground (measured 10.21:1 to 16.41:1 against the
   rendered pixels behind each control). Three contexts are pale and take the
   ink instead -- .people on the home page (the three portrait cards and "Meet
   the studio", ground rgb(239,232,220)), .svc-inside on /what-we-do/ (the pale
   service cards, which already carry --card-fg: var(--ink)) and /about/'s
   quiet link (ground rgb(213,204,192)). */
:where(a[href], button, summary, [tabindex="0"]):focus-visible {
  outline: 2px solid var(--focus-ring, var(--cream));
  outline-offset: 3px;
}
.people, .svc-inside, .ab-quiet-link { --focus-ring: var(--ink); }

/* ---- M3: the footer's social icons reach the 24x24 minimum ---------------
   The links were 22x22 -- an inline-flex box the exact size of its 22px glyph.
   The BORDER BOX, which is what a finger hits, grows to 24x24. The glyph does
   not move and does not change size: the 2px the bigger target would otherwise
   add in each axis is given straight back as a -1px margin all round, so the
   link still claims 22x22 of the flex row and the footer's height, the 26px
   gap and every baseline in it stay exactly where they were. The two boxes
   still clear each other by 24px, so nothing overlaps.
   ⚠ SCOPED TO BELOW 1024. At >=1024 the links are already 48x48 -- they pass
   the minimum on their own -- and the desktop block sets width/height without
   resetting margin, so an unscoped -1px shrank that row from 122x48 to 118x46
   and moved it. Measured, then scoped. */
@media (max-width: 1023px) {
  .foot-social a {
    align-items: center; justify-content: center;
    min-width: 24px; min-height: 24px;
    margin: -1px;
  }
}

/* ============ STAGE 38b: the right column gives way, in order =============
   38 measured the defect: the column's content is taller than the room above
   the phone number, `justify-content: safe center` falls back to flex-start,
   and "See the work" spills into the foot -- 119px into it at 1920x768, its
   box finishing BELOW the digits. Andrea's rule is absolute: "No text should
   ever sit below the rim that shows when we hover on top of the phone number."

   ---- the reserved zone, expressed once -------------------------------------
   The phone number's rim is .hero-tel's PADDING box (11j: `padding: 12px 20px`
   with equal negative margins, so the rim sits 12px above the digits and the
   digits' own rects are untouched). Its focus ring adds 8px more
   (`outline: 2px` at `outline-offset: 6px`). So the zone the column may never
   enter starts, from the foot of the viewport:

       foot padding + the number's own line + 12px of rim + 8px of ring
     = clamp(2rem,4vw,4.5rem) + clamp(1.6rem,4vw - 14px,3.9rem) + 20px

   --hs2-room is the distance from the column's top to that line.

   ---- what the column needs, without measuring it ---------------------------
   Every term is already a CSS expression; the title's height is 3.145 x its
   own --hs2-size (three lines at line-height 1.12, in the 1/1.1449 : 1/1.07 : 1
   ratio -- 1.12 x 2.808 = 3.145), which is why --hs2-size is hoisted above.
   The paragraph is modelled at THREE lines. ⚠ It is four at 1024 and at two of
   the 1050 cells, so the model under-reads those by one line -- measured, and
   harmless: every four-line cell has 190px or more of slack and never engages
   this arithmetic at all. Worst |model - measured| over the thirty live cells
   is 35.8px, and it is that one paragraph line, always on the safe side.

   ---- and it is spent in Andrea's order, cheapest first ---------------------
   --hs2-over is ZERO wherever the column already fits, so every cell that
   clears today is byte-identical: the 26px inset, the three gaps and the
   photographs are all untouched there. Where it is positive:
     1. the column's own 26px top inset goes first (it costs nothing to give);
     2. the three gaps fall to their floors (16/18/18) -- together at least
        30px, measured at every engaging cell;
     3. the photographs take the rest.
   ⚠ Type, copy, the film and the paragraph never give way.

   ⚠ ON "SCALE, NOT CROP": these two images are ALREADY a crop by design --
   `object-fit: cover` with this stylesheet's own note, "cropped: never a whole
   photograph" (10h/11g). The "occlusion, never a crop" rule belongs to stage
   8's vertical card stack, a different element. So what gives way here is the
   HEIGHT OF THE CROP WINDOW: the photograph is never distorted, never scaled
   on one axis, and both cards stay identical to each other and keep their
   flex relationship to the column. The floor is derived, not chosen -- one
   tenth of --panel-w, which holds the window at roughly 4:1 and stops it
   becoming a letterbox slot. */
/* ---- STAGE 38c: the unused room under the number is spent first ----------
   38b left two cells failing and named the source that would close them: the
   room BELOW the phone number's focus ring, which nothing occupies. Measured:
   the ring's bottom is `foot padding - 20px` clear of the screen's edge --
   52px at 1920, 20.95px at 1024 -- because the rim overhangs the number's
   layout box by 12px and the ring by 8px more (11j's negative margins).

   ⚠ How far it may fall is DERIVED, not chosen: the foot's padding is
   `clamp(2rem, 4vw, 4.5rem)`, so its own declared floor is 2rem. The number
   never sits closer to the edge than that floor allows -- 40px of give at
   1920, 35.2 at 1680, 25.6 at 1440, 19.2 at 1280, 15.2 at 1180, 8.96 at 1024.

   ⚠ And it is keyed on the constraint, exactly as --hs2-over is: --foot-give
   is `min(the slack, what the column is short by)`, so it is ZERO wherever the
   column already fits and the number does not move a pixel there -- 1045x731,
   Andrea's own machine, included.

   The chain is a forward one, with no circularity: room0 is measured with the
   number where it is today, the give is derived from that, and only then does
   --hs2-room grow by it. The column therefore gives LESS than it did at 38b,
   so the photographs come back at the widest cells. */
@media (min-width: 1024px) and (min-aspect-ratio: 1/1) {
  :root {
    --foot-pad0: clamp(2rem, 4vw, 4.5rem);
    --hs2-room0: calc(100svh - var(--bar-h) - 26px
      - (var(--foot-pad0) + clamp(1.6rem, 4vw - 14px, 3.9rem) + 20px));
    --hs2-need: calc(3.145 * var(--hs2-size)
      + clamp(16px, calc(1.6svh + var(--panel-w, 26vw) * 0.025), 26px)
      + 4.65 * clamp(0.92rem, 1vw, 1.05rem)
      + clamp(18px, calc(1.9svh + var(--panel-w, 26vw) * 0.03), 30px)
      + clamp(120px, 22svh, 200px) + 20px
      + clamp(18px, calc(1.9svh + var(--panel-w, 26vw) * 0.03), 30px)
      + 43px
      /* ⚠ 8px of margin, not decoration. The first build of this landed cells
         at +0.29 and +4.06 -- above zero but inside the +/-1px instrument noise
         this site carries, which stage 31 already ruled against ("26px rather
         than 24px so no cell sits ON the line"). The column aims 8px clear of
         the reserved zone so that passing is legible. */
      + 8px);
    /* what the column is short by with the number where it stands today */
    --hs2-over0: max(0px, calc(var(--hs2-need) - var(--hs2-room0)));
    /* The unused room under the ring, and how much of it the column asks for.
       ⚠ The 56px is the same constant the photograph rule uses: the 26px top
       inset plus the 30px the three gaps are measured to give. Deducting it
       here puts the foot's give exactly where it belongs in the order --
       AFTER the two free levers, BEFORE the photographs. A window whose
       column can be settled by the inset and the gaps alone therefore never
       moves the number at all, and a window that would otherwise start
       cutting into a photograph spends this free room instead. */
    --foot-slack: max(0px, calc(var(--foot-pad0) - 2rem));
    --foot-give: min(var(--foot-slack), max(0px, calc(var(--hs2-over0) - 56px)));
    --foot-pad: calc(var(--foot-pad0) - var(--foot-give));
    /* the room the column actually has, once the number has moved */
    --hs2-room: calc(var(--hs2-room0) + var(--foot-give) - var(--tel-lift, 0px));   /* 82c: less the number's lift, which is never more than the room to spare */
    --hs2-over: max(0px, calc(var(--hs2-need) - var(--hs2-room)));
  }
  /* 1 · the top inset gives first */
  .panel-r .p-open, .panel-r .p-open.on {
    top: calc(26px - min(26px, var(--hs2-over, 0px)));
  }
  /* 2 · then the three gaps, each to its own floor */
  .panel-r .p-open .hs2-para {
    margin-top: max(16px,
      calc(clamp(16px, calc(1.6svh + var(--panel-w, 26vw) * 0.025), 26px) - var(--hs2-over, 0px)));
  }
  .panel-r .p-open .hs2-imgs,
  .panel-r .p-open .work-btn.sw-more {
    margin-top: max(18px,
      calc(clamp(18px, calc(1.9svh + var(--panel-w, 26vw) * 0.03), 30px) - var(--hs2-over, 0px)));
  }
  /* 3 · and the photographs take whatever is still owed -- the 26px the inset
     gave and the 30px the gaps are measured to give are deducted first. */
  .panel-r .p-open .hs2-frame img {
    height: max(calc(var(--panel-w, 26vw) * 0.1),
      calc(clamp(120px, 22svh, 200px) - max(0px, calc(var(--hs2-over, 0px) - 56px))));
  }
}

/* ============ STAGE 40: the left column adapts, the way 38b's does ========
   Andrea: "so that it adapts and always stays pretty much central, the same
   way the right section does now". Two symptoms, one cause -- but the cause is
   not quite what the brief said, and the audit is worth recording:

   ⚠ the left column's SPACING is already height-derived. The lockup carries a
   32svh cap (10h item 5), the h1's margin is clamp(1.4rem, 3.2vh, 2.4rem) and
   the CTA's is clamp(1.8rem, 4vh, 2.8rem). What is NOT height-derived is
   .hero-left's own padding -- clamp(2rem, 4vw, 4.5rem), width only -- and the
   group's TOP ANCHORING, `margin: 0 0 auto`, which stage 6c set deliberately
   from Andrea's screenshots ("the group OWNS THE TOP of the column"). This
   stage reverses 6c on his newer instruction. Recorded so it is not read as
   drift.

   ⚠ And a defect neither of us was looking for: .hero-skip is a flex item in a
   column that overflows at eleven cells, so it was being SHRUNK -- measured
   22.0px tall at 1440x700 and 1280x700 against its declared 44px, and 34.2 at
   1440x768. `flex: none` below stops that, and it is why --hl-need reserves a
   full 44px for it.

   ---- the model ------------------------------------------------------------
   Every term is a CSS expression, and it is exact: worst |model - rendered|
   over the thirty live cells is 0.08px.
       lockup   min(--col-w x 445/600, 460px x 445/600, 32svh)
       tag      14px margin + 13px line
       h1       clamp(1.4rem,3.2vh,2.4rem) margin + 2.44 x its own size
                (two lines at 1.12 plus the 0.2em between them)
       CTA      clamp(1.8rem,4vh,2.8rem) margin + its 58px min-height
       skip     24px, 10h's guaranteed breath, + 44px
   ⚠ It is declared on .hero-left, not :root: --col-w derives from --film-left,
   which hero.js publishes on #heroPin, so :root cannot see it.

   ---- and it is spent, then centred -----------------------------------------
   Where the column is short, in order, cheapest first:
     1. the padding gives, top and bottom together, never below the 2rem floor
        its own clamp declares;
     2. the lockup gives the rest -- the mark is the left column's photograph,
        floored at 20svh, the same family as the 32svh cap above it.
   Where the column fits, the group takes a derived top margin that puts its
   centre on the band's centre: m = (slack + 68) / 2, capped at the slack so
   the skip is never pushed out of the column.
   ⚠ Where the slack is under 68px the cap binds and the group lands up to 34px
   -- half the skip's block -- above centre. That residual is the geometric
   cost of keeping the skip pinned at the foot, and it is measured per cell.

   §62's button, 31's h1 cap and its 6.995 divisor, --bar-h, --hero-gutter and
   every type size in the column are untouched. */
@media (min-width: 1024px) and (min-aspect-ratio: 1/1) and (prefers-reduced-motion: no-preference) {
  .hero-left {
    --hl-pad0: clamp(2rem, 4vw, 4.5rem);
    --hl-lock0: min(calc(var(--col-w) * 0.7416667), 341.167px, 32svh);
    --hl-h1: min(clamp(1.9rem, 5vw - 16px, 4.9rem), max(1.9rem, calc(var(--col-w) / 6.995)));
    /* ⚠ STAGE 68: the area line is a REAL term in the fit, not an addition on
       top of it. Its size is a fraction of the h1's own derived size, floored
       so it can never fall below legibility, and both its margin and its line
       box are counted in --hl-need below. Without that the column would
       overflow on a short laptop, because --hl-need is what decides whether
       the padding and then the lockup have to give. */
    --hl-area-m: clamp(0.9rem, 2vh, 1.4rem);
    --hl-area: max(0.95rem, calc(var(--hl-h1) * 0.32));
    --hl-need: calc(var(--hl-lock0) + 14px + 13px
      + clamp(1.4rem, 3.2vh, 2.4rem) + 2.44 * var(--hl-h1)
      + clamp(1.8rem, 4vh, 2.8rem) + 58px
      + var(--hl-area-m) + 1.2 * var(--hl-area)
      + 24px + 44px);
    --hl-room0: calc(100svh - var(--bar-h) - 2 * var(--hl-pad0));
    --hl-over0: max(0px, calc(var(--hl-need) - var(--hl-room0)));
    /* 1 · the padding gives first, half the deficit at each edge */
    --hl-padgive: min(max(0px, calc(var(--hl-pad0) - 2rem)), calc(var(--hl-over0) / 2));
    --hl-pad: calc(var(--hl-pad0) - var(--hl-padgive));
    --hl-room: calc(100svh - var(--bar-h) - 2 * var(--hl-pad));
    /* 2 · the lockup takes what is still owed */
    --hl-over: max(0px, calc(var(--hl-need) - var(--hl-room)));
    /* and where there is room to spare, the group centres in it */
    --hl-slack: max(0px, calc(var(--hl-room) - var(--hl-need)));
    /* ⚠ STAGE 68 REVERSES THE CENTRING, on Andrea's newer ruling -- which is
       itself a reversal of the ruling that reversed stage 6c. The group goes
       back to the top of the column and the slack falls BELOW it, which is
       both what he asked for and what makes room for the area line: at four of
       the six gate cells the gap between Enquire and the skip was 25px, i.e.
       the skip's own 24px minimum breath and nothing more, so a new line could
       not have been added without this.
       ⚠ NOT a fixed nudge. Zero lift means the space above is the column's own
       padding-block -- clamp(2rem, 4vw, 4.5rem), viewport-relative -- and the
       space below is --hl-slack, which is derived from 100svh. Both scale.
       --hl-slack is kept: it is the measurement the centring was built on and
       the thing to restore if this ruling reverses again. */
    --hl-lift: 0px;
    padding-block: var(--hl-pad);
  }
  /* ⚠ reverses stage 6c's top anchoring, on Andrea's newer ruling. The auto
     bottom margin STAYS: it is what still sends the skip to the column's foot. */
  .hero-left .hl-group { margin: var(--hl-lift, 0px) 0 auto; }
  /* ⚠ the floor is min(its own natural size, 20svh) -- NOT a bare 20svh. A
     bare floor RAISED the mark at 1180x1050 and 1280x1050, where 20svh (210px)
     is larger than the size --col-w gives it (164.5 and 197.0). A floor must
     never enlarge what it is flooring. Measured, then fixed. */
  .hero-left .hl-group {
    --hl-lock: max(min(var(--hl-lock0), 20svh), calc(var(--hl-lock0) - var(--hl-over)));
    --hl-lockup-w: calc(var(--hl-lock) * 600 / 445);
  }
  /* ⚠ the skip is a control, not a spacer: it must never be shrunk to make the
     column fit. Measured at 22.0px before this line. */
  .hero-left .hero-skip { flex: none; }
}

/* ============ STAGE 41: three findings from a real Android handset =========

   ---- 1 · the bar reads much thicker on Android's first paint --------------
   ⚠ THE MECHANISM, named. It is none of the three the brief proposed. The bar
   itself is not what grows: its gradient is in PIXEL stops that finish
   transparent at 64px, so a taller .bar box paints nothing extra (the comment
   on .bar says as much). What grows is the BLACK BAND ABOVE THE FILM, which is
   the pin's own --canvas ground showing through, and it reads as a thicker bar
   because the scrim sits on it.

   hero.js sizes that band ONCE, at layout:
       const visH    = Math.min(svhProbe.getBoundingClientRect().height, pinH);
       const filmTop = Math.max(0, visH - dispH);          // mobile
       filmBox.style.top = filmTop + 'px';
       --bar-zone = filmTop
   and the re-layout guard deliberately blocks the correction:
       if (w === lastW && Math.abs(h - lastPinH) < 2) return;
   ⚠ That guard keys on the width and the pin's LARGE-viewport height. A change
   in the SMALL viewport -- which is exactly what settles late on Android --
   never re-runs layout(). It is right to block it (an svh change mid-film was
   the frame jump), which is why the fix is not to loosen it.

   So the band is a px value cached from one svh read. Where that read is taken
   before the handset's toolbar has settled it is too LARGE, and the band is too
   deep by the difference -- ~64px at 390 wide -- until something else re-runs
   layout.

   ⚠ THE FIX IS TO STOP CACHING IT -- and to do it WITHOUT recomputing the
   film's height in CSS. The first build of this stage expressed the band as
   `100svh - round(100vw * 1472 / 828, 1px)`, reproducing hero.js's own
   Math.round(SEQ.h * vw / SEQ.w). It was wrong, and measurement caught it:
   ⚠ 100vw is the LAYOUT viewport and window.innerWidth is the VISUAL one, and
   they are not the same under mobile emulation -- at a 320 viewport Chromium
   laid out at 351, so the CSS said 275px where the JS said 220px, a 55px
   disagreement at the one width that most needed to match.

   So the film's height is not recomputed at all. It is ANCHORED instead: the
   pin is 100lvh, so setting the film's bottom to `100lvh - 100svh` puts its
   bottom edge exactly on the small viewport's bottom, which is what the JS was
   computing a top for. `height` stays whatever hero.js measured, `top` follows
   from it, and the only quantities CSS supplies are lvh and svh -- both live,
   neither cached, and no width term to disagree about.
   `!important` is required only because hero.js writes `top` as an inline
   style; hero.js itself is untouched, byte for byte.

   ⚠ WHAT THIS DOES NOT PROVE: headless Chromium has no collapsing toolbar, so
   Android's stale first read cannot be reproduced here. What is measured is
   that the CSS value equals the JS value at the settled viewport at every
   mobile width, and that under a deliberately stale probe the JS value is wrong
   while the CSS value is right. The fix is by construction; the equality is
   measured. */
@media not all and (min-width: 1024px) and (min-aspect-ratio: 1/1) {
  #filmBox { top: auto !important; bottom: calc(100lvh - 100svh) !important; }
}

/* ---- 2 · one swipe, one image ---------------------------------------------
   The rail is `scroll-snap-type: x mandatory` with `scroll-snap-align: center`
   on each card, and ⚠ scroll-snap-stop was simply absent -- the brief's reading
   is correct. Safari stops at every snap point of its own accord; Chrome lets a
   fling pass several, which is why one swipe on Android jumped to the last
   photograph. `always` makes the stop mandatory rather than advisory.
   ⚠ Scoped to below 1024 deliberately: desktop is a zero-diff gate this stage,
   and the desktop rail is driven by its own .rail-scroll control. A touch
   laptop at >=1024 therefore keeps the old behaviour -- stated, not hidden. */
@media (max-width: 1023px) {
  .projects .card { scroll-snap-stop: always; }
}

/* ---- 3 · the disclosure line is centred on mobile -------------------------
   .foot-notes carries `text-align: left` in the base rule, which is right at
   >=1024 where the whole footer goes left, and wrong below it where everything
   around it is centred. Only the alignment changes: the company-number line
   below and the contact block above are untouched, and so is every word. */
@media (max-width: 1023px) {
  .foot-notes { text-align: center; }
}

/* ============ STAGE 42, raised in 48B ======================================
   ⚠ /what-we-do/ IS PARKED — "parked, zero change" since a2c4a69. It was
   unparked for §141 (stage 42) and RE-PARKED in §142; unparked again for stage
   48B and RE-PARKED in the same commit. It is parked as you read this. Zero
   change unless a brief explicitly unparks it and re-parks it in that stage.
   What stays parked within the page: the display lines keep no halo, and the
   copper subtitle keeps its 14px x2 and its colour. BUILD-LOG §120 stands.

   ---- 1 · the white paragraphs get the subtitle's shadow -------------------
   Andrea, from the live page: "we need to add the same subtle shadowing behind
   this white small paragraph at the top of the what we do page." It reverses
   part of 26g, and it is well founded: 26j shallowed the bottom fade from 36%
   to 20%, which let more photograph through beneath exactly these lines.

   14px x2 -- the subtitle's own level, which is what he asked for and what he
   has called subtle and accepted live. Not 20px x4: the ground beneath them
   changed in 26j, so the old level is no longer the derived one.

   ⚠ DESKTOP ONLY, exactly as the subtitle's halo is, and for a measured
   reason: below 1024 the paragraphs sit on the dark canvas BELOW the
   photograph, not on it -- measured median 16.17:1 with 0.0% of the glyph
   footprint under the floor at 320, 390 and 430. There is nothing there for a
   shade to fix, and .open-halo is display:none below 1024 anyway. */
@media (min-width: 1024px) {
  /* the stand-ins sit where the real paragraphs sit. The first one's margin is
     the same derived expression the real first paragraph carries -- gap A plus
     the .16em the .wave-light pair takes back -- and the second is left to
     `.body-copy + .body-copy`, which reaches into the halo already. */
  .wwd-open .open-halo .wwd-title-shape + .body-copy {
    margin-top: calc(var(--gap) + 0.16 * clamp(1.9rem, 7vw, 4rem));
  }
  /* ⚠ 20px x4 — ANDREA'S RULING, stage 48B. This is not a heavier shade he has
     never seen: 20px x4 was the SHIPPED level until 26g removed it on 4 Sep,
     and what he complained about on 5 Sep was its absence, not its weight.
     §141 built it back at 18px x3 and deliberately stopped short of 20px x4 on
     the grounds that reinstating it was "not ours". 48B is his instruction to
     reinstate it, so that reason is spent.
     What the raise buys, measured in 48B at the worst cell (1680x1050, the
     second paragraph): 18px x3 read median 4.71 with 41.8% of its glyph
     footprint under the 4.5:1 floor; 20px x4 reads median 6.87 with 4.0%. No
     cell anywhere exceeds 4.0%, against four cells above 17% before.
     ⚠ The photograph's cost was RE-MEASURED at this level, not carried forward
     from §141's figure for 18px x3 — carrying a computed figure forward has
     cost this project a stage before. See BUILD-LOG §148.
     ⚠ DESKTOP ONLY. .open-halo is display:none below 1024 and the phones read
     16.17 / 0.0% without it; painted halo area below 1024 is 0px².
     The flat dark colour comes from the halo's own blanket rule. */
  .wwd-open .wwd-para-shape {
    filter: blur(20px)
            drop-shadow(0 0 0 rgba(18,16,14,0.85))
            drop-shadow(0 0 0 rgba(18,16,14,0.85))
            drop-shadow(0 0 0 rgba(18,16,14,0.85))
            drop-shadow(0 0 0 rgba(18,16,14,0.85));
  }

  /* ---- 3 · the page stops copying its own opening twice (48B) ------------
     The stand-ins are REAL TEXT in the DOM, so the clipboard took them: a
     select-all returned 7007 characters where the page has 6603, with the
     subtitle and both paragraphs doubled. Present since 26g for the subtitle;
     §141 added two more doubled sentences to a block already doing it.
     ⚠ SELECTION ONLY. No display, visibility, content-visibility, position or
     stacking change — nothing here alters paint. Measured against a
     byte-identical cross-server control on a fresh load: ZERO pixels differ at
     all ten viewports.
     ⚠ The 90px/Δ1 cost reported in §147 was an INSTRUMENT ARTEFACT of injecting
     the declaration into an already-painted page, which re-rasters the filtered
     layer and moves one tile-boundary column by 1/255. A visitor loads the rule
     with the stylesheet and never sees it. §148 records this.
     ⚠ The REAL paragraphs stay fully selectable — only the halo is suppressed.
     It sits on .open-halo so the subtitle's stand-in is covered too. */
  .wwd-open .open-halo {
    -webkit-user-select: none;
            user-select: none;
  }
}

/* ---- 2 · the before/after handle's focus ring clears its floor ------------
   Stage 36 measured .ba-handle's ring at 2.80:1 on the wall slider (4.45 on the
   garden) against the 3:1 non-text floor. It was never among the 62 that stage
   fixed because it already HAD a site ring -- and the page was parked.

   ⚠ The ring sits on a PHOTOGRAPH, so its ground is whatever the image happens
   to be at that edge, and no choice of ring colour can be safe against all of
   it. The fix is therefore to give the ring its own ground rather than to
   change its colour: an inset dark band under and around the copper, so the
   contrast that matters is copper against a known value instead of against a
   photograph. The ring keeps stage 27's colour, its -2px offset and the card's
   radius; nothing is added at rest, nothing moves, and box-shadow takes no
   part in layout, so the side-by-side, the drag, the keyboard and the captions
   are untouched. */
.ba .ba-handle:focus-visible {
  box-shadow: inset 0 0 0 5px rgba(18, 16, 14, 0.92);
}

/* ---- 44 · mobile gets the warm ground desktop already has -----------------
   43 found why the top of the mobile hero reads as a dead slab: `--canvas` is
   #12100E and the bar's scrim is rgba(18,16,14,α) — the SAME colour — so the
   scrim composites to its own ground exactly and nothing there is graded.
   Andrea's ruling: give mobile the ground desktop already has. #201710 is
   GROUND_STOPS[0] in hero.js, the approved opening ground — no colour is
   invented here.

   ⚠ WHAT THIS IS NOT. hero.js writes that ground per FRAME CHANGE from a
   lookup (groundColour(f) lerps the constant GROUND_STOPS table — no pixel is
   ever read). Extending THAT to mobile was audited and rejected, because the
   two layouts put the same ground in opposite places: on desktop the film is a
   narrow centred strip and the ground shows BESIDE it, 296–656px each side,
   full height, with 0px above it at all six widths; on mobile the film spans
   the full width and the ground shows ONLY ABOVE it, 151–298px at the top of
   the screen. hero.js's own ruling, immediately above render(), reads:
     "The top-strip sampler that painted the bar zone with the frame's own
      colour is REMOVED (Andrea, 29 Aug): it made the top of the screen a
      visible colour-shifting bar (canon breach)…
      Do not reintroduce frame-dependent colour at the top of the screen."
   A frame-tracking ground on mobile is that bar again — brown to plum to green
   to rust as the film scrubs — differing from the removed sampler only in
   where the colour comes from, not in what it looks like. So mobile takes the
   ground's COLOUR and not its frame dependence. Reported for Andrea's ruling.

   ⚠ This changes the HOMEPAGE HERO'S GROUND, not the bar. `.bar` is untouched:
   one fixed scrim, identical on every page.

   The media query is the exact negation of §85's gate, written the way 41
   wrote it, so it and hero.js's `desktop()` stay in step by construction:
     CSS  min-width: 1024px          ↔  JS  innerWidth >= 1024
     CSS  min-aspect-ratio: 1/1      ↔  JS  innerWidth >= innerHeight
   --pin-bg is set alongside so the film's own top feather dissolves into the
   new ground rather than the old one; nothing else reads it on mobile. */
@media not all and (min-width: 1024px) and (min-aspect-ratio: 1/1) {
  .hero-pin {
    --pin-bg: #201710;
    background: var(--pin-bg);
  }
}

/* ============ STAGE 50A — THE WORD-BY-WORD REVEAL ========================
   Andrea is trialling this on ONE page: "let's test it on this page, see how
   that works... If I like it, then we'll proceed with all the pages."

   ⚠ /what-we-do/ IS PARKED. Unparked for this stage and RE-PARKED in the same
   commit; §141's clause stands again the moment this ships.

   FOUR PARAGRAPHS, and nothing else on the page: the two opening paragraphs,
   the one under "The whole home", and the one under "We stand behind it". The
   display lines, the copper subtitle, every heading, the two unmarked
   paragraphs and the seven process steps are untouched.

   ⚠⚠ THE RULE THAT OUTRANKS THE EFFECT (stage 19): if the script fails, every
   word is visible. So the spans ship VISIBLE and the hiding is applied only
   under `.js-reveal`, which the head snippet adds and removes again if the
   modules never load. There is no rule anywhere below that hides a word
   without JS having first proved itself alive.

   ⚠⚠ THE HALO GHOST, and how it is handled. Targets 1 and 2 sit over
   hand-typed `aria-hidden` duplicates (§141, raised to 20px x4 at §148) that
   are NOT generated from the real text. Splitting only the real paragraph
   would leave the blurred shadow of the WHOLE sentence sitting under words
   that had not arrived yet — a ghost reading ahead of the copy. So the
   duplicates are split identically, word for word, and carry `.wg` too: the
   shade staggers in lockstep with its own words. Identical markup is also what
   keeps the two layers in registration, which is the reason the duplicates
   exist at all.

   ⚠ THE SPANS CARRY NO PADDING, NO MARGIN, NO DISPLAY CHANGE AND NO
   LINE-HEIGHT. The word-separating space is left OUTSIDE the span, so line
   breaking and clipboard text are exactly what a bare text node gives.

   TIMING: 18ms per word, 450ms fade. The longest paragraph is 43 words, so its
   last word starts at 756ms and the reveal completes at 1206ms. A 40ms step
   would have put that paragraph at 2.2s; 18ms keeps all four inside ~1.2s.
   Andrea can move the step alone and the arithmetic follows.

   OPACITY ONLY, no `filter: blur()`. 191 spans of animated blur is a real
   paint cost for an effect that reads without it; measured in BUILD-LOG §151. */
.wg .w { display: inline; }     /* stated, so nothing later makes it a block */

.js-reveal .wg { opacity: 1; transform: none; transition: none; }
.js-reveal .wg .w {
  opacity: 0;
  transition: opacity 450ms var(--ease);
  transition-delay: calc(var(--i, 0) * 18ms);
}
.js-reveal .wg.on .w { opacity: 1; }   /* 50B narrows this below */

/* whole paragraph present immediately, no stagger */
@media (prefers-reduced-motion: reduce) {
  .js-reveal .wg .w,
  .js-reveal .wg.on .w { opacity: 1; transition: none; transition-delay: 0s; }
}

/* ============ STAGE 50B — THE SEQUENCING CHAIN ============================
   ⚠ /what-we-do/ IS PARKED. Unparked for this stage and RE-PARKED in the same
   commit; §141's clause stands again the moment this ships.

   Andrea, having seen 50A: "when a paragraph is split into multiple sections
   it should show one section at a time... Obviously this needs to be
   overridden if somebody scrolls very fast." 18ms/word and the 450ms fade are
   unchanged.

   TWO GROUPS. `a` is 50A's two opening paragraphs; `b` is the seven process
   steps, whose paragraphs this stage splits. A member waits for the one before
   it to finish, then runs.

   ⚠ THE TRIGGER SITS IN DIFFERENT PLACES IN THE TWO GROUPS. Group `a` carries
   `.reveal` on the paragraph itself; each step carries it on the `.step`
   container, because the number and the heading keep the block fade they have
   and only the paragraph gets words. So the show rule is written twice — once
   for the class on the element, once for it on an ancestor.

   ⚠ THE HOLD IS ADDED BY SCRIPT, NEVER BY THIS STYLESHEET. Without JS there is
   no `.js-reveal` and no `.chain-hold`, so every word is visible and no chain
   exists. The stage-19 rule outranks the effect.

   ⚠ THE HALO DUPLICATES CARRY THE SAME data-chain VALUE as the paragraph they
   shade, so the controller releases both in the same instant and the shade can
   never lead its own words. */
.js-reveal .wg.on:not(.chain-hold) .w,
.js-reveal .on:not(.chain-hold) .wg .w { opacity: 1; }
.js-reveal .wg.chain-hold .w,
.js-reveal .chain-hold .wg .w { opacity: 0; transition-delay: 0s; }

@media (prefers-reduced-motion: reduce) {
  /* no chain at all: present immediately, whatever the script has set */
  .js-reveal .wg.chain-hold .w,
  .js-reveal .chain-hold .wg .w { opacity: 1; transition: none; transition-delay: 0s; }
}

/* ============ STAGE 51D — THE SKIP CONTROL ON MOBILE =====================
   Andrea, having seen 51C on his iPhone: the wide pill "reads as a second
   button competing with ENQUIRE". His ruling: the disc goes back to exactly
   what it was, and the words sit ABOVE it, outside the circle, very small,
   with a slight shadow. He will restyle the button himself later, so this is
   deliberately minimal.

   ⚠ THE DISC IS UNTOUCHED. 51C's pill — width:auto, padding, pill radius, gap,
   0.64 plate — is GONE, not overridden. `.hero-skip` at site.css:624 is the
   only thing that styles it: round, 44 x 44, rgba(18,16,14,0.3). Desktop is
   untouched for the same reason, and inherits nothing from here.

   ⚠⚠ THE CONTRAST FLOOR IS KNOWINGLY MISSED HERE, AND IT IS A RULED EXCEPTION,
   NOT A DEFECT — the site's SECOND, after §120. A slight shadow cannot carry
   cream over this film: 51B measured the bare film at 1.00:1 worst with 1,044
   of 1,128 frames below 4.5:1, and §155 established the film peaks at
   rgb(255,251,243), luminance 0.967. Andrea has been shown those numbers and
   has chosen the look. The measured figure is recorded in BUILD-LOG §156.
   ⚠ DO NOT "fix" this in a later stage, and do not let a pre-launch sweep
   flag it as a regression — exactly as §120 is handled. */

/* ⚠ THE CONTROL IS NOT SHOWN UNLESS THE SCRIPT HAS RUN (51C, kept). §153 found
   it was a DEAD BUTTON with JavaScript off: never re-homed, never labelled,
   and its click handler never binds. Its label is not content — it is the name
   of an action that cannot happen — so hiding it is correct and no word of
   copy is withheld with it. */
.hero-skip:not(.ready) { display: none; }

@media not all and (min-width: 1024px) and (min-aspect-ratio: 1/1) {
  /* the words sit ABOVE the circle and OUTSIDE it. Absolutely positioned, so
     the button's own box stays exactly 44 x 44 and the disc keeps its shape;
     right-aligned to the circle rather than centred on it, because the circle
     sits 16px from the film's right edge and .film-box carries overflow:hidden,
     so a centred label would be clipped. */
  /* Stage 51E, Andrea: "put skip, then return, then the film... and that sits
     centrally above the arrow". Two lines, centred on the disc.
     ⚠ THE BREAK IS CSS-GATED AND CARRIES NO NEW TEXT. The space between the two
     words is its own `<span class="hs-nl"> </span>`, so the label's text node
     content is still exactly "Skip the film" — the accessible name and the
     clipboard are untouched. A `<br>` would have meant either deleting that
     space (giving "Skipthe film") or trusting a break element to collapse
     correctly in the name; neither is worth the risk for a line break.
     ⚠ And it is not a width trick: a width chosen to wrap after "Skip" would
     depend on the webfont loading, and a fallback face could break it into
     three lines. This breaks the same way whatever face renders it. */
  /* ⚠ A GENERATED newline, not a block box. `display:block` on the space span
     also worked, but it made the space a LINE BREAK IN THE COPY: the clipboard
     returned "Skip\nthe film". Generated content is not part of textContent and
     is not copied, so the real space survives into the clipboard while the
     break still happens visually. `white-space: pre` on the pseudo-element is
     what preserves the newline against the label's own `nowrap`. */
  .hero-skip .hs-nl::after { content: "\A"; white-space: pre; }

  .hero-skip .hs-label {
    display: inline-block;
    position: absolute;
    right: 50%; transform: translateX(50%);   /* centred on the disc */
    bottom: 100%;
    margin-bottom: 7px;
    font-size: 0.62rem; font-weight: 500;
    letter-spacing: 0.14em; text-transform: uppercase;
    line-height: 1.15;                        /* tight: two lines, no slack */
    text-align: center;
    /* ⚠ REQUIRED. Without it the label shrink-to-fits to min-content and "the
       film" wraps as well, giving THREE lines. nowrap keeps each side of the
       break on one line; the break itself comes from .hs-nl above, not from
       the wrapping. */
    white-space: nowrap;
    /* ⚠ ONE declaration, and it is the film-cue's own — site.css:1019, the
       established shadow for type over this film. No plate, no pill, no
       blurred duplicate, no halo layer. */
    text-shadow: 0 2px 14px rgba(18, 16, 14, 0.4);
  }
}

/* ============ STAGE 52 — WORD REVEALS ON /portfolio/ AND THE HOMEPAGE ======
   NO RULE WAS ADDED FOR THIS STAGE, AND THAT IS THE POINT. 50A and 50B were
   written against `.wg` and `.w`, not against /what-we-do/, so the two pages
   this stage marks up are carried by the blocks above unchanged.

   WHICH OF 50B's TWO SHOW RULES EACH ITEM USES:
     /portfolio/ — `.reveal` sits on the paragraph itself, so the paragraph
       carries both `.wg` and `.on`: the FIRST selector,
       `.js-reveal .wg.on:not(.chain-hold) .w`. `.js-reveal .wg` also cancels
       the block's own fade and lift, so the words arrive instead of the box.
     the homepage intro — `.reveal` sits on `.wwd-copy`, which also wraps the
       heading and the link, so `.on` lands on an ANCESTOR: the SECOND
       selector, `.js-reveal .on:not(.chain-hold) .wg .w`. This is the same
       shape as 50B's seven process steps, where the trigger is on `.step`.
       The wrapper keeps its block fade; the words stagger inside it.

   ⚠ /portfolio/ IS DELIBERATELY UNCHAINED. Twelve projects are twelve
   separate reads, not one sequence, and a chain would hold the last paragraph
   behind eleven predecessors. The paragraphs carry `.wg` and nothing else.
   The homepage intro IS one chained group, `c`.

   ⚠⚠ /about/ WAS BUILT AND THEN NOT SHIPPED. It failed the no-layout-shift
   gate and is not in this commit. MEASURED, one tree, each split paragraph
   against a plain-text clone of itself in the same containing block:
   at 1180 wide Marco's second paragraph went from 6 lines to 7 (+29.4px);
   at 1440 wide Massimo's first kept 4 lines but broke in a different place.
   CAUSE: an inline box's advance is snapped to a LayoutUnit, so every word
   gains up to 1/64px — "hospitality," measures 91.984 as bare text and 92.000
   inside a span. Over a 47-word paragraph that accumulates past a line that
   was nearly exactly full. /portfolio/, the homepage and /what-we-do/ were
   swept the same way at ten widths and NOT ONE line break moved on any of
   them, so this is /about/'s two paragraphs, not the method. Andrea rules on
   it; nothing was adjusted to make it pass. */

/* ============ STAGE 82b: the curved, slowly moving seams ==================
   Andrea (25 Sep). A seam is where the colour of the block above holds solid
   down to a gently curved line and fades across it into the block below. The
   first build (Stage 82) drew each seam as a live, blurred SVG and scaled it
   every frame; on Andrea's devices the whole page stuttered. This build draws
   NOTHING at run time:

   - each seam is a div.seam IN THE SERVED MARKUP, the first child of the
     block below the boundary (its "host"), which carries .seam-host; the
     block above carries .seam-above. The curve is there from the first paint
     and needs no script: without JavaScript the seams simply show and move.
   - the curve is a MASK IMAGE: a greyscale PNG one period wide, the soft edge
     baked in (public/assets/img/seams/, from scripts/website/
     build-seam-masks.mjs), tiled sideways on a box filled with the block
     above's colour (--prev-bg, the value every section already declares).
     Two such layers, curve a and curve b, sit in every seam: where either is
     solid the upper colour shows, so the combined edge is the lower of the
     two curves and keeps changing shape as they move apart.
   - they move ONLY with transform: translate3d(), a compositor animation of
     a texture the browser already holds: no paint, no raster, no blur, no
     scale per frame. Curve a drifts one full period every 20-27 s (a period
     is the seam's own width, so the loop is seamless at every size); curve b
     rocks a tenth of a period each way, a full swing every 6-8 s. Both differ
     per seam (.s1 … .s15 below): the timings, the direction of the drift
     (even seams the other way) and the phase of each curve, which is a
     fixed mask-position, never an animation delay, so a seam's still pose
     (no animation: off screen, reduced motion, a browser without seams.js)
     is exactly the pose its animation starts from, and nothing jumps.
   - seams.js (deferred) only removes the animations from seams that are off
     screen, so a page keeps compositor memory for the seams in view alone;
     html.seams-js, set by a one-line script in <head>, makes that the state
     from the first paint. Under reduced motion every seam stands still.

   THE GEOMETRY, in the mask's 120-unit height. The curve sits on y = 50 with
   A = 12 and a 0.28·A second harmonic, the soft edge reaches S = 27 units
   either side of it (the 2% reach of the blur Stage 82 used, so the envelope
   is what it was), and over both curves the fade as shipped (the rows whose
   8-bit alpha is neither 0 nor 255) lies inside rows 10-91: 0.0833 and 0.7583
   of the box (build-seam-masks.mjs prints these; test-stage82-seams.mjs
   reads them from the files). Those two fractions set
   the paddings, so that the gap from where the fade ends to the nearest
   content is --seam-gap on both sides of every seam, whatever the width and
   wherever the curves have moved to:
     host padding-top     = 0.7583 × box + gap
     above padding-bottom = gap − 0.0833 × box
   The box stays in its host: straddling the boundary would have to paint the
   lower ground up into the block above. */
:root {
  --seam-h: clamp(56px, 8.5vw, 110px);
  --seam-gap: 36px;
  --seam-pt: calc(var(--seam-h) * .7583 + var(--seam-gap));
  --seam-pb: calc(var(--seam-gap) - var(--seam-h) * .0833);
}
/* the paddings, on every host and every block above one. Written at (0,2,0)
   and last in the file, because the desktop rhythm rules (Stage 15: 
   `.section.people`, `.people + .enquire-block` …) are (0,2,0) too and would
   otherwise outrank a bare class; the per-block rules below tie and win by
   order. */
:is(.section, .proj, .site-footer, .ba-pair-wrap).seam-host { padding-top: var(--seam-pt); }
:is(.section, .proj, .pf-intro, .wwd-open, .ba-pair-wrap).seam-above { padding-bottom: var(--seam-pb); }
/* a browser without mask images keeps the straight band it had, inside the
   new paddings; everything that draws a curve is inside this @supports */
.seam { display: none; }
@supports ((-webkit-mask-image: url("")) or (mask-image: url(""))) {
  .seam-host::before { display: none; }              /* the old straight band */
  .ct-flow.seam-above::after { display: none; }      /* its straight fade into the footer: the footer hosts a seam */
  /* THE BOX (Stage 82c). The tile (the curve) sits --seam-lead below the
     box's top; above it a solid strip of the same fill reaches --seam-lead up
     INTO the block above, and below it a solid strip --seam-tail tall reaches
     down. The strips are the BOX's own background (the two tile layers are
     inset by them): a mask with more than one layer took the tile layers off
     the compositor in Chromium, and they were painted again every few frames
     (measured: 474 paints on seam nodes in 6 s; 0 with one mask layer).
     Both strips are 0 except where they are needed:
       lead 2px on an ordinary seam. Every boundary sits on a fractional
         pixel (the paddings are fractions of a viewport unit), and a clipped
         layer whose edge is snapped one way while the block above is snapped
         the other shows the host's ground as a hairline between two runs of
         the colour above (Andrea's line under "See six more spaces", cream
         over brown). The strip is the colour of the block above, so where it
         overlaps that block nothing shows, and the hairline can no longer
         exist.
       tail on the hero (below). The pixel fractions are handled with mask
         layers rather than by moving the tile, so the curve's geometry is
         exactly what the paddings assume.
     STAGE 82d: the lead did not remove Andrea's line on an iPhone. The two
     moving tile layers are composited, each its own layer, and at 3 device
     pixels to the CSS pixel they and the strip under them snap to device
     pixels separately, so a butt joint between strip and tile could still
     open by one device pixel and show the host's own ground through it. So
     no joint is a butt joint any more:
       - each strip runs 2px ON UNDER the tile (--lead-over, --tail-over),
         where the tile's mask is fully opaque (the first 10 and the last 29
         of its 120 rows are 255 in the masks that meet a strip; 2px is at
         most 4.3 rows at the smallest seam, 56px);
       - the HOST paints the colour above in its own top 4px (the inset
         shadow below; 4px is inside the tile's solid rows even at 56px), so
         whatever a snapping gap shows at the boundary is the colour above.
     A rising seam has no lead and no host strip: its tile is one layer
     across the boundary, solid below it, and its lower edge lies inside the
     host, which is its own colour; where it has a tail (the hero on a phone,
     over the pin's dark canvas) the tail runs 2px under the tile. */
  .seam {
    display: block;
    position: absolute; left: 0; right: 0;
    --seam-lead: 2px; --seam-tail: 0px; --tile-h: var(--seam-h);
    --lead-over: 2px; --tail-over: 0px;   /* 82d: how far each strip runs on under the tile */
    top: calc(-1 * var(--seam-lead));
    height: calc(var(--tile-h) + var(--seam-lead) + var(--seam-tail));
    background: linear-gradient(180deg, var(--seam-fill) 0, var(--seam-fill) calc(var(--seam-lead) + var(--lead-over)), transparent calc(var(--seam-lead) + var(--lead-over)),
      transparent calc(100% - var(--seam-tail) - var(--tail-over)), var(--seam-fill) calc(100% - var(--seam-tail) - var(--tail-over)));
    overflow: hidden;
    pointer-events: none;
    /* ABOVE the block's content (which is z-index auto): a photograph that
       runs to the block's edge lies under the seam's fade; text, buttons and
       cards keep their 32-48px and never enter the box. On the homepage this
       is also the explicit order over the hero's sticky pin and its film. */
    z-index: 1;
    --seam-fill: var(--prev-bg, var(--canvas));
  }
  .seam::before, .seam::after {
    content: '';
    position: absolute; top: var(--seam-lead); bottom: var(--seam-tail); left: 0;   /* the tile, between the strips */
    width: 200%;                       /* two periods: moving by one loops without a join */
    background: var(--seam-fill);
    -webkit-mask-image: url(../img/seams/curve-a.png);
    mask-image: url(../img/seams/curve-a.png);
    -webkit-mask-size: 50% 100%; mask-size: 50% 100%;   /* one period = the seam's width */
    -webkit-mask-repeat: repeat-x; mask-repeat: repeat-x;
    -webkit-mask-position: var(--sx, 0%) 0; mask-position: var(--sx, 0%) 0;   /* a fixed phase per seam */
    animation: seam-drift var(--dt, 24s) linear infinite var(--dir, normal);
  }
  .seam::after {
    width: 120%; left: -10%;           /* rocks a tenth of the seam's width each way */
    -webkit-mask-image: url(../img/seams/curve-b.png);
    mask-image: url(../img/seams/curve-b.png);
    -webkit-mask-size: 83.3333% 100%; mask-size: 83.3333% 100%;   /* the same period, of this wider box */
    /* this box is only a fifth of a period wider than its tile, so 100% here
       is a fifth of a period: 500% is a whole one */
    -webkit-mask-position: var(--sx2, 0%) 0; mask-position: var(--sx2, 0%) 0;
    animation: seam-rock var(--bt, 7s) linear infinite;
  }
  /* a RISING seam: the LOWER colour is the filled shape, the box sits above
     the boundary over the block above's photograph (or the film), no lead */
  .seam-rise::before { -webkit-mask-image: url(../img/seams/curve-a-rise.png); mask-image: url(../img/seams/curve-a-rise.png); }
  .seam-rise::after { -webkit-mask-image: url(../img/seams/curve-b-rise.png); mask-image: url(../img/seams/curve-b-rise.png); }
  /* 82d: the host's top 4px in the colour above (an inset shadow: it paints
     over the host's own ground and under its content, and leaves every
     background the blocks already have alone). A rising seam's host is the
     seam's own colour, so it takes none. */
  .seam-host { box-shadow: inset 0 4px 0 var(--prev-bg, var(--canvas)); }
  .seam-host.seam-host-rise { box-shadow: none; }
}
/* both start from the identity (and a reversed drift starts one whole period
   along, which is the same picture), so the first animated frame is the still
   pose; the rock eases through each swing */
@keyframes seam-drift { to { transform: translate3d(-50%, 0, 0); } }
@keyframes seam-rock {
  0% { transform: translate3d(0, 0, 0); animation-timing-function: ease-out; }
  25% { transform: translate3d(8.3333%, 0, 0); animation-timing-function: ease-in-out; }
  75% { transform: translate3d(-8.3333%, 0, 0); animation-timing-function: ease-in; }
  100% { transform: translate3d(0, 0, 0); }
}
/* off screen (seams.js clears .live), no animation and so no compositor
   layer; the seam is still drawn, standing still, in its block's own paint */
html.seams-js .seam:not(.live)::before, html.seams-js .seam:not(.live)::after { animation: none; }
@media (prefers-reduced-motion: reduce) {
  .seam::before, .seam::after { animation: none; }
}
/* each seam's own timing and phases, by its order on the page: drift
   20 + (5n mod 8) s, rock 6 + (3n mod 5) / 2 s, even seams reversed, curve a
   at (37n mod 100)% of a period and curve b at (61n mod 100)% (× 5, see above) */
.s1  { --dt: 25s; --bt: 7.5s; --sx: 37%; --sx2: 305%; }
.s2  { --dt: 22s; --bt: 6.5s; --sx: 74%; --sx2: 110%; --dir: reverse; }
.s3  { --dt: 27s; --bt: 8s;   --sx: 11%; --sx2: 415%; }
.s4  { --dt: 24s; --bt: 7s;   --sx: 48%; --sx2: 220%; --dir: reverse; }
.s5  { --dt: 21s; --bt: 6s;   --sx: 85%; --sx2: 25%; }
.s6  { --dt: 26s; --bt: 7.5s; --sx: 22%; --sx2: 330%; --dir: reverse; }
.s7  { --dt: 23s; --bt: 6.5s; --sx: 59%; --sx2: 135%; }
.s8  { --dt: 20s; --bt: 8s;   --sx: 96%; --sx2: 440%; --dir: reverse; }
.s9  { --dt: 25s; --bt: 7s;   --sx: 33%; --sx2: 245%; }
.s10 { --dt: 22s; --bt: 6s;   --sx: 70%; --sx2: 50%; --dir: reverse; }
.s11 { --dt: 27s; --bt: 7.5s; --sx: 7%;  --sx2: 355%; }
.s12 { --dt: 24s; --bt: 6.5s; --sx: 44%; --sx2: 160%; --dir: reverse; }
.s13 { --dt: 21s; --bt: 8s;   --sx: 81%; --sx2: 465%; }
.s14 { --dt: 26s; --bt: 7s;   --sx: 18%; --sx2: 270%; --dir: reverse; }
.s15 { --dt: 23s; --bt: 6s;   --sx: 55%; --sx2: 75%; }

/* ---- the hero's edge ----------------------------------------------------
   The hero paints no flat ground: its bottom is the film's last frame. So its
   seam RISES: What we do's own ground (--sec-bg) is the filled shape, the box
   sits above the boundary over the film's bottom edge, and the ground is
   solid at the boundary even at the curve's lowest point (the box ends
   0.7583 of its height above the edge), so there is never a band or a step
   between the two. The visible rise is 0.675 × the box (0.7583 − 0.0833).
   FULL WIDTH (Andrea, 25 Sep): over the film and both side panels alike. */
.seam-rise {
  --seam-fill: var(--sec-bg, var(--proj-ground)); --rise-h: var(--seam-h); --tile-h: var(--rise-h); --seam-lead: 0px;
  --lead-over: 0px;   /* its tile's top is clear, over the photograph: no strip there */
  top: calc(var(--rise-h) * -.7583);
}
/* On a phone or a portrait tablet the film box is anchored by this
   stylesheet (`#filmBox { bottom: calc(100lvh - 100svh) }`, the Android fix
   above) with its bottom lvh − svh above the 100lvh pin's edge, so it never
   hides behind the toolbar, and that much bare pin canvas lies under it. The
   rise sits on the film's own bottom edge and the tail strip paints What we
   do's ground over that gap: zero wherever svh = lvh, 80-odd px on an iPhone
   with its toolbar showing. The desktop layout (hero.js: at least 1024 wide
   AND at least as wide as tall, an iPad in landscape included) anchors the
   film to the pin, so no strip applies. Guarded on svh/lvh support: without
   them the seam must keep its size. */
.hero + .wwd .seam-rise { --hero-gap: 0px; --seam-tail: var(--hero-gap); top: calc(var(--rise-h) * -.7583 - var(--hero-gap)); }
@supports (height: 100svh) {
  @media not all and (min-width: 1024px) and (min-aspect-ratio: 1/1) {
    /* 82d: the tail strip runs 2px on under the tile (whose last 29 of 120
       rows are solid; the tile is the full seam, 56px or more, on these
       layouts). Only here: on the desktop layout there is no tail, and the
       tile can be as short as 8px, where 2px would reach into the curve. */
    .hero + .wwd .seam-rise { --hero-gap: calc(100lvh - 100svh); --tail-over: 2px; }
  }
}
/* a block whose photograph runs to the boundary (Andrea, 26 Sep): the seam
   sits on the photograph. Above a photograph the colour above fades onto its
   top (an ordinary seam over a host that opens on its photograph, no padding
   above it); below a photograph the colour below rises over its bottom (a
   rising seam in the host, no padding under the photograph). The
   photograph's own soft edge (.img-blend) comes off the edge the seam sits
   on, and a rounded corner there becomes square.
   STAGE 84 (Andrea, 27 Sep): /about/ only. /portfolio/'s projects no longer
   carry these classes: there the seam ends in the project's own ground, the
   photograph is whole below the ordinary host padding, and it sits beside its
   copy as it did before 82c. */
.seam-host.seam-host-rise { padding-top: var(--seam-gap); }   /* the rise ends on the boundary: the gap below it is this alone */
.seam-host.seam-photo-top { padding-top: 0; }
.seam-above.seam-photo-bottom { padding-bottom: 0; }
.seam-photo-top .proj-img:not(.stage) img {
  -webkit-mask-image: linear-gradient(180deg, #000 89%, transparent 100%); mask-image: linear-gradient(180deg, #000 89%, transparent 100%);
}
.seam-photo-bottom .proj-img:not(.stage) img {
  -webkit-mask-image: linear-gradient(180deg, transparent 0%, #000 11%); mask-image: linear-gradient(180deg, transparent 0%, #000 11%);
}
/* (The before/after sliders on What we do keep their ordinary seams, their
   corners and their paddings: Andrea, 26 Sep, "fine and doesn't need
   adjusting".) */
@media (min-width: 1024px) {
  /* side by side with its copy, the photograph's column is the section's
     full height, so it reaches the edge the seam sits on (align-self); the
     copy carries the paddings the section gave up and stays centred beside
     it. Where the copy's column is the taller, the photograph floats short of
     the far edge, which is shown to Andrea. A project that opens on its
     photograph also ends on it where the photograph is the taller column
     (on /about/; /portfolio/ left this rule in Stage 84): its bottom edge dissolves into the
     project's own ground (its .img-blend), which the next seam curves onto
     the next photograph, so no band of ground sits between the two. */
  .proj.seam-host.seam-photo-top .proj-copy { padding-top: var(--seam-pt); }
  .proj.seam-host.seam-photo-top .proj-img { align-self: start; }
  .proj.seam-above:is(.seam-photo-top, .seam-photo-bottom) { padding-bottom: 0; }
  .proj.seam-above:is(.seam-photo-top, .seam-photo-bottom) .proj-copy { padding-bottom: var(--seam-pb); }
  /* under a photograph the next seam RISES 0.7583 of its box above the
     boundary, so the copy beside that photograph clears the rise, not an
     ordinary seam's lead */
  .proj.seam-above.seam-photo-bottom .proj-copy { padding-bottom: var(--seam-pt); }
  .proj.seam-above.seam-photo-bottom .proj-img { align-self: end; }
}
/* Desktop: the phone number stands in the right-hand panel, its digits
   --foot-pad above the edge (Stage 38c), and must stay 32px clear of the fade
   at the curve's highest point (plus 0.06 × its font size, what the digits'
   glyph box reaches below their layout box: 1.1-2.6px at 1024-1920). Stage
   82c (Andrea, 26 Sep): the number and its icon MOVE UP, by what the full
   rise needs (--rise-need) but never more than HALF the panel's spare room
   (--hs2-slack: what the column above the number has to spare before Stage
   38c's arithmetic would start giving way). Half, because the column's
   content is centred in its box, so it sits slack/2 above the number's
   reserved line: a lift of at most slack/2 keeps the 20px the column
   reserves above the number's ring with the column itself untouched. So
   nothing else in the panel ever moves, and where the room is short the
   number rises as far as it can and the curve sits lower for the rest
   (never cut short, never a straight step; never under 8px of box). The lift
   is also taken from the column's room in Stage 38c's chain (--hs2-room), so
   the give-way levers cannot follow the number down. */
@media (min-width: 1024px) and (min-aspect-ratio: 1/1) {
  :root {
    --tel-clear: calc(32px + .06 * clamp(1.6rem, 4vw - 14px, 3.9rem));
    --rise-need: calc(var(--seam-h) * .675 + var(--tel-clear) - var(--foot-pad));
    --hs2-slack: max(0px, calc(var(--hs2-room0) - var(--hs2-need)));
    --tel-lift: max(0px, min(var(--rise-need), var(--hs2-slack) / 2));
  }
  .hero-right-foot { padding-bottom: calc(var(--foot-pad, clamp(2rem, 4vw, 4.5rem)) + var(--tel-lift, 0px)); }
  .hero + .wwd .seam-rise { --rise-h: max(8px, min(var(--seam-h), calc((var(--foot-pad, 2rem) + var(--tel-lift, 0px) - var(--tel-clear)) / .675))); }
}
/* Before hero.js has laid the hero out (it writes the hero's height inline;
   without JavaScript, never), the hero ends on its pin's flat canvas ground
   and its edge takes an ordinary seam from it. So does the still hero under
   reduced motion, which ends on its stills column's canvas: decided ONCE, at
   load, by the <head> line (html.seams-still), because hero.js chooses the
   film or the stills once at load and never changes over. */
.hero:not([style*="height"]) + .wwd, html.seams-still .hero + .wwd { padding-top: var(--seam-pt); }
.hero:not([style*="height"]) + .wwd .seam-rise, html.seams-still .hero + .wwd .seam-rise {
  --seam-fill: var(--canvas); --rise-h: var(--seam-h); --seam-lead: 2px; --seam-tail: 0px; top: -2px;
  --lead-over: 2px; --tail-over: 0px;   /* 82d: an ordinary seam's strips (these take the ordinary masks) */
}
/* ⚠ before hero.js has laid the hero out, the boundary is exactly one
   viewport down (an integer), and a lead strip there would put 2px of the
   seam in the first viewport, to be counted as a layout shift when the hero
   grows (+0.013 at 1440, +0.07 at 390): no lead until then */
.hero:not([style*="height"]) + .wwd .seam-rise { --seam-lead: 0px; top: 0; }
/* without mask images .wwd keeps the host padding, under its straight band */
@supports not ((-webkit-mask-image: url("")) or (mask-image: url(""))) {
  .seam-host.seam-host-rise { padding-top: var(--seam-pt); }
}
@supports ((-webkit-mask-image: url("")) or (mask-image: url(""))) {
  /* 82d: and so its host takes the ordinary host strip, in the canvas above */
  .hero:not([style*="height"]) + .wwd, html.seams-still .hero + .wwd { box-shadow: inset 0 4px 0 var(--prev-bg, var(--canvas)); }
  .hero:not([style*="height"]) + .wwd .seam-rise::before, html.seams-still .hero + .wwd .seam-rise::before {
    -webkit-mask-image: url(../img/seams/curve-a.png); mask-image: url(../img/seams/curve-a.png);
  }
  .hero:not([style*="height"]) + .wwd .seam-rise::after, html.seams-still .hero + .wwd .seam-rise::after {
    -webkit-mask-image: url(../img/seams/curve-b.png); mask-image: url(../img/seams/curve-b.png);
  }
}

/* ---- per-block exceptions ------------------------------------------------ */
/* home: the featured-projects rail opens its section with its own top margin
   (--rail-top); the padding gives it back so the gap is the same */
.projects.seam-host { padding-top: calc(var(--seam-pt) - var(--rail-top)); }
/* /what-we-do/: the before/after pair is two blocks below 1024 (each with its
   own seam) and one block, the wrapper, above it (its seam, its band gone,
   its paddings; the cells keep their zero padding from the grid rules) */
@media (max-width: 1023px) {
  .ba-pair-wrap > .seam { display: none; }
  .ba-pair-wrap.seam-host { box-shadow: none; }   /* 82d: its first cell hosts the seam and the strip */
}
@media (min-width: 1024px) {
  .ba-pair-wrap > .ba-sec > .seam { display: none; }
  .ba-pair-wrap > .ba-sec.seam-host { padding: 0; box-shadow: none; }   /* the cells: the wrapper carries the paddings (Stage 27) and the host strip (82d) */
  .ba-pair-wrap.seam-host::before { display: none; }
  .ba-pair-wrap.seam-host { padding-top: var(--seam-pt); }
  .ba-pair-wrap.seam-above { padding-bottom: var(--seam-pb); }
}
/* /portfolio/: the intro's headline sits on a line-height of 0.98, so its
   descenders reach about 0.08em past its box; 8px more keeps them 32px clear */
.pf-intro.seam-above { padding-bottom: calc(var(--seam-pb) + 8px); }
/* /about/: a project that opens on its copy (the text-only sections; below
   1024 the image-right ones too, where the copy comes first) loses the hook's
   own top margin: the seam padding is that gap now. /portfolio/'s projects
   all open on their photograph, so nothing there matches. */
.proj.seam-host > .inner > .proj-copy:first-child > .proj-hook:first-child { margin-top: 0; }
@media (min-width: 1024px) {
  /* the breathing room between copy and photograph (29 Aug, item 5) is a
     stacked-layout gap; side by side it only pushed the photograph 56px down
     and off centre, and its top was the nearest thing to the seam */
  .proj.seam-host .proj-copy + .proj-img { margin-top: 0; }
}
/* /about/: "The people behind it" opens on a seam now (#handle's brown to its
   cream), so the 16px that closed the old gap gives way to the seam's room */
#people.seam-host { padding-top: var(--seam-pt); }
#people.seam-host.seam-host-rise { padding-top: var(--seam-gap); }   /* 82c: its seam rises over #handle's photograph */
/* without mask images it keeps the padding, under its straight band (the
   same fallback as .seam-host-rise above, written after the rule it must
   beat at equal specificity) */
@supports not ((-webkit-mask-image: url("")) or (mask-image: url(""))) {
  #people.seam-host.seam-host-rise { padding-top: var(--seam-pt); }
}

/* ============ STAGE 88: every size of job ===================================
   The bridge's two ways in, the smaller-job form, and the two questions every
   enquiry route now ends with. Nothing here restyles an existing control: the
   bars, chips, fields, upload and nav are the flow's own; these rules only
   lay out the new markup and dress real checkboxes and radios as them. */

/* the two ways in: the site's copper pill twice, each with its small line
   under it. Stacked on phones, side by side from 768. One width for both so
   they read as a pair; on the narrowest phones the pill never outgrows the
   column (the label wraps inside it rather than the page scrolling). The
   line is full cream, like every sentence on the bridge (Stage 73's ladder:
   nothing under 0.9 alpha carries a word on the crests). */
.eq-routes {
  margin-top: clamp(2.2rem, 7vw, 3.4rem);
  display: flex; flex-direction: column; align-items: center; gap: 1.6rem;
}
/* the pill's bloom paints 64px outside it (.cta-wrap::after); at 19.5rem
   that runs past a phone's edge and the page scrolls sideways. Clipped at the
   section's sides, as Stage 82 does for every section holding a pill: only
   the glow nobody could see goes. */
.eq-landing { overflow-x: clip; }
.eq-route { display: flex; flex-direction: column; align-items: center; max-width: 100%; }
.eq-landing .eq-routes .cta-wrap { margin-top: 0; max-width: 100%; }
.eq-routes .cta { width: 19.5rem; max-width: 100%; padding: 0 24px; }
.eq-routes .cta span { text-align: center; line-height: 1.4; }
.eq-route-line {
  margin-top: 0.85rem;
  font: 500 0.95rem/1.4 'Instrument Sans', sans-serif;
  letter-spacing: -0.005em;
  color: var(--cream);
  text-shadow: 0 3px 20px rgba(18, 16, 14, 0.5);
}
@media (min-width: 768px) {
  .eq-routes { flex-direction: row; justify-content: center; align-items: flex-start; gap: 2.2rem; }
}
/* Andrea, 2 Oct: the two pills' lights must not move in step, which reads as copy and paste. The second
   route's every moving layer starts half a lap on: the turning rim, hover rim and bloom (p3-lap, 9s) by
   4.5s, the flare (p3-lap-flare, 12s) by 6s, so one light is at the top while the other is at the bottom.
   Same speed, same picture. Only where the lights move at all (86c's own gate); a browser without :has()
   or container units draws the old rim, and that is offset the same way (p3-orbit 9s, p3-flare 12s). */
@media (prefers-reduced-motion: no-preference) {
  .eq-routes > .eq-route:nth-child(2) .lap-rim::before,
  .eq-routes > .eq-route:nth-child(2) .lap-hover::before,
  .eq-routes > .eq-route:nth-child(2) .lap-pill::before { animation-delay: -4.5s; }
  .eq-routes > .eq-route:nth-child(2) .lap-flare::before { animation-delay: -6s; }
  .eq-routes > .eq-route:nth-child(2) .cta-wrap { animation-delay: -4.5s; }
  .eq-routes > .eq-route:nth-child(2) .cta::after { animation-delay: -6s; }
  /* Andrea, 2 Oct (2): and the second pill's flare sweeps the other way, right to left. Its box is the pill
     itself (inset 0) and its label mask is the same from either side, so the box is mirrored: the same
     animation, speed, easing and passes, run in the mirror. The old-rim fallback's flare (.cta::after) too. */
  .eq-routes > .eq-route:nth-child(2) .lap-flare,
  .eq-routes > .eq-route:nth-child(2) .cta::after { scale: -1 1; }
}

/* a checkbox or radio dressed as the flow's bar (.eq-btn) or chip (.eq-chip).
   The input stays in the document, focusable and announced, and only its box
   is hidden; the label is the tap target. enquire.js mirrors the checked
   state to .sel, so the selected face and the travelling rim are the flow's
   own; without script the selected face comes from :has() below. */
.eq-pick { position: relative; cursor: pointer; }
.eq-btn.eq-pick { display: block; }
.eq-chip.eq-pick { display: inline-flex; align-items: center; justify-content: center; line-height: 1; }
.eq-pick > input {
  position: absolute; left: 50%; top: 50%;
  width: 1px; height: 1px; margin: 0; padding: 0;
  opacity: 0; pointer-events: none;
  clip-path: inset(50%);
}
.eq-pick:has(> input:focus-visible) { outline: 2px solid var(--eq-focus, var(--copper-lit)); outline-offset: 2px; }
html:not(.js-on) .eq-pick:has(> input:checked) {
  background: var(--eq-face-hi, var(--surface-hi));
  border-color: #2B1F1E;
  box-shadow: inset 0 0 0 2px #DB975D;
}
.eq-pickset, .eq-sm-step { border: 0; margin: 0; padding: 0; min-width: 0; }
.eq-pickset { margin-top: 1rem; }
/* the question over a set of chips: the fields' own label look */
.eq-legend {
  display: block; width: 100%; padding: 0 0 16px;
  font-size: 0.72rem; font-weight: 500;
  letter-spacing: 0.14em; text-transform: uppercase;
  color: var(--eq-muted, var(--cream-muted));
}
.eq-pickset > .f-error, .eq-sm-err {
  color: var(--eq-error, #E8A765);
  font-size: 0.78rem; margin-top: 6px; min-height: 1em;
}
.eq-sm-err { text-align: center; margin-top: 0; }
/* the opt-out box: a plain checkbox, its sentence beside it, the form's ink */
.eq-check {
  display: flex; align-items: flex-start; gap: 12px;
  margin-top: 0.4rem;
  font-size: 0.92rem; line-height: 1.45;
  color: inherit; cursor: pointer;
}
.eq-check input {
  flex: none; width: 20px; height: 20px; margin: 1px 0 0;
  accent-color: var(--eq-focus, var(--copper-lit));
  cursor: pointer;
}
.eq-check input:focus-visible { outline: 2px solid var(--eq-focus, var(--copper-lit)); outline-offset: 2px; }
/* the privacy line now sits UNDER Send, so the nav gives it less room above */
.eq-nav + .eq-privacy { margin-top: -0.4rem; }
.eq-privacy { text-wrap: pretty; }

/* the smaller-job form: one step at a time with script, all three in order
   without it (and then a plain gap between them) */
.eq-sm-step { display: grid; gap: 16px; }
.js-on .eq-small .eq-sm-step:not(.on) { display: none; }
html:not(.js-on) .eq-sm-step + .eq-sm-step { margin-top: 3rem; }
.eq-small .eq-q { margin-bottom: 0.4rem; }
.eq-small .eq-note { margin: -0.6rem 0 0.4rem; }
.eq-small .eq-sm-jobs { margin-top: 0.4rem; }
.eq-sm-help { font-size: 0.85rem; line-height: 1.45; color: var(--eq-muted, var(--cream-muted)); margin-top: 6px; }
.eq-small .eq-upload + .eq-sm-help { margin-top: -4px; text-align: center; }
.eq-sm-other[hidden] { display: none; }
.eq-small .eq-resumed { margin-bottom: 0; }
/* the step bar over the page, as in the guided flow: a third per step */
.eq-small-on .eq-progress { opacity: 1; }
/* without script "A smaller job" is a plain link to the form, revealed the
   way Stage 36 reveals the standard form */
html:not(.js-on) #eqSmall:target { display: block; }
/* visually hidden, still read: the "Step n of 3" legends */
.eq-vh {
  position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}
/* Stage 88: the privacy page's four new "What we collect" rows. The page's
   list (.legal-list), each row's parts on lines of their own so the four
   columns of the office's table read in order on a phone. */
.legal .legal-rows li { margin-bottom: 1.1rem; }
.legal .legal-rows li > span { display: block; }
.legal .legal-rows em { font-style: normal; font-weight: 600; }
