/* ============================================================
   Yarra Gardens — shared site framework
   Rules below are byte-identical across all pages (extracted from
   the former per-page inline <style> blocks). Link this file in
   <head> BEFORE each page's small page-specific inline <style>.
   Source order is preserved — the cascade depends on it.
   ============================================================ */

/* ── FONTS ── self-hosted WOFF2. Two families, four weights + one italic.
   Fraunces ships as static instances because nothing on the site varies its
   weight: the two variable files (roman + italic, 300-700) cost 149 KB to
   render a face used at exactly 400. Montserrat ships variable because its
   single 300-500 file (35 KB) beats three statics (56 KB).
   unicode-range mirrors Google's `latin` subset, so a non-latin glyph falls
   through to the system stack rather than rendering tofu. */
@font-face {
  font-family: 'Fraunces';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/fraunces-v38-latin-regular.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
                 U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Fraunces';
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url('/assets/fonts/fraunces-v38-latin-italic.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
                 U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Montserrat';
  font-style: normal;
  font-weight: 300 500;
  font-display: swap;
  src: url('/assets/fonts/montserrat-v31-latin-wght.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
                 U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

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

/* Native smooth scroll (Lenis is gone — it registered non-passive wheel/touch
   listeners on window, which forced every scroll input through the main thread).
   Gated so it never overrides a reduced-motion preference. */
@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}
/* The header is fixed; anchor jumps must clear it. */
html { scroll-padding-top: calc(var(--header-h) + 16px); }

:root {
  color-scheme: light;
  --bg: #f2f0ec;
  --text: #1a1a1a;
  /* #888 measured 3.11:1 on the cream ground — an AA failure on ~40 pieces of
     10-12px text (labels, eyebrows, footer, form labels). #6b6b6b is 5.3:1 and
     still reads as a quiet grey. The old value survives as --line-strong for
     hairlines and SVG strokes, where contrast minimums don't apply. */
  --muted: #6b6b6b;
  --line-strong: #888;
  --placeholder: #8a8a8a;
  --border: #ccc;
  --error: #b34a4a;
  /* Refined forest green — used SPARINGLY (link/CTA hover, active nav
     underline, scroll-progress hairline). Never for body text or fills. */
  --accent: #3c4a3e;
  /* Deep near-black forest — backdrop for the ONE cinematic dark band per
     page. Cream (--bg) text sits on it at ~14:1, comfortably AA/AAA. */
  --ink: #1c2118;

  --font-display: 'Fraunces', Georgia, serif;
  --font-sans: 'Montserrat', sans-serif;

  /* 8-pt spacing tokens */
  --s-1: 8px;  --s-2: 16px; --s-3: 24px; --s-4: 32px;
  --s-5: 40px; --s-6: 48px; --s-8: 64px; --s-10: 80px;
  --s-12: 96px;

  /* ── Tweakable motion / texture knobs (safe to adjust on preview) ── */
  --grain-opacity: 0.04;     /* paper-grain overlay strength */
  --reveal-duration: 1.1s;   /* base scroll-reveal duration. Was 1.7s, which left
                                content at opacity:0 for most of two seconds after
                                it entered the viewport — the single biggest source
                                of *felt* slowness on the site. */
  --magnetic-max: 8px;       /* max magnetic-CTA pull toward cursor */
  --intro-duration: 0.8s;    /* intro veil total runtime. Was 1.4s, which held an
                                opaque cream screen over an already-decoded hero
                                (measured +640ms LCP on a first-of-session load). */
  --parallax-max: 0.10;      /* framed-image parallax travel, as a fraction of
                                wrap height — MUST stay <= the 0.10 slack the
                                .parallax img height:120%/top:-10% provides, or
                                the image edge would expose a gap */
  --marquee-duration: 75s;   /* whisper-ribbon loop time (one full -50% cycle) — slow + editorial */
  --marquee-opacity: 0.55;   /* quiet-ribbon micro-cap text strength */
  --cursor-size: 7px;        /* custom-cursor inner dot diameter */
  --cursor-ring: 34px;       /* custom-cursor trailing ring diameter */
  --reading-floor: 0.5;      /* B1 scroll-reading reveal: resting word opacity
                                (legible at rest; warms to 1 as the block scrolls
                                through). 0.5 cream on --ink stays AA. */
  --header-h: 84px;          /* LIVE header height, republished by a ResizeObserver
                                in site.js on every box change. Read by the pinned
                                scene's .pin-stage and by scroll-padding-top. It
                                used to update only on resize/load, so mid-scroll
                                it was stale by ~87px and the "full-bleed" pinned
                                photo floated below the header with a cream band
                                across the top. Default is a first-paint guess. */
}

body {
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-sans);
  font-weight: 300;
  font-size: 13px;
  line-height: 1.8;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
  /* Optical sizing on for Fraunces (opsz axis) across the page. */
  font-optical-sizing: auto;
  /* Horizontal-scroll guard for full-bleed sections. `clip` (NOT hidden)
     is sticky-SAFE — it does not create a scroll container, so the sticky
     header keeps working. NEVER use overflow:hidden or transform here. */
  overflow-x: clip;
  /* NO `opacity: 0` + fade-in on <body>. It made the entire site one un-run CSS
     animation away from a blank page, and it was already dead in every current
     browser (the @supports below cancels it wherever View Transitions exist,
     which is Chrome, Safari 18+ and Firefox 144+). Engines without View
     Transitions simply get an instant page, which is the safe failure mode. */
}

::selection { background: var(--accent); color: var(--bg); }
h1, h2, h3, blockquote { text-wrap: balance; }
p, li { text-wrap: pretty; }

/* ── PAGE TRANSITIONS ── (declarative cross-fade between same-origin
   navigations; NO JavaScript). Non-supporting browsers just navigate
   normally, with no entry animation. Reduced-motion neutralises this in the
   overrides block at the end of the file. */
@view-transition { navigation: auto; }
::view-transition-old(root),
::view-transition-new(root) {
  /* 0.5s held the outgoing page rendered long enough to read as an unresponsive
     click on a slow connection. */
  animation-duration: 0.25s;
  animation-timing-function: ease;
}

/* ── PAPER GRAIN ── (whisper-faint tiled noise; barely perceptible)
   Fixed full-viewport overlay, pointer-events:none so it never blocks clicks.
   The noise is an inline feTurbulence SVG data-URI (no network request).

   NO mix-blend-mode. A blended fixed layer over the whole viewport forces the
   entire page into a blended stacking context and repaints the full viewport on
   every scroll frame — measured as the worst frame going 166.6ms -> 83.4ms at
   20x CPU throttle once it was removed. At 4% opacity the plain alpha overlay
   is visually indistinguishable from the multiply version. */
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  opacity: var(--grain-opacity);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  background-size: 160px 160px;
}

/* ── INTRO VEIL ── (first load, once per session; see inline head guard +
   site.js cleanup). Default HIDDEN so no-JS / repeat visits never see it and
   content is never hidden. It is shown ONLY under html.intro, which the tiny
   inline head guard adds before paint on the first session — so it covers
   from frame 1 (no FOUC) and a CSS animation dismisses it (~--intro-duration,
   forwards, ending pointer-events:none + opacity:0). Reduced-motion is
   double-guarded: the inline guard won't add html.intro, and the @media
   block forces display:none. */
#intro-veil { display: none; }
html.intro #intro-veil {
  display: flex;
  position: fixed;
  inset: 0;
  z-index: 1000;
  background: var(--bg);
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 22px;
  /* in (cover) -> hold -> lift/out. Drives the whole veil's fade+rise out. */
  animation: introVeil var(--intro-duration) cubic-bezier(0.4, 0, 0.2, 1) forwards;
}
html.intro #intro-veil .intro-mark {
  width: clamp(46px, 7vw, 72px);
  height: auto;
  opacity: 0;
  transform: translateY(8px) scale(0.96);
  animation: introMark calc(var(--intro-duration) * 0.62) cubic-bezier(0.16, 0.84, 0.24, 1) 0.05s forwards;
}
html.intro #intro-veil .intro-word {
  font-family: var(--font-sans);
  font-weight: 400;
  font-size: clamp(11px, 1.6vw, 14px);
  letter-spacing: 0.42em;
  text-indent: 0.42em;       /* balance the trailing tracking */
  text-transform: uppercase;
  color: var(--text);
  opacity: 0;
  transform: translateY(6px);
  animation: introWord calc(var(--intro-duration) * 0.5) ease-out 0.22s forwards;
}
@keyframes introVeil {
  0%, 58%  { opacity: 1; transform: translateY(0); }
  100%     { opacity: 0; transform: translateY(-12px); pointer-events: none; }
}
@keyframes introMark {
  to { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes introWord {
  to { opacity: 1; transform: translateY(0); }
}
/* The veil lift IS the entry when it runs. */

/* ── DISPLAY HEADINGS — Fraunces refinements ──
   Optical sizing, ligatures + oldstyle figures, and a slight negative
   tracking so large Fraunces settings read as intended. Body stays
   Montserrat (untouched). The .site-title wordmark is deliberately
   EXCLUDED (it stays Montserrat — do not restyle the logotype). */
h1, h2, h3, blockquote {
  font-optical-sizing: auto;
  font-feature-settings: "liga" 1, "onum" 1;
  letter-spacing: -0.015em;
}

/* Reusable inline text link */
.text-link {
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--text);
  text-decoration: none;
  border-bottom: 1px solid var(--border);
  padding-bottom: 1px;
  transition: border-color 0.2s ease;
}
.text-link:hover { border-bottom-color: var(--accent); }

/* ── MAGNETIC CTA ── (primary buttons subtly pull toward the cursor).
   JS (site.js) owns the transform while pointing — it writes a translate
   capped at --magnetic-max that already folds in the -1px hover lift, so it
   never fights the page-level `:hover { transform: translateY(-1px) }`.
   On mouseleave JS clears the inline transform and this transition springs
   it back. Only bound when canHover && !reduced-motion; otherwise the
   element behaves exactly as before. The hover colour swap is a separate
   accent tint, applied without disturbing the page's own hover rule. */
.magnetic {
  transition: transform 0.3s cubic-bezier(0.2, 0.7, 0.2, 1),
              background-color 0.25s ease, color 0.25s ease,
              border-color 0.25s ease;
  will-change: transform;
}
.magnetic:hover { border-color: var(--accent); }

/* ── SKIP LINK ── (visually hidden until focused) */
.skip-link {
  position: absolute;
  top: 8px;
  left: 8px;
  z-index: 300;
  padding: 10px 16px;
  background: var(--text);
  color: var(--bg);
  font-family: var(--font-sans);
  font-size: 11px;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  text-decoration: none;
  border-radius: 2px;
  transform: translateY(-150%);
  opacity: 0;
  pointer-events: none;
  transition: transform 0.2s ease, opacity 0.2s ease;
}
.skip-link:focus {
  transform: translateY(0);
  opacity: 1;
  pointer-events: auto;
}

/* ── INTERACTIVE / FOCUS STATES ── */
*:focus { outline: none; }
*:focus-visible {
  outline: 2px solid var(--text);
  outline-offset: 3px;
  border-radius: 1px;
}

/* ── IMG WRAP — cursor-tracking 3D shadow ──
   Shadow offset is driven by --shadow-x / --shadow-y, set by JS
   on mousemove. Cursor on top-left → both negative → shadow falls
   to top-left, giving a directional "lit by cursor" feel. */
.img-wrap {
  --shadow-x: 0px;
  --shadow-y: 0px;
  --shadow-strength: 0;
  overflow: hidden;
  box-shadow:
    calc(var(--shadow-x) * 1.2) calc(var(--shadow-y) * 1.2) 44px -14px rgba(20, 22, 18, calc(0.26 * var(--shadow-strength))),
    calc(var(--shadow-x) * 0.5) calc(var(--shadow-y) * 0.5) 16px -6px  rgba(20, 22, 18, calc(0.16 * var(--shadow-strength)));
  transition: box-shadow 0.45s cubic-bezier(0.2, 0.7, 0.2, 1);
  /* No `will-change: box-shadow` — box-shadow is not a compositable property, so
     the hint promoted a layer for every photo on the site and bought nothing. */
}
/* ── PROGRESSIVE IMAGE LOAD ──
   Each wrap carries a ~24px blurred WebP of its own photo as an inline `--lqip`
   custom property (injected at bake time, ~150 bytes each). The box therefore
   shows a soft, colour-accurate blur the instant it is laid out, and the real
   photo cross-fades in over it once decoded — instead of snapping in from an
   empty rectangle, which is what made scrolling feel abrupt and unloaded.

   Gated on `.js`, like the reveal system: without JS nothing adds `.is-loaded`,
   so the image must start fully opaque. */
.img-wrap {
  background-image: var(--lqip);
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
.js .img-wrap picture img {
  opacity: 0;
  transition: opacity 0.7s ease;
}
.js .img-wrap picture img.is-loaded { opacity: 1; }

.img-wrap img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* The `filter: saturate(1.04)` that used to live here is now BAKED into the
     images by tools/optimize-images.mjs. A filter on ~30 photos forced each onto
     its own compositing layer and re-filtered on every repaint, for an adjustment
     that is not perceptible. Same pixels, no runtime cost. */
}
/* NOTE: there is deliberately no `.img-wrap:not(:hover)` rule here.
   `:not()` takes the specificity of its argument, so that selector scored (0,2,0)
   and beat BOTH `.reveal` and `.reveal-mask` (0,1,0) — meaning the winning
   transition on every photo was `box-shadow`, and the opacity fade had no
   transition at all. ~25 of the site's ~30 photos hard-cut into view instead of
   fading. The shadow already resets via `.img-wrap`'s own custom-property
   defaults when the cursor leaves, so the rule was never needed. */

/* ── SCROLL-REVEAL ── (granular: every photo + content block fades in)
   Driven by the SINGLE IntersectionObserver in site.js which toggles .in-view.

   ⚠ NO-JS GATE: the hidden resting state is scoped to `html.js`, a class set by
   the pre-paint inline guard in each page's <head>. Without that gate a failed,
   blocked or disabled site.js left every .reveal at opacity:0 forever — i.e. a
   blank page below the hero on all four pages. */
.js .reveal {
  opacity: 0;
  transform: translateY(40px);
  transition: opacity var(--reveal-duration) cubic-bezier(0.2, 0.7, 0.2, 1) var(--reveal-delay, 0s),
              transform var(--reveal-duration) cubic-bezier(0.2, 0.7, 0.2, 1) var(--reveal-delay, 0s);
}
.reveal.in-view { opacity: 1; transform: translateY(0); }

/* Compose the reveal with .img-wrap rather than losing to it (see the note
   above). Equal specificity to the old trap, declared later, so it wins. */
.img-wrap.reveal {
  transition: opacity var(--reveal-duration) cubic-bezier(0.2, 0.7, 0.2, 1) var(--reveal-delay, 0s),
              transform var(--reveal-duration) cubic-bezier(0.2, 0.7, 0.2, 1) var(--reveal-delay, 0s),
              box-shadow 0.45s cubic-bezier(0.2, 0.7, 0.2, 1);
}

/* ── REVEAL MODIFIER: MASK ── (CSS-only, used alongside .reveal on
   .img-wrap elements). The WRAP fades opacity 0->1; the inner IMG does a
   clip-path wipe + a subtle scale settle. clip-path is applied to the IMG,
   NEVER the wrap, so the wrap's cursor-shadow box-shadow isn't clipped. */
.reveal-mask {
  transform: none;            /* override the base translateY; the img moves */
  transition: opacity var(--reveal-duration) cubic-bezier(0.2, 0.7, 0.2, 1) var(--reveal-delay, 0s);
}
.img-wrap.reveal-mask { transform: none; }
.reveal-mask.in-view { transform: none; }
.js .reveal-mask img {
  clip-path: inset(0 0 100% 0);
  transform: scale(1.06);
  transition: clip-path 0.9s cubic-bezier(0.16, 0.84, 0.24, 1) var(--reveal-delay, 0s),
              transform 0.9s cubic-bezier(0.16, 0.84, 0.24, 1) var(--reveal-delay, 0s);
}
.reveal-mask.in-view img {
  clip-path: inset(0 0 0 0);
  transform: scale(1);
}

/* ── REVEAL MODIFIER: TEXT ── (CSS-only, used alongside .reveal on major
   headings / intro copy). Whole-element soft blur + rise + fade. No line or
   character splitting. */
/* The blur is kept — it is a deliberate part of the entrance and the resting
   state is unfiltered, so it costs one bounded 0.9s animation per heading rather
   than anything persistent. `will-change` is NOT declared: it was never released,
   so it held a permanent layer for an animation that runs exactly once. */
.js .reveal-text {
  opacity: 0;
  transform: translateY(18px);
  filter: blur(6px);
  transition: opacity 0.9s cubic-bezier(0.2, 0.7, 0.2, 1) var(--reveal-delay, 0s),
              transform 0.9s cubic-bezier(0.2, 0.7, 0.2, 1) var(--reveal-delay, 0s),
              filter 0.9s cubic-bezier(0.2, 0.7, 0.2, 1) var(--reveal-delay, 0s);
}
.reveal-text.in-view {
  opacity: 1;
  transform: translateY(0);
  filter: blur(0);
}

/* Stagger children when parent has .reveal-stagger */
.reveal-stagger > *:nth-child(1)  { --reveal-delay: 0s; }
.reveal-stagger > *:nth-child(2)  { --reveal-delay: 0.08s; }
.reveal-stagger > *:nth-child(3)  { --reveal-delay: 0.16s; }
.reveal-stagger > *:nth-child(4)  { --reveal-delay: 0.24s; }
.reveal-stagger > *:nth-child(5)  { --reveal-delay: 0.32s; }
.reveal-stagger > *:nth-child(6)  { --reveal-delay: 0.40s; }
.reveal-stagger > *:nth-child(7)  { --reveal-delay: 0.48s; }
.reveal-stagger > *:nth-child(8)  { --reveal-delay: 0.56s; }
.reveal-stagger > *:nth-child(9)  { --reveal-delay: 0.64s; }

/* ── SCROLL-PROGRESS HAIRLINE ── */
#scroll-progress {
  position: fixed;
  top: 0; left: 0;
  height: 3px;
  width: 100%;
  /* Driven by --scroll-p (0..1) from site.js. scaleX is compositor-only; the
     previous `style.width = pct + '%'` dirtied layout on every scroll frame,
     which is what made the geometry reads later in the same handler *forced*. */
  transform: scaleX(var(--scroll-p, 0));
  transform-origin: 0 50%;
  background: var(--accent);
  opacity: 0;
  z-index: 200;
  pointer-events: none;
  transition: opacity 0.3s ease;
  will-change: transform;
}
#scroll-progress.active { opacity: 0.6; }


/* ── SITE HEADER ── (proportional, scroll-driven shrink)
   --logo-progress is set in JS based on scroll position relative to the hero.
   0 = full size at top, 1 = compact once the hero has scrolled past.

   ⚠ THE HEADER IS `fixed`, NOT `sticky`, AND THIS IS LOAD-BEARING.
   It shrinks by animating padding + font-size — layout properties. While it was
   `sticky` it sat in flow, so every one of those frames dragged the whole document
   upward: measured scroll CLS 0.048-0.060 across the four pages, with ~19 shift
   events per scroll. Load CLS was already 0.000, so the header was the site's
   ENTIRE Core Web Vitals CLS problem. Taking it out of flow and reserving its
   maximum height on <body> keeps the shrink pixel-identical and takes scroll CLS
   to 0. --header-full must stay >= the expanded header height at each breakpoint
   (measured: 185px desktop, 98px mobile). */
:root { --logo-progress: 0; --header-full: 186px; }

#site-header {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 100;
  background: var(--bg);
  padding-left: var(--s-4);
  padding-right: var(--s-4);
  padding-top: calc(28px - 18px * var(--logo-progress));
  padding-bottom: calc(22px - 14px * var(--logo-progress));
  border-bottom: 1px solid rgba(204, 204, 204, calc(var(--logo-progress) * 0.6));
  box-shadow: 0 1px 0 rgba(0, 0, 0, calc(var(--logo-progress) * 0.03));
}

/* Reserve the header's full height so content never sits underneath it. */
body { padding-top: var(--header-full); }

.site-title {
  /* B3 SHARED-ELEMENT TRANSITION: the wordmark is byte-identical and in the
     same position on every page, so naming it lets the cross-document View
     Transition persist it rock-solid across navigations (no flash/morph).
     Appears on exactly ONE element per page. The header itself is deliberately
     NOT named (its per-page .active nav state would morph). */
  view-transition-name: site-wordmark;
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  width: 100%;
  font-family: var(--font-sans);
  font-weight: 400;
  /* Single clamp-based formula — sizes correctly at every viewport width,
     so no separate mobile font-size override is needed (avoids the old
     680px discontinuity). Endpoints expressed once as custom properties. */
  --title-full: clamp(18px, 3.2vw, 42px);
  --title-compact: clamp(11px, 1.5vw, 16px);
  font-size: calc(var(--title-full) - (var(--title-full) - var(--title-compact)) * var(--logo-progress));
  text-transform: uppercase;
  color: var(--text);
  text-decoration: none;
  user-select: none;
}
.site-title span { display: inline-block; }

.header-nav {
  display: flex;
  justify-content: space-between;
  width: 100%;
  margin-top: calc(14px - 8px * var(--logo-progress));
}
/* Nav links: AA-legible (#4a4a4a on cream) but still subordinate to the
   wordmark. ONE reusable animated underline (::after scaleX) replaces the old
   per-state border-bottom. Vertical padding keeps the tap target ~40px+. The
   visible :focus-visible outline comes from the shared *:focus-visible rule. */
.header-nav a {
  position: relative;
  font-size: 11.5px;
  font-weight: 500;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: #4a4a4a;
  text-decoration: none;
  padding: 12px 0;
  transition: color 0.2s;
}
.header-nav a::after {
  content: '';
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 1px;
  background: var(--accent);
  transform: scaleX(0);
  transform-origin: left;
  transition: transform 0.3s ease;
}
.header-nav a:hover::after,
.header-nav a:focus-visible::after,
.header-nav a.active::after { transform: scaleX(1); }
.header-nav a:hover { color: var(--text); }
.header-nav a.active { color: var(--text); }

/* ── HERO ── (standardized: 56vw / 660 max / 320 min across all pages) */
.hero {
  width: 100%;
  height: 56vw;
  max-height: 660px;
  min-height: 320px;
  overflow: hidden;
  /* Blurred placeholder so the viewport is never empty cream while a large hero
     streams. It is a background on the WRAPPER, not the <img>, so the LCP element
     is still the real photo — and a 24px blur scaled to full width sits far below
     the entropy threshold browsers use to exclude placeholder images from LCP
     candidacy, so it cannot flatter the metric either. */
  background-image: var(--lqip);
  background-size: cover;
  background-position: center;
  background-repeat: no-repeat;
}
.hero img {
  /* B3 SHARED-ELEMENT TRANSITION: every page's hero shares the same box, so
     naming the hero image cross-dissolves it between pages. One element/page. */
  view-transition-name: page-hero;
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* NO `opacity: 0` here. The hero is the LCP element on all four pages; painting
     it transparent for 1.8s meant that on a warm cache the browser disqualified it
     as an LCP candidate entirely and LCP landed on some later, arbitrary element
     (measured 2,068ms on the home page). The scale settle alone reads almost
     identically and does not affect LCP eligibility. */
  transform: scale(1.06);
  animation: heroEnter 1.2s cubic-bezier(0.16, 0.84, 0.24, 1) both;
  will-change: transform;
  /* CINEMATIC HERO (signature moment 3): a SUBTLE scroll parallax driven by
     object-position — NOT transform. Using object-position keeps the hero's
     own getBoundingClientRect() stable (the header --logo-progress math reads
     it) and never collides with the heroEnter scale keyframe above. JS nudges
     --hero-py between ~40% and ~60% as the hero scrolls; default 50% is
     perfectly centred (so reduced-motion / no-JS sees a normal centred crop). */
  object-position: center var(--hero-py, 50%);
}
@keyframes heroEnter {
  to { transform: scale(1); }
}

/* ============================================================
   SIGNATURE MOMENTS (additive — cream editorial base untouched)
   1 dark band · framed-image parallax · custom cursor ·
   kinetic marquee · botanical line-art dividers.
   Every motion here is reduced-motion-safe (see the single
   @media (prefers-reduced-motion: reduce) block above, which is
   extended for each of these).
   ============================================================ */

/* ── (1) CINEMATIC DARK BAND ── (full-bleed; ONE per page max)
   Deep forest --ink ground with cream text + a large Fraunces italic.
   Full-bleed via the SAME width:100vw + centring-margin escape the rest of
   the site uses; body's overflow-x:clip stops horizontal scroll and this is
   never an ancestor of #site-header (sticky stays safe). Static by nature —
   no internal motion, so nothing to gate for reduced motion. */
.dark-band {
  width: 100vw;
  margin-left: calc(50% - 50vw);
  background: var(--ink);
  color: var(--bg);
  padding: var(--s-12) var(--s-4);
  text-align: center;
}
.dark-band .dark-band-inner {
  max-width: 880px;
  margin: 0 auto;
}
.dark-band .dark-band-label {
  display: block;
  font-family: var(--font-sans);
  font-size: 12px;
  font-weight: 500;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  /* Cream dimmed to a soft sage — still well above AA on --ink. */
  color: rgba(242, 240, 236, 0.62);
  margin-bottom: var(--s-3);
}
.dark-band blockquote {
  font-family: var(--font-display);
  font-weight: 400;
  font-style: italic;
  font-size: clamp(26px, 3.6vw, 50px);
  line-height: 1.32;
  letter-spacing: -0.015em;
  color: var(--bg);
}

/* ── B1: SCROLL-READING REVEAL (About #statement only) ──
   At init, site.js wraps each word of the statement paragraphs in <span.w>
   (spaces kept as text nodes between them, so wrapping / copy-paste / reading
   order are unaffected). The RESTING opacity is --reading-floor (legible — the
   words are never invisible), and onScroll warms each word toward 1 as the
   block passes up through the viewport. The transition softens the per-frame
   opacity writes. JS is gated on !reduceMQ; reduced-motion (and no-JS) shows
   the words at the floor, and the @media block below forces them fully on. */
.w {
  opacity: var(--reading-floor, 0.5);
  transition: opacity 0.3s ease;
}

/* ── (2) FRAMED-IMAGE PARALLAX ── (applied to a FEW feature .img-wrap only)
   The image is sized to 120% height and pulled up 10% so it sits CENTERED by
   default — meaning with NO JS (reduced motion, Lenis absent, or before the
   first scroll frame) it shows a correct, non-cropped frame. JS (onScroll in
   site.js) writes translateY on the IMG only, capped to ±--parallax-max of
   the wrap height so the image edge never exposes a gap. The wrap keeps its
   overflow:hidden (from .img-wrap) so the 20% overscan is clipped. transform
   lives on the inner img; any .reveal on the wrap animates the wrap — different
   element, no conflict. (Never combine with .reveal-mask — clip-path + this
   transform interplay is avoided per the design review.) */
.parallax img {
  position: relative;
  height: 120%;
  top: -10%;
  will-change: transform;
}

/* ── (5) KINETIC MARQUEE RIBBON ── (CSS-only; ONE per page, above footer)
   overflow:hidden lives ONLY on .marquee (a leaf element — NOT on body / html
   / .page-wrap / any #site-header ancestor, so sticky is safe). The track holds
   the real services list TWICE; translateX(-50%) loops the first copy out as the
   clone arrives, giving a seamless cycle. Pauses on hover. */
.marquee {
  width: 100vw;
  margin-left: calc(50% - 50vw);
  overflow: hidden;
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
  padding: var(--s-3) 0;
  margin-top: var(--s-10);
}
.marquee-track {
  display: flex;
  flex-wrap: nowrap;
  white-space: nowrap;
  width: max-content;
  animation: marquee var(--marquee-duration, 75s) linear infinite;
}
.marquee:hover .marquee-track { animation-play-state: paused; }
/* Quiet editorial ribbon: small Montserrat micro-caps, widely tracked, in the
   muted tone at --marquee-opacity. Reads as a whisper, not a headline. */
.marquee-item {
  display: inline-flex;
  align-items: center;
  font-family: var(--font-sans);
  font-size: clamp(11px, 1.4vw, 13px);
  text-transform: uppercase;
  letter-spacing: 0.3em;
  font-weight: 500;
  color: var(--muted);
  padding: 0 var(--s-4);
}
.marquee-sep {
  display: inline-flex;
  align-items: center;
  width: clamp(7px, 0.9vw, 11px);
  height: clamp(7px, 0.9vw, 11px);
  margin: 0 var(--s-2);
  color: var(--accent);
  opacity: var(--marquee-opacity, 0.55);
  flex: none;
}
.marquee-sep svg { width: 100%; height: 100%; display: block; }
@keyframes marquee {
  from { transform: translateX(0); }
  to   { transform: translateX(-50%); }
}

/* ── (6) BOTANICAL LINE-ART DIVIDERS ── (draw-on-scroll; reuses the existing
   IntersectionObserver via .reveal → .in-view). Each <path> carries
   pathLength="1" so a dasharray/offset of 1 == the whole path; clearing the
   offset on .in-view draws it. Stroke-only, motif derived from the favicon
   leaf for brand cohesion. */
.svg-divider {
  display: block;
  width: 100%;
  max-width: 320px;
  margin: var(--s-10) auto;
  /* The wrapper is also tagged .reveal; neutralise the base translate so only
     the stroke draw reads (the wrapper still fades opacity 0→1). */
  transform: none;
}
.svg-divider svg {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}
.svg-divider path {
  fill: none;
  stroke: var(--line-strong);
  stroke-width: 1.4;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
}
.svg-divider.in-view path {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 1.4s ease;
}
/* Stagger the leaves so the branch draws first, then leaves fill in. */
.svg-divider.in-view path:nth-child(2) { transition-delay: 0.5s; }
.svg-divider.in-view path:nth-child(3) { transition-delay: 0.62s; }
.svg-divider.in-view path:nth-child(4) { transition-delay: 0.74s; }
.svg-divider.in-view path:nth-child(5) { transition-delay: 0.86s; }
/* Accent variant for the occasional emphasised divider. */
.svg-divider.accent path { stroke: var(--accent); }

/* ── (4) CUSTOM CURSOR ── (dot + trailing ring; built in site.js ONLY when
   canHover && !reduced-motion). The ring trails purely via a CSS transition —
   NO perpetual rAF. Both layers are fixed, pointer-events:none, aria-hidden.
   Native cursor is hidden by html.has-cursor (added in JS), but text fields
   keep their I-beam via the carve-out so forms stay usable. */
.cursor-dot,
.cursor-ring {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 9999;
  pointer-events: none;
  border-radius: 50%;
  opacity: 0;
  /* ADAPTIVE CURSOR: white layers + mix-blend-mode:difference invert against
     ANY backdrop (crisp on the dark band / dark photos AND on the cream),
     so the cursor never gets lost. NOTE: will-change is intentionally NOT set
     on these elements — a will-change:transform would form a stacking context
     and isolate the blend, breaking the inversion. JS still drives transform
     inline (no perpetual rAF), which the compositor handles fine without it. */
  mix-blend-mode: difference;
  /* Centre the layer on the pointer; JS sets translate() before this. */
  transition: opacity 0.25s ease;
}
.cursor-dot {
  width: var(--cursor-size);
  height: var(--cursor-size);
  margin: calc(var(--cursor-size) / -2) 0 0 calc(var(--cursor-size) / -2);
  background: #fff;
}
.cursor-ring {
  width: var(--cursor-ring);
  height: var(--cursor-ring);
  margin: calc(var(--cursor-ring) / -2) 0 0 calc(var(--cursor-ring) / -2);
  border: 1px solid #fff;
  /* The trail: ring eases toward the dot's position. */
  transition: transform 0.16s ease-out, opacity 0.25s ease,
              width 0.2s ease, height 0.2s ease,
              margin 0.2s ease, border-width 0.2s ease;
}
.cursor-dot.cursor-visible,
.cursor-ring.cursor-visible { opacity: 1; }
/* Hover-grow over interactive / image targets — ring swells (and thickens
   slightly). NO accent tint/background here: a fill or colour swap is
   incompatible with mix-blend-mode:difference, so the hover state is the
   size grow + a marginally heavier border only. */
.cursor-ring.cursor-hover {
  width: calc(var(--cursor-ring) * 1.5);
  height: calc(var(--cursor-ring) * 1.5);
  margin: calc(var(--cursor-ring) * -0.75) 0 0 calc(var(--cursor-ring) * -0.75);
  border-width: 1.5px;
}
/* Hide the native cursor only once JS has built the custom one. Carve out text
   fields so the I-beam survives (forms stay legible/usable). */
html.has-cursor { cursor: none; }
html.has-cursor a,
html.has-cursor button { cursor: none; }
html.has-cursor input,
html.has-cursor textarea,
html.has-cursor select,
html.has-cursor [contenteditable] { cursor: text; }

/* ── OUTER WRAPPER ── */
.page-wrap { padding: 0 var(--s-4); }

/* ── FOOTER ── */
footer {
  border-top: 1px solid var(--border);
  margin: 0 var(--s-4);
  padding: var(--s-3) 0 var(--s-5);
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  flex-wrap: wrap;
  gap: var(--s-3);
}
footer .footer-left {
  font-size: 10px;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--muted);
}
footer .footer-right {
  font-size: 10px;
  letter-spacing: 0.15em;
  text-transform: uppercase;
  color: var(--muted);
  text-align: right;
}
footer .footer-right a { color: var(--muted); text-decoration: none; }
footer .footer-right a:hover { color: var(--text); }

/* ── MOBILE (shared header / page-wrap / footer rules) ── */
@media (max-width: 680px) {
  /* Matches the smaller expanded header below — keep these two in step. */
  :root { --header-full: 100px; }
  #site-header {
    padding-left: 20px;
    padding-right: 20px;
    padding-top: calc(16px - 8px * var(--logo-progress));
    padding-bottom: calc(12px - 6px * var(--logo-progress));
  }
  .header-nav { margin-top: calc(8px - 4px * var(--logo-progress)); }
  .header-nav a { font-size: 11px; letter-spacing: 0.16em; }
  .page-wrap { padding: 0 20px; }
  footer { margin: 0 20px; }

  footer { flex-direction: column; }
  footer .footer-right { text-align: left; }
}

/* ════════════════════════════════════════════════════════════════════════
   ACCESSIBILITY / OUTPUT OVERRIDES — MUST STAY LAST IN THIS FILE.
   Nothing may be declared below this banner.

   The reduced-motion block used to sit around rule 49 of 103, so every rule
   declared after it won on source order at equal specificity: the marquee never
   stopped, the botanical dividers still drew over 1.4s, and .parallax img kept a
   permanent compositing layer. Moving the block to the end fixes all three and
   let four of the five !important declarations go.
   ════════════════════════════════════════════════════════════════════════ */

@media (prefers-reduced-motion: reduce) {
  .reveal, .js .reveal { opacity: 1; transform: none; transition: none; }
  /* All reveal variants show instantly — clear clip/blur/transform. */
  .reveal-mask, .js .reveal-text { opacity: 1; transform: none; filter: none; transition: none; }
  .reveal-mask img, .js .reveal-mask img { clip-path: none; transform: none; transition: none; }
  .img-wrap, .img-wrap img { transition: none; }
  .js .img-wrap picture img { opacity: 1; transition: none; }
  .hero img {
    animation: none; opacity: 1; transform: none;
  }
  /* Keep the scroll-driven header from continuously morphing for
     reduced-motion users (JS snaps --logo-progress to 0/1). */
  /* `#site-header *` does not match pseudo-elements, so the nav underline
     needed naming explicitly. */
  #site-header, #site-header *, .header-nav a::after { transition: none; }
  .skip-link { transition: opacity 0.2s ease; }
  .btn, .magnetic { transition: background-color 0.25s ease, color 0.25s ease; }
  /* Disable the declarative page cross-fade. */
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) { animation: none !important; }
  /* Intro veil is already skipped by the inline guard (it won't add
     html.intro), but belt-and-suspenders in case the class is present. */
  #intro-veil { display: none; }

  /* ── SIGNATURE MOMENTS — reduced-motion neutralisation ──
     (1) dark band is static by nature — nothing to disable.
     (2) parallax: JS is gated on !reduceMQ so it never writes a transform;
         clear any inherited will-change and force transform:none belt-and-
         suspenders. The img keeps height:120%/top:-10% so it stays CENTRED. */
  .parallax img { transform: none; will-change: auto; }
  /* (3) hero object-position parallax: JS is gated, so --hero-py stays 50%
         (centred). No rule needed; the default token handles it.
     (4) custom cursor is never CREATED under reduced motion (JS gate) — no
         html.has-cursor, no nodes — so no CSS override is required.
     (5) marquee: stop the loop and show the first (readable) copy static. */
  .marquee-track { animation: none; transform: none; }
  /* (6) botanical dividers: show fully drawn, no stroke transition. */
  .svg-divider path { stroke-dashoffset: 0; transition: none; }
  /* B1 scroll-reading reveal: the JS warm-up is gated off, so show every word
     fully (overriding the resting floor) — the statement reads as static. */
  .w { opacity: 1; transition: none; }
}

/* ── FORCED COLOURS ── (Windows high-contrast) */
@media (forced-colors: active) {
  .svg-divider path { stroke: CanvasText; }
  #scroll-progress { background: Highlight; }
  .img-wrap { box-shadow: none; }
}

/* ── PRINT ──
   Without this, printing any page produced a near-blank sheet: every .reveal is
   at opacity:0 until scrolled past, and the print snapshot does not scroll. The
   contact page printed with no contact details and no form. */
@media print {
  #intro-veil, #scroll-progress, .marquee, .svg-divider,
  .cursor-dot, .cursor-ring { display: none !important; }
  body::after { display: none !important; }
  .js .img-wrap picture img { opacity: 1 !important; transition: none !important; }
  .img-wrap { background-image: none !important; }
  .reveal, .js .reveal,
  .reveal-mask, .js .reveal-mask img,
  .reveal-text, .js .reveal-text,
  .w {
    opacity: 1 !important;
    transform: none !important;
    filter: none !important;
    clip-path: none !important;
    transition: none !important;
    animation: none !important;
  }
  body { background: #fff; color: #000; padding-top: 0; }
  #site-header { position: static; }
  .hero { height: auto; }
  a[href^="http"]::after { content: " (" attr(href) ")"; font-size: 10px; }
  .img-wrap { box-shadow: none; break-inside: avoid; }
}
