/* fern Studio marketing site — revision 4.

   Voice and stage. Black acts are fern's voice: type only, one accent, text
   arrives by typing or by blur resolving. White/cream/photo acts are the
   stage: the work shows. Hard cuts between them, opaque section grounds, no
   drift.

   Nothing pins (rev 8b): every act is data-sc-act="flow", so scrolling never
   holds. Each act's own .stage sets min-height:100svh (never 100vh) and
   position:relative directly, since the engine's sticky .sc-stage class only
   ever applies to a pinned act. Reveals key off the act's own --sc-p, which
   sweeps 0..1 across its visible life (entering, filling the screen,
   leaving) -- see BRIEF.md, Rev 8b.

   Nothing here restyles a [data-sc-*] selector except the documented engine
   overrides in the "wiring" block near the bottom. Layout properties only
   on .stage (flex, gap, padding, align, min-height, position) -- never
   position:sticky or a fixed height, which would fight the engine's own
   handling of a genuinely pinned act if one is ever added back. */

/* ------------------------------------------------------------ @font-face --
   Self-hosted latin-subset WOFF2 so font-src 'self' holds under the CSP.
   Only two DM Sans weights are self-hosted (400, 500) -- there is no 700
   file. The hero headline below is set at weight 500 rather than a
   synthetic/fake bold, which self-hosted subsets can render inconsistently
   across engines; size and tracking carry the display weight instead. */
@font-face {
  font-family: "Comfortaa";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("assets/fonts/comfortaa-latin-500.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* fern Mark: the wordmark face for .mark, built from Comfortaa's own
   outlines (scripts/build-wordmark-font.py; provenance + per-letter notes
   in assets/fonts/FACES.md). Already a bespoke subset -- the ten letters
   of "fern Studio" plus space, nbsp (.mk-sp uses &nbsp;) and dotlessi --
   so no unicode-range restricts it further. */
@font-face {
  font-family: "fern Mark";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("assets/fonts/fern-mark-500.woff2") format("woff2");
}
@font-face {
  font-family: "Instrument Serif";
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url("assets/fonts/instrument-serif-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+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "DM Mono";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("assets/fonts/dm-mono-latin-400.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "DM Sans";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("assets/fonts/dm-sans-latin-400.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "DM Sans";
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url("assets/fonts/dm-sans-latin-500.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: "DM Sans";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("assets/fonts/dm-sans-latin-700.woff2") format("woff2");
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ------------------------------------------------------------------ tokens --
   Extends the engine's six roles; nothing here re-declares them from
   scratch. */
:root {
  --sc-canvas:      #000000;
  --sc-surface:     #0E0E0E;
  --sc-ink:         #F4F1EC;
  --sc-ink-soft:    #A3A099;
  --sc-accent:      #D97757;
  --sc-accent-ink:  #140B06;

  /* Small orange text on a white ground steps down to this; #D97757 is only
     3.1:1 on white, which clears the 3:1 a graphic needs and misses the 4.5:1
     text needs. Display-size orange on white is fine as-is. */
  --accent-deep:  #B5573A;

  --sc-hairline: rgba(244, 241, 236, .16);
  --sc-hairline-strong: rgba(244, 241, 236, .26);

  --sc-font-display: "DM Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
  --sc-font-text:    "DM Sans", ui-sans-serif, system-ui, -apple-system, sans-serif;
  --font-mark:   "fern Mark", "Comfortaa", ui-rounded, system-ui, sans-serif;
  --font-accent: "Instrument Serif", Georgia, serif;
  --font-mono:   "DM Mono", ui-monospace, "SFMono-Regular", Menlo, monospace;

  /* The hero sentence is DM Sans, behind one token so flipping it to the
     display face is a one-line change if that is ever wanted. */
  --font-hero: var(--sc-font-text);

  --gut: clamp(1.25rem, 6vw, 5.5rem);
  --sc-gutter: var(--gut);
  --sc-maxw: 74rem;

  /* Hero timing, one place to retune. Johannes tunes the feel here without
     touching site.js. The typed line is no longer uniform: each character's
     delay is charMs multiplied by a deterministic pseudo-random factor
     (0.5-1.8, seeded LCG in site.js -- never Math.random, so every load
     types identically), plus --t-char-space after a space and --t-char-beat
     once, right before the accent word. Total measured 2.76s for "You're
     losing clients" at these values -- inside "1 to 3 seconds, I will
     judge". */
  --t-letter: 90ms;
  --t-wipe: 480ms;
  --t-type-start: 1.3s;
  --t-char: 105ms;
  --t-char-space: 52ms;
  --t-char-beat: 180ms;
  --t-caret: 530ms;
  /* The rolling word (exposure/clients/revenue) is revealed, and starts
     rolling, off the real moment the last typed character lands -- computed
     in site.js from the timings above, not a guessed flat delay. This is
     only the fade/blur-resolve duration of that reveal. */
  --t-rot-reveal: 650ms;
  /* The pause BEFORE that reveal starts -- between the last character
     landing and .is-typed/rotator() firing. 2x --t-rot-reveal: the fade
     alone read as too short a gap for the visitor to register the type
     finishing before "exposure" arrives. */
  --t-rot-delay: 650ms;
  /* The world act's point cloud spin: seconds per revolution, wall-clock,
     never scroll (site.js reads this with cssMs). */
  --t-world-rev: 180s;
}

/* Redefining --sc-ink on a subtree does not re-ink inherited text: restate
   `color`. Used for the white/cream acts. */
.on-white {
  --sc-ink: #0B0C0E;
  --sc-ink-soft: #55595F;
  --sc-accent: var(--accent-deep);
  --sc-hairline: rgba(11, 12, 14, .12);
  --sc-hairline-strong: rgba(11, 12, 14, .22);
  color: var(--sc-ink);
  background: #FFFFFF;
}
/* The minimal-demo ground: warm beige, calmer than pure white, still needs
   the same text-role restatement. #EDE4D3, warmer than the old #F4F1EC. */
.on-cream {
  --sc-ink: #14120D;
  --sc-ink-soft: #5B5648;
  --sc-accent: var(--accent-deep);
  --sc-hairline: rgba(20, 18, 13, .12);
  --sc-hairline-strong: rgba(20, 18, 13, .22);
  color: var(--sc-ink);
  background: #EDE4D3;
}

/* ------------------------------------------------------------------- base --
   The engine's own body rule sets `overflow-x: clip`, which is silently
   coerced on real phones and clips shadows that render fine on desktop.
   Same specificity, later in the cascade: this wins. */
body { overflow-x: hidden; background: var(--sc-canvas); color: var(--sc-ink); font-family: var(--sc-font-text); }
img { display: block; max-width: 100%; }
a { color: inherit; }

.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}
.skip-link {
  position: absolute; z-index: 100;
  left: var(--gut); top: -3rem;
  background: var(--sc-accent); color: var(--sc-accent-ink);
  padding: .7rem 1.1rem; border-radius: var(--sc-r-md, 10px);
  text-decoration: none; font-weight: 600; font-size: .92rem;
  transition: top 160ms var(--sc-ease-out);
}
.skip-link:focus-visible { top: 1rem; }

.mark { font-family: var(--font-mark); font-weight: 500; margin: 0; }
.script { font-family: var(--font-accent); font-style: italic; font-weight: 400; }
.script.accent { color: var(--sc-accent); }

/* An icon slot: an inline <svg fill="currentColor">. */
.ico { display: inline-flex; width: 1.15em; height: 1.15em; align-items: center; justify-content: center; flex: none; }
.ico svg { width: 100%; height: 100%; display: block; }

/* The small ruled grey label at the top of a stage (ref-3.png): a short
   rule, then small uppercase letterspaced text. One class, three uses --
   transparency, offer and pricing are all flow now (rev 8b). Real <h2>
   underneath; only the styling changes. --sc-ink-soft on black measures
   ~8:1, well over the 4.5:1 a label needs. */
.section-label {
  display: flex;
  align-items: center;
  gap: .75rem;
  font-family: var(--font-mono);
  font-size: clamp(1.05rem, 3vw, 1.5rem);
  letter-spacing: .14em;
  text-transform: uppercase;
  color: #fff;
  margin: 0;
  flex: none;
  opacity: 1;
}
.section-label::before {
  content: "";
  display: block;
  width: clamp(2rem, 5vw, 3.25rem);
  height: 1px;
  background: var(--sc-hairline-strong);
  flex: none;
}
/* Sits at the top of the stage, content flowing below it in the same
   column. Only #honest uses this class (rev 8b: flow, not pinned -- see
   .act-honest .stage below for its own min-height/position). Flex/gap/align
   only here. */
.stage.stage-labeled { justify-content: flex-start; gap: clamp(2rem, 7vh, 4rem); padding-block: clamp(2rem, 6vh, 3.5rem); }
.stage-body { flex: 1; display: flex; flex-direction: column; justify-content: center; min-height: 0; width: 100%; }

/* ----------------------------------------------- no-JS / failed-JS states --
   Every bespoke hidden state below is scoped under html.js. This one rule,
   after scrollcraft.css so specificity wins, covers the engine's own hidden
   defaults for anyone who never got `js`: cues, in/stagger children render
   at rest instead of staying invisible forever. */
html:not(.js) [data-sc-cue],
html:not(.js) [data-sc-in],
html:not(.js) [data-sc-stagger] > * {
  opacity: 1; transform: none; filter: none; transition: none;
}
html:not(.js) .navtoggle { display: none; }
/* min-height:auto overrides every per-act .stage rule's min-height:100svh
   (site.css, further down) -- without it a no-JS visitor gets eight empty
   full-screen boxes before any content, since those rules apply regardless
   of html.js. */
html:not(.js) .stage[data-sc-stage] { position: static; height: auto; min-height: auto; padding-block: clamp(4rem, 14vh, 8rem); }
html:not(.js) .act-honest .tw,
html:not(.js) .honest-close { opacity: 1; filter: none; }
html:not(.js) .tc { opacity: 1 !important; }
html:not(.js) .tw-caret, html:not(.js) .caret { display: none; }
html:not(.js) .world-canvas { display: none; }
html:not(.js) .morph-b { clip-path: none; }
html:not(.js) .morph-seam { display: none; }

/* ------------------------------------------------------------------- nav --
   A folding remote: a glass pill with four links and a circular toggle,
   right-anchored so the collapsed state sits at the right edge on every
   ground the page cuts to. */
.bar {
  position: fixed;
  z-index: var(--sc-z-chrome, 60);
  top: calc(12px + env(safe-area-inset-top));
  right: var(--gut);
  left: auto;
  transform: translateX(calc(var(--gut) + 50% - 50vw));
  transition: transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
.bar.is-closed { transform: translateX(0); }
/* Liquid glass: tint, rim, blur and inset highlight for the nav pill. The
   pricing act's own glass box (.estimator) uses the same recipe at its own
   rule below -- kept as a second copy, not merged into this selector,
   because a backdrop-filter nested inside an already-blurred box costs
   real frame time on mid-range Android for no visible gain; the chips and
   segmented tracks inside that box use plain translucent fills instead. */
.pill-nav {
  border-radius: 999px;
  /* The bar crosses hard cuts between black, white and beige acts, so the
     tint has to carry white links over the LIGHTEST of them. Computed
     against pure white: .28 gave 2.1:1 and .55 only 4.25:1, both short of
     the 4.5:1 the links need at this size. .62 lands at 5.4:1 on white and
     better on beige, and still lets 38 percent of the blurred backdrop
     through, which is what reads as glass. */
  background: rgba(16, 16, 16, .62);
  /* Two declarations, not four: a clip-path on a backdrop-filtered element
     (its own composited layer) gets a non-antialiased 1-bit clip mask, which
     stair-stepped the curve against the antialiased border-radius underneath
     it. The nav's own fix is to never clip that element at all (collapse it
     by animating width with overflow:hidden instead, below) -- which also
     means the rim only needs one border and one highlight, not four hard
     1px lines landing on different fractional rows at non-integer DPR. */
  border: 1px solid rgba(255, 255, 255, .20);
  backdrop-filter: blur(28px) saturate(180%);
  -webkit-backdrop-filter: blur(28px) saturate(180%);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .18), 0 10px 30px -12px rgba(0, 0, 0, .55);
}
.pill-nav {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: clamp(1.1rem, 2.5vw, 1.5rem);
  height: 44px;
  width: var(--nav-w, auto);
  padding: 0 0 0 1.1rem;
  overflow: hidden;
  position: relative;
  color: #fff;
  transition: width 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
/* Liquid-glass thumb: sits behind whichever link matches the section
   currently under the viewport's middle band (site.js's IntersectionObserver
   in bootNav()). Right-anchored, not left: .pill-nav is justify-content:
   flex-end and collapses by shrinking its own width, so every link's
   distance from the pill's RIGHT edge stays constant at any width, open or
   mid-collapse -- a left offset would need recalculating on every collapse
   frame instead of just riding along with `right:0`. --thumb-r/--thumb-w are
   written by site.js when the active link changes or its own geometry is
   re-measured (resize, fonts.ready) -- never per frame.
   Faked glass, not a second backdrop-filter: see the comment above
   .pill-nav -- a blurred layer nested inside an already-blurred box costs
   real frame time on mid-range Android for no visible gain, so this is a
   plain translucent gradient fill, rim and inset highlights instead. */
.nav-thumb {
  position: absolute;
  /* Full height of the bar, and (site.js) grown either side of its link by
     the pill's own left padding, so on Work it meets the pill's left edge.
     The link gaps are floored at that same padding so the wider thumb
     never runs under a neighbouring label. */
  top: 0; bottom: 0; right: 0;
  width: var(--thumb-w, 0px);
  transform: translateX(calc(-1 * var(--thumb-r, 0px)));
  border-radius: 999px;
  pointer-events: none;
  z-index: 0;
  /* Kept faint: white link text over the .62 pill tint on a WHITE act has
     only ~.08 of white wash to spare at the text's centre before it drops
     under 4.5:1 (.22 gave ~3.9:1). The rim and highlights carry the glass. */
  background: linear-gradient(180deg, rgba(255, 255, 255, .12), rgba(255, 255, 255, .04));
  border: 1px solid rgba(255, 255, 255, .32);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, .45),
    inset 0 -1px 1px rgba(255, 255, 255, .10),
    0 2px 8px -2px rgba(0, 0, 0, .35);
  /* Hidden by default -- hero and refine never get a .has-active, and a
     hard reload before site.js has measured anything must not flash a
     thumb at r:0/w:0 (which would sit on top of Work). */
  opacity: 0;
  transition: transform 480ms cubic-bezier(0.16, 1, 0.3, 1),
              width 480ms cubic-bezier(0.16, 1, 0.3, 1),
              opacity 240ms var(--sc-ease-out);
}
.pill-nav.has-active .nav-thumb { opacity: 1; }
.bar.is-closed .nav-thumb { opacity: 0; }
.navlink {
  font-size: .88rem; font-weight: 500;
  text-decoration: none; color: #fff;
  white-space: nowrap;
  flex-shrink: 0;
  padding: .3rem .1rem;
  opacity: 1;
  transition: opacity 320ms var(--sc-ease-out) 180ms, visibility 0s linear 0s;
  /* Above .nav-thumb (z-index:0) so the label reads over the glass. */
  position: relative;
  z-index: 1;
}
.navlink:hover { opacity: .78; }
/* 44x44: while the pill is collapsed this is the only tappable control on
   the whole page, and it has to clear the touch-target floor. */
.navtoggle {
  flex: none;
  width: 44px; height: 44px;
  display: grid; place-items: center;
  border: 0; border-radius: 999px;
  background: transparent; color: #fff;
  cursor: pointer;
}
.navtoggle .chev { width: 16px; height: 16px; transform: rotate(180deg); transition: transform 480ms cubic-bezier(0.16, 1, 0.3, 1); }
.bar.is-closed .pill-nav { width: 44px; }
.bar.is-closed .navlink { opacity: 0; visibility: hidden; transition: opacity 200ms var(--sc-ease-out), visibility 0s linear 380ms; }
.bar.is-closed .navtoggle .chev { transform: rotate(0deg); }

/* Rev 8, scrub mode: while the visitor has not touched the chevron by hand,
   site.js writes --nav-k (0 open/centred, 1 collapsed at the right edge)
   onto .bar every rAF tick, eased off the hero's own --sc-p -- so the nav
   moves WITH the wheel instead of snapping on a 40px threshold after a
   560ms transition. No transitions on anything below: JS is already
   driving every frame, and a transition racing a same-property JS write
   is what causes stutter. The first chevron click removes .is-scrub and
   hands the bar back to the transitioned .is-closed rules above (see the
   click handler in site.js). --nav-k defaults to 0 (open) if a frame ever
   reads before the first write. */
.bar.is-scrub {
  transition: none;
  transform: translateX(calc((1 - var(--nav-k, 0)) * (var(--gut) + 50% - 50vw)));
}
.bar.is-scrub .pill-nav {
  transition: none;
  width: calc(44px + (1 - var(--nav-k, 0)) * (var(--nav-w, 44px) - 44px));
}
.bar.is-scrub .navlink {
  transition: none;
  opacity: clamp(0, calc(1 - var(--nav-k, 0) / 0.4), 1);
}
/* Same fade, gated on .has-active: without that guard this would outrank
   .nav-thumb's own opacity:0 default (three classes beat one) and show a
   ghost thumb mid-scrub over the hero, where no link is active. */
.bar.is-scrub .pill-nav.has-active .nav-thumb {
  transition: none;
  opacity: clamp(0, calc(1 - var(--nav-k, 0) / 0.4), 1);
}
/* Belt-and-braces past the halfway point: opacity is already 0 by k=0.4,
   this just stops a fully invisible link from still being tappable or
   landing in tab order while the pill keeps collapsing. Toggled by
   site.js, not derived in CSS -- a plain custom-property comparison like
   "--nav-k > 0.5" has no CSS equivalent. */
.bar.is-scrub.is-scrub-hidden .navlink { visibility: hidden; }
.bar.is-scrub .navtoggle .chev {
  transition: none;
  transform: rotate(calc((1 - var(--nav-k, 0)) * 180deg));
}

/* Jank rule: the real-phone check failed on 2026-09-15 -- scrolling past
   #pricing jittered, with this pill's blur sliding over the estimator's own
   blur over a full-bleed photo. Blur off on phones, tint raised instead. */
@media (max-width: 860px) {
  .pill-nav { backdrop-filter: none; -webkit-backdrop-filter: none; background: rgba(14, 14, 14, .88); }
}
@supports not (backdrop-filter: blur(1px)) {
  .pill-nav { background: rgba(14, 14, 14, .92); }
}

/* The four-link pill at desktop tracking/gaps is wider than a 390px screen
   has room for, right-anchored or not. Width-keyed, not a phone fallback:
   tighter type and gaps so the same four links fit before it ever needs to
   collapse. */
@media (max-width: 520px) {
  /* Nav sits a touch lower and a touch wider on phones only. Measured with
     a real headless Chromium render (not by eye): open pill is 274.4px at
     390/360/320px viewports (font and gap are fixed-rem here, so width
     doesn't move with the viewport across those three) -- centred with
     ~58px margins at 390, ~43px at 360, ~23px at 320, all comfortably clear
     of the edge. See the .hero-wordmark collision comment below for the
     paired 8px top offset. */
  .bar { top: calc(20px + env(safe-area-inset-top)); }
  .pill-nav { gap: 1rem; padding: 0 6px 0 1rem; }
  .navlink { font-size: .74rem; }
}

html { scroll-padding-top: calc(80px + env(safe-area-inset-top)); }

/* ---------------------------------------------------------------- layout --
   Shared shell for every act. Grounds are opaque and painted per section, no
   drift, so a hard cut always lands on a real edge. */
.act { position: relative; }
.wrap { width: 100%; max-width: var(--sc-maxw); margin-inline: auto; padding-inline: var(--gut); }
/* Layout only here -- no position or height on the shared selector, since a
   future pinned act's [data-sc-stage] still needs the engine's own sticky
   box (position:sticky; height:100svh) with nothing overriding it at this
   specificity (that was rev 1's blocker). Every act on THIS page is flow,
   so each one sets its own position:relative + min-height:100svh directly
   on its own .stage rule below instead -- scoped per act, not shared, so it
   never collides with a pinned act's own sizing if one comes back. */
.stage { display: flex; flex-direction: column; justify-content: center; padding-inline: var(--gut); }
.stage-center { align-items: center; text-align: center; }

/* ---------------------------------------------------------------- 1 hero --
   Time-driven, the only non-scroll act: the machine speaks first. Grounded
   on a photograph now, same three-layer recipe as #contact's close screen
   (base colour, cover picture, scrim) -- isolation:isolate keeps the
   picture's and scrim's negative z-indexes confined to this act, rather
   than bleeding through to whatever paints behind it. */
.act-hero { background: var(--sc-canvas); isolation: isolate; }
/* The picture and scrim live INSIDE .stage, not as siblings of it: .stage
   is the positioned box the headline and wordmark sit in too (rev 8b:
   position:relative + min-height:100svh set directly on .act-hero .stage
   below, since the act is flow, not the engine's sticky box). Nesting the
   picture inside keeps it scrolling together with the headline as one
   unit -- a sibling of .stage on the outer .act-hero box would still work
   today (.act is also position:relative) but would stop being anchored to
   the right box the moment .stage ever grew taller than its content for
   any reason. No-JS/failed-JS: a plain flow image, 16:9, ahead of the
   wordmark and headline -- not full-bleed, but readable, which is all a
   no-JS visitor needs. */
.hero-bg { position: relative; width: 100%; aspect-ratio: 16 / 9; margin: 0 0 1.5rem; background: #05070A; }
.hero-bg img, .hero-bg picture { width: 100%; height: 100%; object-fit: cover; display: block; object-position: 50% 50%; }
/* The wide frame's sun sits upper-LEFT and throws its brightest shaft down
   and right, far enough to reach the centred rolling word: measured at
   1440x900, only 88.7% of that word's glyph box cleared 4.5:1 on a centred
   crop. 16:9 source into a 16:9-ish viewport leaves horizontal slack, so
   biasing the crop right walks the shaft off the word without darkening
   anything -- the photograph stays as bright as it was generated. The
   portrait source below 861px needs none of this (99.9% clearing centred),
   and its composition is built around a centred subject, so it is excluded
   rather than inheriting the bias. */
@media (min-width: 861px) {
  .hero-bg img { object-position: 66% 50%; }
}
html.js .hero-bg { position: absolute; inset: 0; aspect-ratio: auto; z-index: -2; margin: 0; }
/* Aimed at the UPPER band, not the middle band the close screen uses: the
   headline sits ~30% down and the rolling word directly under it.
   Deliberately LIGHT. The first pass ran .74-.82 through this band, which
   cleared 4.5:1 by crushing the photograph to near-black -- passing the
   contrast check by destroying the thing it was protecting, and exactly the
   full-frame dark overlay the house rules forbid. The replacement frames put
   the sun in the upper LEFT and hold dark canopy across the upper centre, so
   white type already measures 11.3:1 (wide) and 14.5:1 (portrait) on the raw
   image with no scrim at all. What is left here is a safety net for the ray
   shafts crossing the left of frame, not a blackout: it lifts the worst
   pixels without flattening the ferns. */
html.js .act-hero .stage::after {
  content: "";
  position: absolute; inset: 0;
  z-index: -1;
  background: linear-gradient(180deg,
    rgba(0, 0, 0, .30) 0%,
    rgba(0, 0, 0, .36) 18%,
    rgba(0, 0, 0, .38) 34%,
    rgba(0, 0, 0, .26) 52%,
    rgba(0, 0, 0, .10) 70%,
    rgba(0, 0, 0, 0) 88%);
}
/* Centred now, not left-anchored: with only the headline and the rolling
   word left in this column (the wordmark moved to its own top-left slot,
   "And it's not your fault" deleted), a centred composition reads as the
   single idea it is. Sits ~30% down via padding-top rather than the
   stage's usual vertical centring, which is a --gut-scale top offset
   against the stage's own 100svh box, not a flat vh guess: it holds up
   across the viewport heights this was checked against. */
.act-hero .stage {
  position: relative;
  min-height: 100svh;
  justify-content: flex-start;
  align-items: center;
  text-align: center;
  padding-top: 30vh;
}
/* Flow, not pinned (rev 8b): the stage sets its own min-height/position now
   (above) instead of inheriting the engine's sticky box, and already
   carries the gutter and the vertical centring, so the hero block only sets
   its own column width. 64rem, not the old 34rem: that width was sized for
   the previous two-line headline, which had an explicit <br> forcing the
   break regardless of column width. "You're losing out on" has no break now
   and needs to actually fit on one line at typical desktop widths --
   measured, not guessed: 34rem wrapped it to two lines at 1400px wide. */
.hero-inner { width: 100%; max-width: 64rem; }
@media (max-width: 760px) { .hero-inner { max-width: none; } }

html.js .hero-inner { visibility: hidden; }
html.js .act-hero.hero-run .hero-inner { visibility: visible; }

/* Top-left, in line with the nav's 44px band -- a sibling of .hero-inner
   inside the same .stage, so it scrolls away together with the rest of the
   hero rather than becoming a second fixed header. Positioned against
   .stage's own padding box, which is edge-to-edge with the viewport
   (padding is drawn INSIDE that box), so left:var(--gut) lines up with the
   flow content's own gutter with no double-counting. */
.hero-wordmark {
  font-size: clamp(1.4rem, 4.2vw, 1.85rem);
  letter-spacing: -.01em;
  margin: 0;
}
html.js .hero-wordmark {
  position: absolute;
  left: var(--gut);
  top: calc(12px + env(safe-area-inset-top));
  height: 44px;
  display: flex;
  align-items: center;
}
/* Collision: the nav pill is open and centred at the top of the page --
   translateX(calc(gut + 50% - 50vw)) -- and is wide enough (274.4px at
   390px under the ≤520px rules below; up to 338.1px at desktop widths)
   to leave no room beside a top-left wordmark on the same 44px line.
   Rather than touch the nav's own (already-verified) open/close
   behaviour, the wordmark drops to its own line under it below 760px, the
   site's existing mobile breakpoint. */
@media (max-width: 760px) {
  html.js .hero-wordmark { top: calc(12px + env(safe-area-inset-top) + 44px + 14px); }
}
/* Below 520px the bar itself moves down 8px (see the .bar rule in the nav
   section above, same breakpoint) and the pill widens -- see the measured
   width note above the ≤520px nav rules. The wordmark's own top has to
   drop the same 8px to stay 14px clear of the now-lower pill; this rule
   comes after the 760px one above so it wins at this narrower width. */
@media (max-width: 520px) {
  html.js .hero-wordmark { top: calc(20px + env(safe-area-inset-top) + 44px + 14px); }
}
.mk { display: inline-block; }
html.js .mk {
  mask-image: linear-gradient(90deg, #000 45%, transparent 65%);
  -webkit-mask-image: linear-gradient(90deg, #000 45%, transparent 65%);
  mask-size: 220% 100%; -webkit-mask-size: 220% 100%;
  mask-position: 100% 0; -webkit-mask-position: 100% 0;
  filter: blur(4px);
}
.mk-sp { width: .34em; }
/* JS triggers this per letter with setTimeout (site.js), rather than baking
   the stagger into animation-delay: a CSS animation-delay beyond about a
   second never committed its end state on an otherwise-idle page in
   testing -- the compositor simply never ticked it forward. setTimeout is a
   JS timer and fires reliably; the animation itself then runs with no
   delay. */
html.js .mk.is-on {
  animation: wordmarkWipe var(--t-wipe) var(--sc-ease-out) forwards;
}
@keyframes wordmarkWipe {
  from { mask-position: 100% 0; -webkit-mask-position: 100% 0; filter: blur(4px); }
  /* filter: none, not blur(0): a live filter value keeps the letter on its
     own composited layer for the rest of the page's life. */
  to   { mask-position: 0 0;   -webkit-mask-position: 0 0;   filter: none; }
}

.hero-h1 {
  font-family: var(--font-hero);
  font-weight: 500;
  font-size: clamp(2.2rem, 9vw, 4.6rem);
  line-height: 1.06;
  letter-spacing: -.02em;
  margin: 0 0 clamp(.85rem, 2.6vh, 1.25rem);
  /* Both hero lines are large-scale text, so WCAG 1.4.3 asks 3:1, not 4.5:1.
     Measured on the composited background: headline 100% of its glyph box
     clears 3:1 at both widths; the rolling word clears 100% at 390px but
     95.3% at 1440px, the shortfall being thin bright sky slivers between
     trunks, not a broad bright field. A real shadow (offset and blur, no
     coloured halo) covers exactly that case locally, which is the cheap fix
     -- darkening the scrim enough to bury those slivers would cost the whole
     photograph the brightness it was regenerated for. */
  text-shadow: 0 1px 14px rgba(0, 0, 0, .55);
}
/* site.js flips .is-on per character with setTimeout, timed off a seeded
   LCG for non-uniform typing (real typing has no fixed cadence). Hidden
   characters already reserve their space (opacity, not display), so the
   caret can be moved character-to-character with no reflow. */
html.js .ch { opacity: 0; }
html.js .ch.is-on { opacity: 1; }

/* White, and it travels: moved with insertBefore ahead of the next
   not-yet-shown character each time one lights (site.js), the same
   technique the transparency act uses for its own caret. */
/* Zero-width on purpose. An inline 3px+2px box travelling between the
   characters changed the line length as it went, which is what made "losing"
   start on the first line and jump to the second mid-type. The box is now
   drawn by ::after out of flow, so the caret still travels and still blinks
   but contributes nothing to layout. */
.caret { position: relative; display: inline-block; width: 0; height: .82em; vertical-align: -.08em; opacity: 0; }
.caret::after { content: ""; position: absolute; left: 2px; top: 0; width: 3px; height: 100%; background: #fff; }
html.js .caret.is-on { animation: caretBlink calc(var(--t-caret) * 2) step-end infinite; }
.caret.is-off { display: none; }
@keyframes caretBlink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }

/* The rolling word: exposure / clients / revenue, a mask-and-slide vertical
   roll ported from nextraining.co.za (site.js/site.css there), NOT a
   crossfade -- that build's own comments record a crossfade being tried and
   rejected: "a crossfade with a gap in the middle reads as a rendering
   fault." White (#fff), not the accent colour -- the brief calls for it
   explicitly, distinct from the typed line above it. The item box is 1.28em
   against the type's own 1.0 line-height so the descenders in "exposure"
   and the tall "revenue" both clear the mask. site.js writes only --n. */
.rot {
  display: block;
  overflow: hidden;
  height: 1.28em;
  margin-block: -.12em;
  font-family: var(--font-accent);
  font-style: italic;
  font-weight: 400;
  font-size: clamp(2.3rem, 9.4vw, 4.9rem);
  letter-spacing: -.01em;
  /* See .hero-h1: this is the line that measured 95.3% at 3:1 on desktop. */
  text-shadow: 0 1px 14px rgba(0, 0, 0, .55);
  color: #fff;
}
.rot__track {
  display: block;
  transform: translate3d(0, calc(var(--n, 0) * -1.28em), 0);
  transition: transform 720ms cubic-bezier(.76, 0, .24, 1);
}
.rot__track.no-t { transition: none; }
.rot__i { display: block; height: 1.28em; line-height: 1.28em; white-space: nowrap; }
/* Revealed the moment the headline's last character lands (site.js calls
   this the same beat as .hero-run.is-typed, the old .fault trigger --
   renamed in spirit, not in markup, since .fault itself is gone). */
html.js .rot { opacity: 0; filter: blur(12px); }
html.js .hero-run.is-typed .rot {
  opacity: 1; filter: none;
  transition: opacity var(--t-rot-reveal) var(--sc-ease-out), filter var(--t-rot-reveal) var(--sc-ease-out);
}

/* Reduced motion: no split, no caret, no wipe, no roll. The typed line
   fades in over 300ms; the rolling word sits statically on "exposure"
   (--n never leaves 0, rotator() is never called from site.js) and simply
   shows at rest -- .hero-run.is-typed never lands under reduced motion, so
   it needs its own unconditional final state here rather than the fade the
   non-reduced path uses. */
@media (prefers-reduced-motion: reduce) {
  html.js .hero-inner { visibility: visible; }
  html.js .mk { mask-image: none; -webkit-mask-image: none; filter: none; }
  html.js .ch, html.js .caret { opacity: 1; animation: none; }
  html.js .caret { display: none; }
  html.js .rot { opacity: 1; filter: none; }
}

/* --------------------------------------------------------------- 2 refine --
   White stage, flow (rev 8b -- was pinned). Percept's move: "refinement"
   parks a viewport-width off the right edge and crosses to centre as a
   plain function of refine's own --sc-p (site.js writes --rx, no timer, no
   transition -- the word moves with the wheel and stops with it). Nothing
   else is on this stage. .stage sets its own position:relative and
   min-height:100svh directly (below, since the engine no longer supplies a
   sticky box for a flow act); its two children are positioned absolutely
   against that box instead of via the shared flex layout, since the lead
   line and the word sit at two fixed bands of the viewport height rather
   than stacked in flow. Padding-inline is cleared for this act only -- the
   word needs to run close to the full viewport width, not the shared --gut
   column; the lead line restores its own comfortable margin below. */
html.js .act-refine .stage { position: relative; min-height: 100svh; padding-inline: 0; }
/* Position and the percept-scale font are both gated on html.js: they
   depend on the stage's own 100svh box (55-92% of ITS height), which only
   exists under JS. Without that gate, the no-JS override that collapses the
   stage to auto-height (site.css, the no-JS block near the top) leaves
   these percentages resolving against a box a few rem tall -- measured on
   this revision, that clipped "refinement" top and bottom at 1440 wide. No-JS
   gets the plain flow rules below instead: same two lines, modest size, no
   clipping, nothing to restate. */
.refine-lead {
  margin: 0 0 1.5rem;
  padding-inline: var(--gut);
  text-align: center;
  font-family: var(--sc-font-text);
  font-weight: 500;
  font-size: clamp(1.4rem, 3vw, 2.2rem);
  letter-spacing: -.01em;
  color: var(--sc-ink);
  will-change: opacity, filter;
}
html.js .refine-lead {
  position: absolute;
  left: 0; right: 0;
  margin: 0;
  /* Measured by its own baseline, not its box top: 40% landed the baseline
     at 46.3-46.6% of the viewport (the headless check this revision ran).
     35.5% lands it at 42%, mid of the 40-44% the brief asks for. */
  top: 35.5%;
}
.rf-clip {
  display: flex;
  align-items: center;
  justify-content: center;
  padding-block: clamp(1rem, 4vh, 2rem);
}
/* The clipping box, html.js only: width:100% of the (now ungutted) stage,
   overflow hidden, so the word's off-screen parked position never widens
   the page -- clipped INSIDE the stage, never on the stage or an ancestor
   of it, which would unpin the sticky act (see the engine-facts note).
   Positioned in the bottom half of the viewport (55%-92%), where the brief
   asks the word to sit. */
html.js .rf-clip {
  position: absolute;
  left: 0; right: 0;
  top: 55%;
  bottom: 8%;
  width: 100%;
  overflow: hidden;
  padding-block: 0;
}
.rf {
  margin: 0;
  font-family: var(--font-accent);
  font-style: italic;
  font-weight: 400;
  white-space: nowrap;
  line-height: .9;
  color: var(--sc-ink);
  /* No-JS size: modest and safe at any width, since this renders in normal
     flow with no clipping box to fit. */
  font-size: clamp(2.5rem, 10vw, 5rem);
}
html.js .rf {
  /* Measured, not guessed: a headless check of this revision found the
     glyph run's ink box (Range.getBoundingClientRect over the text node)
     at 19vw spanned only 75.7-75.9% of the viewport width at both 390 and
     1440 -- short of the 80-92% target. 21.5vw measured 85.8% at both
     (neither clamp bound engaged at either width, so the ratio holds flat
     across sizes as expected for a pure-vw value); the rem bounds below
     only guard the extremes this was never checked at. */
  font-size: clamp(4.3rem, 21.5vw, 21.5rem);
}
/* The move: parked 100vw to the right at rest, gated on html.js so no-JS
   needs no restatement (the default above is already centred-at-rest).
   site.js writes --rx from refine's own --sc-p, p 0.15-0.5, landing as the
   section fills the screen. No CSS transition -- the word tracks the scroll
   position exactly, one frame behind nothing. */
html.js .rf { transform: translate3d(var(--rx, 100vw), 0, 0); will-change: transform; }
@media (max-width: 760px) {
  /* Measured at 390: top:38% landed the baseline at 46.6% (the smaller
     clamp size here shifts the top-to-baseline offset from the general
     rule's own number), so the narrow-width top needs its own value, not
     the same shift applied to both. 33.4% lands the baseline at 42%. */
  html.js .refine-lead { top: 33.4%; }
  html.js .rf-clip { top: 54%; bottom: 6%; }
}

/* ------------------------------------------------------------- 3 the world --
   The pinnacl.co device. Black, flow (rev 8b -- was pinned). Raw WebGL 1
   points, no library: a scanned building interior under construction
   (columns, beams, a stair core, partition walls, site clutter -- generated
   in site.js, never a photo) rendered as a simulated terrestrial-LiDAR point
   cloud, additive white dots turning on the wall clock -- never on --sc-p.
   Scroll drives only the caption's fade. Reduced motion: one static frame,
   no spin, no drift. No-JS: canvas blank, card visible at rest. */
.act-work { background: #000; }
/* Right-anchored, a third down the stage, floating directly on the scan --
   topograph.app's own card is bare text, no glass. This was the page's
   second glass artifact; the client asked for the topograph treatment
   specifically. position:relative + min-height:100svh replace the engine's
   sticky box now that this act is flow: the canvas is absolute inset:0
   against this element, and worldResize() reads its real box size, so it
   has to actually have a size with no JS layout pass needed to grant it
   one. */
.act-work .stage { position: relative; min-height: 100svh; align-items: flex-end; justify-content: flex-start; padding-top: 30vh; }
.world-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
.world-card {
  position: relative;
  z-index: 2;
  max-width: 22rem;
  text-align: right;
  opacity: 0;
}
.world-card-text {
  margin: 0;
  font-family: var(--sc-font-text);
  font-weight: 500;
  font-size: clamp(1.2rem, 3.6vw, 1.7rem);
  letter-spacing: -.01em;
  color: #fff;
  /* A bright run of scan points (a close wall, a column) can still land
     under this text at some rotation angles; a small LOCAL text-shadow
     scrim (never a full-frame overlay) keeps it clear of the 4.5:1 floor
     without putting glass back under it. */
  text-shadow: 0 1px 16px rgba(0, 0, 0, .7), 0 1px 3px rgba(0, 0, 0, .9);
}

/* -------------------------------------------------------------- 4 minimal --
   Calm, beige. FLOW (rev 8b -- was pinned at span 1, which was already zero
   pinned travel: a one-screen-tall pin holds the scroll for no distance at
   all, so this act visually behaves the same either way). .stage sets its
   own min-height:100svh + position:relative below, same as every other act
   on the page now -- nothing in it is scroll-driven beyond the one-time
   data-sc-in/data-sc-stagger reveal, which does not care whether its
   ancestor act pins or flows. */
.act-minimal .stage { position: relative; min-height: 100svh; }
/* Deliberately declares no background of its own: .on-cream paints this
   act's ground, and any background rule here would win on source order and
   black the whole act out. */
.minimal-grid { width: 100%; display: grid; gap: clamp(2rem, 6vw, 3rem); align-items: center; }
.minimal-h2 {
  font-family: var(--sc-font-display);
  font-weight: 700;
  font-size: clamp(1.9rem, 6vw, 2.8rem);
  letter-spacing: -.02em;
  margin: 0 0 clamp(1.5rem, 5vh, 2.25rem);
}
.demo-card {
  border: 1px solid var(--sc-hairline-strong);
  border-radius: 18px;
  overflow: hidden;
  background: #fff;
  max-width: 34rem;
  margin-inline: auto;
}
.demo-photo { margin: 0; aspect-ratio: 16 / 9; overflow: hidden; background: #E7E2D8; }
.demo-photo img { width: 100%; height: 100%; object-fit: cover; }
.demo-body { padding: clamp(1.5rem, 4.5vw, 2.25rem); display: grid; gap: .9rem; }
.demo-name {
  font-family: var(--sc-font-text);
  font-weight: 500;
  font-size: 1.5rem;
  margin: 0;
  letter-spacing: -.01em;
  color: #14120D;
}
.demo-facts { list-style: none; margin: 0; padding: 0; display: grid; gap: .65rem; font-family: var(--sc-font-text); font-size: .98rem; color: #14120D; }
.demo-facts li { display: flex; align-items: center; gap: .7rem; }
.demo-facts .ico-fact { width: 1.3em; height: 1.3em; flex: none; color: #14120D; }
.pill-cta {
  display: inline-flex; align-items: center; gap: .5rem;
  margin-top: .3rem;
  min-height: 44px; padding: .65rem 1.15rem;
  border-radius: 999px;
  background: var(--sc-accent); color: var(--sc-accent-ink);
  font-weight: 600; font-size: .92rem; text-decoration: none;
  width: fit-content;
  transition: transform 140ms var(--sc-ease-out);
}
a.pill-cta:hover { transform: translateY(-1px); }
a.pill-cta:active { transform: translateY(1px) scale(.98); }
/* A picture of a website, not fern's own CTA: this one is a <span>, not a
   link, so a visitor can't mistake it for fern's own WhatsApp number. */
span.pill-cta { cursor: default; }
/* On a white/cream ground --sc-accent resolves to --accent-deep (#B5573A),
   and --sc-accent-ink (#140B06) on that is only ~4.0:1 at this text size.
   White text on the same deep-accent background clears ~4.8:1. */
.on-white .pill-cta, .on-cream .pill-cta { color: #fff; }
.pill-white { background: #fff; color: #14110C; }

@media (min-width: 760px) {
  .minimal-grid { grid-template-columns: minmax(0, 1fr) minmax(0, 24rem); }
}

/* ---------------------------------------------------------- 7 transparency
   The signature move. Black. A bland guesthouse page on the left is
   transformed into a good one as you scroll, while the sentence types
   itself on the right. Both frames are our own builds of the same
   demonstration page, shot at identical framing: if they do not line up
   the wipe reads as a cut rather than a change.

   Rev 8b: FLOW, not pinned, one screen tall like every other act -- making
   this stage taller than 100svh was tried and reverted: an element in flow
   is on screen for (viewport height − its own height) regardless of the
   section's total height, so the extra height only added empty black space
   around the content, not extra viewing time, and squeezed the wipe into a
   cut. The four beats (type, look at the old page, wipe, closing line) are
   timed in site.js off measured pixel offsets of .tw/.morph within this
   section (cached on load/resize) and scrollY directly, not off this act's
   own --sc-p -- see the comment there. */
.act-honest { background: var(--sc-canvas); }
.act-honest .stage { position: relative; min-height: 100svh; }
.honest-body { display: flex; flex-direction: row; align-items: center; justify-content: center; gap: clamp(1.75rem, 5vw, 4rem); }
@media (max-width: 860px) { .honest-body { flex-direction: column; text-align: center; gap: 1.5rem; } }

.morph { margin: 0; flex: none; width: min(38rem, 52%); }
@media (max-width: 860px) { .morph { width: min(30rem, 100%); } }
.morph-frame {
  position: relative;
  display: block;
  border-radius: 12px;
  overflow: hidden;
  background: #0B0C0E;
  box-shadow: 0 24px 60px -22px rgba(0, 0, 0, .85);
}
.morph-img { width: 100%; height: auto; display: block; }
/* The refined frame is revealed by a wipe from the left. --w is the share
   still hidden, written per frame by site.js, so at rest (1) the dated page
   is what shows. */
.morph-b { position: absolute; inset: 0; height: 100%; clip-path: inset(0 var(--w, 100%) 0 0); }
.morph-seam {
  position: absolute; top: 0; bottom: 0;
  width: 2px; margin-left: -1px;
  left: calc(100% - var(--w, 100%));
  background: var(--sc-accent);
  opacity: var(--seam, 0);
  box-shadow: 0 0 20px 3px rgba(217, 119, 87, .5);
}
.morph figcaption {
  margin-top: .8rem;
  font-family: var(--font-mono);
  font-size: .72rem;
  letter-spacing: .05em;
  color: var(--sc-ink-soft);
}
.honest-copy { max-width: 26rem; }
.tw {
  font-family: var(--font-mono);
  font-size: clamp(1.35rem, 4.4vw, 2.1rem);
  line-height: 1.45;
  margin: 0;
  max-width: 20ch;
}
.tc { opacity: 0; }
.tc.is-on { opacity: 1; }
.tw-caret { display: inline-block; width: 3px; height: .85em; margin-left: 1px; vertical-align: -.12em; background: #fff; animation: caretBlink 900ms step-end infinite; }
.tw-caret.is-off { display: none; }
.honest-close {
  font-family: var(--font-mono);
  font-size: clamp(1.1rem, 3.4vw, 1.4rem);
  color: var(--sc-ink-soft);
  margin: 1.25rem 0 0;
  opacity: 0;
  will-change: opacity, filter;
}

/* ------------------------------------------------------------- 6 offer ---
   A robust.to-style staggered grid now, in flow -- was a pinned card stack
   crossing the stage one at a time (Johannes: "take inspiration from
   robust.to... I want the images with rounded corners and text to be like
   that, with a little bit of zoom upon clicking an image"). Two columns,
   column B pushed down so the lanes read as offset bricks rather than a
   lockstep grid; each item is ONE card -- text on the card ground, image
   flush below it, sharing one set of outer rounded corners rather than two
   cards with a gap between them. One column at phone width -- see the
   mobile block below. */
.act-offer { background: var(--sc-canvas); }
.offer-wrap {
  /* robust.to's own card ground, scoped as a real custom property rather
     than a bare hex dropped into each rule below. */
  --card-bg: #F5F5F5;
  padding-block: clamp(3rem, 10vh, 5.5rem);
}
.offer-wrap .section-label { margin-bottom: clamp(1.75rem, 6vh, 3rem); }
.offer-grid {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  /* row-gap only shows in the phone's single column, where it sits between
     card 2 and card 3 -- kept equal to the in-column gap so the stack is even. */
  column-gap: clamp(2rem, 6vw, 3.5rem);
  row-gap: clamp(5rem, 16vw, 8rem);
  align-items: start;
}
.offer-col { display: flex; flex-direction: column; gap: clamp(5rem, 16vw, 8rem); }
/* The stagger: pushed down by roughly half a card, so column B's cards sit
   between column A's rather than lining up beside them. */
.offer-col--b { margin-top: clamp(2.5rem, 9vw, 5rem); }

/* One card per item: the outer radius and ground live here, the text and
   image children sit flush against each other inside it. */
.offer-card {
  margin: 0;
  background: var(--card-bg);
  border-radius: 1.875rem;
  overflow: hidden;
  /* WebKit can drop the rounded clip while a child transform animates. */
  isolation: isolate;
  display: flex;
  flex-direction: column;
}

.offer-text {
  margin: 0;
  color: #0B0C0E;
  padding: 1.875rem;
}
.offer-text h3 {
  font-family: var(--sc-font-display);
  font-weight: 700;
  font-size: clamp(1.5rem, 3.6vw, 2rem);
  line-height: 1;
  letter-spacing: -.04em;
  margin: 0 0 .6rem;
}
.offer-text p { margin: 0; color: #55595F; font-size: .96rem; line-height: 1.4; }

/* The image half of the card -- the picture itself, click-to-zoom, flush
   against the text above it inside .offer-card. Its top corners carry the
   card's own radius, so the grey ground curves into the photo where the two
   meet; the bottom corners are rounded by the card's overflow:hidden clip.
   A real <button>, not a div with a click handler, so it is
   reachable and operable by keyboard with no extra ARIA wiring beyond
   aria-pressed. Three of the four are screenshots CONTAINED on a dark
   ground (cropping a screenshot stops it reading as a website); only the
   photograph covers. Each modifier's aspect-ratio is chosen per source, not
   forced square -- see the build notes for why. */
.offer-img {
  display: block;
  width: 100%;
  padding: 0;
  border: 0;
  border-radius: 1.875rem 1.875rem 0 0;
  overflow: hidden;
  isolation: isolate;
  background: #0B0C0E;
  cursor: pointer;
}
.offer-img img {
  width: 100%; height: 100%;
  display: block;
  object-fit: contain;
  transition: transform 700ms var(--sc-ease-out);
}
.offer-img[aria-pressed="true"] img { transform: scale(1.04); }
/* robust.to-style push-in on hover: the image grows inside its clipped
   frame, the frame stays put. Fine-pointer only, or it sticks after a tap. */
@media (hover: hover) and (pointer: fine) {
  .offer-img:hover img { transform: scale(1.06); }
  /* Clicking happens under the cursor, so the pressed state needs its own
     step past hover or the click shows nothing. */
  .offer-img[aria-pressed="true"]:hover img { transform: scale(1.1); }
}
/* Flush to the card's clipped edges, so the global ring (drawn outside)
   would be cut off on three sides -- pull it inside the image instead. */
.offer-img:focus-visible { outline-offset: -4px; }
/* example-site.webp, 1200x798 -- near-landscape, contained on its own dark
   ground with only a hair of letterboxing. */
.offer-img--site { aspect-ratio: 1200 / 798; }
/* hosting-logs.webp, 1200x688. */
.offer-img--hosting { aspect-ratio: 1200 / 688; }
/* phone-site.webp is 780x976 -- the screenshot IS the 4:5 crop already (no
   device frame around it), so it can cover its box edge to edge instead of
   the letterboxed treatment the old taller phone-next.webp needed. */
.offer-img--phone { aspect-ratio: 4 / 5; }
.offer-img--phone img { object-fit: cover; }
/* care.webp, 700x928, a real photograph -- it can cover, and the box ratio
   is close enough to its own that the crop is barely noticeable. */
.offer-img--photo { aspect-ratio: 3 / 4; }
.offer-img--photo img { object-fit: cover; }

@media (max-width: 700px) {
  .offer-grid { grid-template-columns: 1fr; }
  .offer-col--b { margin-top: 0; }
}

/* --------------------------------------------------------------- 7 price --
   Black, FLOW now -- not pinned. A full-bleed fern photograph with ONE
   liquid-glass box floating on it, rev 1's loose orange chips and accent
   glow on flat black "looked quite bad" (Johannes) and are gone. Also not
   pinned: Johannes asked the scroll stopper taken off entirely, so the
   section scrolls past like #offer or #contact rather than holding the
   visitor still. It is therefore its own top-level flow container, not a
   [data-sc-stage] child -- that element is the engine's sticky box for
   PINNED acts only, and this act carries none of its own layout. Every
   price is still a data-price attribute on its own input, so the DOM
   already sums to R1000 before site.js runs a single line -- this act works
   with JS off and with a keyboard, which a self-drawing chart never could.
   No orange anywhere in this section: the accent tokens are overridden to
   white/black below as a safety net, and every rule past this point spells
   out its own colour rather than reaching for --sc-accent. */
.act-pricing {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  /* At least one screen, same floor as #contact/.close below -- but this
     is min-height, not height: unlike a pinned act this section is free to
     grow taller than 100svh if the content ever needs it (a narrow phone
     with everything expanded), and nothing here should clip if it does. */
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  background: var(--sc-canvas);
  --sc-accent: #fff;
  --sc-accent-ink: #0B0C0E;
}
/* Anchored to the ACT itself now (position:relative + overflow:hidden
   above), not to a pinned [data-sc-stage] -- there is no sticky box on a
   flow section for it to live inside. Same recipe as .close-pic. */
.pricing-bg { position: absolute; inset: 0; z-index: -1; margin: 0; }
.pricing-bg img { width: 100%; height: 100%; object-fit: cover; display: block; }
/* A soft dark scrim across the top band only, where the white section
   label sits directly on the raw photo with no glass under it. Measured,
   not guessed: the first pass (.60 to .38 across the band) left the single
   brightest bokeh fleck behind the label text at 4.0-4.1:1 on a phone
   crop -- short of the 4.5:1 floor even though the AVERAGE background
   there was already 8-17:1. Raised to .74/.56 so that same worst pixel
   clears 4.5:1; the rest of the photograph past 42% stays untouched. Also
   moved to the act itself, same reason as .pricing-bg above. */
.act-pricing::after {
  content: "";
  position: absolute; inset: 0;
  z-index: -1;
  background: linear-gradient(180deg, rgba(0, 0, 0, .74) 0%, rgba(0, 0, 0, .56) 20%, rgba(0, 0, 0, 0) 42%);
  pointer-events: none;
}
/* Replaces the pinned .stage/.stage-labeled wrapper this act used to sit
   in: a plain flex column, gutter padding on the sides, breathing room top
   and bottom. .wrap supplies the max-width/margin-inline/padding-inline;
   flex:1 lets it fill (or exceed) the act's own min-height:100svh. */
.pricing-in {
  position: relative;
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: clamp(2rem, 7vh, 4rem);
  padding-block: clamp(2rem, 6vh, 3.5rem);
}
/* Replaces the pinned .stage-body this div used to also carry (index.html)
   -- flex:1 and centring now live here directly instead of being inherited
   from that class. */
.pricing-body {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  width: 100%;
  gap: clamp(1.25rem, 4vh, 2rem);
}
/* The one glass box. backdrop-filter is claimed HERE ONCE for the whole
   section -- chips and segmented tracks inside it (below) are plain
   translucent fills with no blur of their own, because a backdrop-filter
   nested inside an already-blurred box costs real frame time on mid-range
   Android for no visible gain. */
.estimator {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: clamp(.85rem, 2.2vh, 1.3rem);
  width: min(100%, 28rem);
  margin: 0;
  padding: clamp(1.5rem, 4vh, 2.25rem) clamp(1.25rem, 4vw, 1.75rem);
  text-align: center;
  border-radius: 24px;
  /* Light enough to read as glass: the black inner fills below carry the
     darkening that text contrast needs, so the box itself doesn't have to. */
  background: rgba(10, 14, 8, .65);
  border: 1px solid rgba(255, 255, 255, .22);
  backdrop-filter: blur(28px) saturate(160%);
  -webkit-backdrop-filter: blur(28px) saturate(160%);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .20), 0 24px 60px -20px rgba(0, 0, 0, .65);
}
.est-kicker { margin: 0; font-family: var(--font-mono); font-size: .82rem; letter-spacing: .12em; text-transform: uppercase; color: rgba(255, 255, 255, .86); }
.est-total { margin: 0; display: flex; align-items: baseline; justify-content: center; gap: .4rem; }
#estTotal {
  font-family: var(--sc-font-text);
  font-weight: 500;
  font-size: clamp(3rem, 11vw, 4.5rem);
  line-height: 1;
  letter-spacing: -.02em;
  color: #fff;
  font-variant-numeric: tabular-nums;
}
.est-unit { font-size: 1rem; font-weight: 500; color: #fff; }
.est-rule { width: 100%; height: 1px; margin: 0; border: 0; background: rgba(255, 255, 255, .18); }
.est-foot { margin: 0; font-size: clamp(.78rem, 2vw, .86rem); color: rgba(255, 255, 255, .86); }

/* Chips: one column, one per row, full width. No blur of their own -- see
   the box comment above. Three visual states, deliberately distinct at a
   glance: locked (greyed, inert), off (clear glass, still reads as
   clickable), on (solid white). The native input sits opacity:0 over the
   whole label so it stays a real, focusable checkbox; the visible pill is
   its very next sibling, which is what the :checked / :focus-visible rules
   below key off. */
.est-chips { display: flex; flex-direction: column; gap: .55rem; margin: 0; padding: 0; border: 0; }
.chip { position: relative; }
.chip input { position: absolute; z-index: 1; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
.chip.is-locked input { cursor: default; }
.chip-face {
  display: flex;
  align-items: center;
  gap: .6rem;
  min-height: 44px;
  padding: .6rem 1.1rem;
  border-radius: 999px;
  /* A black tint, not white: a translucent WHITE fill lightens whatever
     photo sits behind it, which is what was fighting the box's own tint
     for text contrast. Black darkens instead, so the box tint itself can
     come back down and still show the photo through it. The rim stays
     white -- that is the glass edge, not the fill. */
  background: rgba(0, 0, 0, .28);
  border: 1px solid rgba(255, 255, 255, .28);
  color: #fff;
  font-size: .92rem;
  font-weight: 500;
  transition: background 200ms var(--sc-ease-out), border-color 200ms var(--sc-ease-out), transform 140ms var(--sc-ease-out);
}
.chip-tick {
  width: 15px; height: 15px; flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 999px;
  border: 1.5px solid rgba(255, 255, 255, .5);
}
.chip-tick::after {
  content: "";
  width: 7px; height: 7px;
  border-radius: 999px;
  background: #0B0C0E;
  transform: scale(0);
  transition: transform 220ms var(--sc-ease-out);
}
/* Toggle, checked: solid white, black tick scales in. */
.chip:not(.is-locked) input:checked + .chip-face {
  background: #fff;
  border-color: #fff;
  color: #0B0C0E;
}
.chip:not(.is-locked) input:checked + .chip-face .chip-tick { border-color: #0B0C0E; }
.chip:not(.is-locked) input:checked + .chip-face .chip-tick::after { transform: scale(1); }
/* Fine-pointer only: a translateY "hover" on a touchscreen just means it
   sticks after the tap, since there's no pointer to leave. */
@media (hover: hover) {
  .chip:hover:not(.is-locked) .chip-face { transform: translateY(-1px); }
}
.chip:active:not(.is-locked) .chip-face { transform: scale(.97); }
.chip input:focus-visible + .chip-face { outline: 2px solid #fff; outline-offset: 2px; }
/* Locked: greyed out, always ticked, inert. is-locked overrides the toggle
   rule above by losing to a later, more specific selector -- it never
   matches :not(.is-locked), so it keeps its own flat state regardless of
   :checked. */
.chip.is-locked .chip-face {
  /* Greyed out: a darker fill, a dimmer rim and dimmer text (.74, against
     .86 for the box's other secondary labels) keep a locked row reading as
     locked at a glance. The .74 still clears 4.5:1 over the brightest
     bokeh because of the darker fill under it. */
  background: rgba(0, 0, 0, .32);
  border-color: rgba(255, 255, 255, .16);
  color: rgba(255, 255, 255, .74);
}
.chip.is-locked .chip-tick { border-color: rgba(255, 255, 255, .3); }
.chip.is-locked .chip-tick::after { background: rgba(255, 255, 255, .5); transform: scale(1); }
.chip-note { margin-left: auto; font-size: .76rem; font-weight: 400; color: rgba(255, 255, 255, .86); }
@supports not (backdrop-filter: blur(1px)) {
  .estimator { background: rgba(8, 11, 6, .82); }
}
/* Jank fallback, same as .pill-nav's above: the real-phone check failed on
   2026-09-15 (scroll jitter through this section), so no blur on phones --
   a darker solid tint instead, which only raises text contrast. */
@media (max-width: 860px) {
  .estimator { backdrop-filter: none; -webkit-backdrop-filter: none; background: rgba(6, 9, 5, .92); }
}

/* Segmented controls: legend sits to the left of the track (one row, fits
   a phone), plain translucent track, a WHITE thumb slides under the
   checked option. The thumb's position is CSS-only, keyed to DOM order
   with :has() -- no JS write, so it still lands correctly with no JS at
   all. .seg-track's children are [thumb, opt1, opt2, opt3], so
   the checked opt's OWN nth-child (counting the thumb as 1) is one ahead
   of its index. */
.est-seg { display: flex; align-items: center; gap: .75rem; width: 100%; margin: 0; padding: 0; border: 0; }
.est-seg-label { flex: none; margin: 0; padding: 0; font-family: var(--font-mono); font-size: .74rem; letter-spacing: .09em; text-transform: uppercase; color: rgba(255, 255, 255, .86); }
.seg-track {
  position: relative;
  flex: 1;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 4px;
  padding: 4px;
  border-radius: 999px;
  /* Black, not white -- see .chip-face; a white fill here lightened the
     photo behind the track and fought the box tint for text contrast. */
  background: rgba(0, 0, 0, .28);
  border: 1px solid rgba(255, 255, 255, .20);
}
.seg-thumb {
  position: absolute;
  top: 4px; left: 4px;
  width: calc((100% - 8px) / 3);
  height: calc(100% - 8px);
  border-radius: 999px;
  background: #fff;
  transform: translateX(calc(var(--i, 0) * 100%));
  transition: transform 480ms cubic-bezier(0.16, 1, 0.3, 1);
}
.seg-track:has(.seg-opt:nth-child(3) input:checked) .seg-thumb { --i: 1; }
.seg-track:has(.seg-opt:nth-child(4) input:checked) .seg-thumb { --i: 2; }
.seg-opt {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  border-radius: 999px;
  cursor: pointer;
}
.seg-opt input { position: absolute; z-index: 2; inset: 0; margin: 0; opacity: 0; cursor: pointer; }
.seg-opt-text { font-size: .84rem; font-weight: 500; color: #fff; transition: color 240ms var(--sc-ease-out); }
.seg-opt input:checked + .seg-opt-text { color: #0B0C0E; }
.seg-opt:has(input:focus-visible) { outline: 2px solid #fff; outline-offset: 3px; border-radius: 999px; }

/* --------------------------------------------------------------- 8 close --
   Warm. A fern forest at sunrise; the wordmark, the line, two white pills.
   Centred vertically and, now, actually centred horizontally. The scrim is a
   soft vertical gradient strongest through the middle band, never a flat
   full-frame overlay. */
.close {
  position: relative;
  min-height: 100svh;
  display: grid;
  align-items: center;
  justify-items: center;
  overflow: hidden;
  isolation: isolate;
  background: #14100C;
}
.close-pic { position: absolute; inset: 0; z-index: -2; margin: 0; }
.close-pic img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 45%; }
.close::after {
  content: "";
  position: absolute; inset: 0;
  z-index: -1;
  background: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, .4) 24%, rgba(0, 0, 0, .84) 44%, rgba(0, 0, 0, .84) 58%, rgba(0, 0, 0, .4) 76%, rgba(0, 0, 0, 0) 100%);
}
/* Measured, not assumed. This was a grid with max-width:34rem and a --gut
   padding on each side, which left a 368px content box; "Companies" at
   display size is far wider than that, so the grid's single column track
   grew to fit the headline and overflowed to the RIGHT only. Every centred
   child then centred on the overflowed track: the block sat 93px right of
   the screen centre. A full-width flex column with the cap moved onto the
   children cannot do that. */
.close-in {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  width: 100%;
  padding-inline: var(--gut);
  color: #fff;
}
.close-in > * { max-width: 34rem; }
.mark-close { margin: 0 0 clamp(1rem, 4vh, 1.5rem); font-size: clamp(1.6rem, 5.5vw, 2.15rem); color: #fff; }
.close-h {
  font-family: var(--sc-font-display);
  font-weight: 700;
  font-size: clamp(2.1rem, 9vw, 3.8rem);
  line-height: 1.02;
  letter-spacing: -.02em;
  margin: 0 0 clamp(1rem, 4vh, 1.5rem);
  text-wrap: balance;
}
.close-s { margin: 0 0 clamp(.35rem, 1.4vw, .55rem); color: rgba(255, 255, 255, .88); font-size: clamp(.95rem, 3.4vw, 1.05rem); }
.close-pills { display: flex; flex-wrap: wrap; gap: .75rem; justify-content: center; margin: 0; }

/* Sibling of .close-in, not a child: sits over the closing forest photo with
   no background of its own -- no black strip, no band. The scrim (.close::
   after) is transparent at the very bottom of the frame, so legibility comes
   from a soft text-shadow instead of a fill. */
.close-legal {
  position: absolute;
  bottom: max(1rem, env(safe-area-inset-bottom));
  left: 0;
  right: 0;
  display: flex;
  justify-content: center;
  gap: 1.25rem;
}
.close-legal a {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-height: 44px;
  padding: .6rem .9rem;
  font-family: var(--sc-font-text);
  font-size: .8rem;
  font-weight: 500;
  color: rgba(255, 255, 255, .75);
  text-decoration: none;
  text-shadow: 0 1px 3px rgba(0, 0, 0, .55), 0 1px 14px rgba(0, 0, 0, .35);
  transition: color 140ms var(--sc-ease-out, ease);
}
.close-legal a:hover, .close-legal a:focus-visible { color: #fff; }
.close-legal a:focus-visible { outline: 2px solid #fff; outline-offset: 2px; border-radius: 4px; }


/* ------------------------------------------------------------------ wiring
   Engine overrides fern is allowed. Only what the build plan documents.
   No [data-sc-act="pin"] override here any more (rev 8b removed the last
   one): every act on the page is flow, so there is no engine .sc-stage
   sticky box left on this page to override the overflow of. If a future act
   ever pins again, its overflow:visible escape hatch would be re-added
   here, scoped to that one act rather than the whole page. */
[data-sc-in], [data-sc-stagger] > * { filter: blur(6px); }
[data-sc-in].sc-in, [data-sc-stagger] > .sc-in { filter: none; }

/* --------------------------------------------------------- reduced motion --
   Fewer and gentler, not zero: every bespoke piece states its final state.
   The engine handles its own cues, reveals and in/stagger children. */
@media (prefers-reduced-motion: reduce) {
  /* Every bespoke element that starts at opacity 0 must state its final
     state here. (.section-label no longer needs an entry: it is plain
     opacity:1 now, never a cue -- see the "static from the start" fix.) */
  .refine-lead { opacity: 1 !important; filter: none !important; }
  /* JS never writes --rx under reduced motion (frame() only ever boots
     outside it), so this is the load-bearing line: without it the CSS
     default of --rx:100vw would leave "refinement" parked off-screen. */
  html.js .rf { transform: none !important; }
  .honest-close { opacity: 1; filter: none; transform: none; }
  .offer-img img { transition: none; }
  .tc { opacity: 1; }
  .tw-caret { display: none; }
  .caret { display: none; }
  .world-card { opacity: 1; }
  /* #offer is a plain flow grid now (no pin, no per-frame transform), so it
     needs no reduced-motion override of its own beyond the engine's generic
     [data-sc-in]/[data-sc-stagger] handling above. */
  .morph-b { clip-path: none; }
  .morph-seam { display: none; }
  /* No thumb slide and no tick scale-in; site.js also skips the rAF
     count-up tween and writes the total straight to #estTotal when this
     media query matches. */
  .seg-thumb { transition: none; }
  .nav-thumb { transition: none; }
  .chip-tick::after { transition: none; }
  .chip-face { transition: none; }
}

/* --------------------------------------------------------------- mobile --
   Width-keyed only. */
@media (max-width: 860px) {
  .close-in > * { max-width: none; }
  /* Reserves room for .close-legal at the bottom of the frame -- 3.75rem
     matches .close-legal's own footprint (44px tap target + its padding),
     plus the safe-area inset .close-legal itself pads out to on a notched
     phone. .close centres its content with align-items:center on a
     min-height:100svh grid, so padding-bottom alone (not padding-top)
     shifts the centred block up without a band -- see the fix note above
     .close-legal. */
  .close { padding-bottom: calc(3.75rem + env(safe-area-inset-bottom)); }
}

/* --------------------------------------------- 7 price, narrow widths --
   Flow now, so the act is free to grow past 100svh if it needs to -- this
   is compactness, not a hard fit requirement. The box, its padding, its
   line-height and its gaps still step down on a phone because a tighter,
   denser card reads better than a loosely-padded one at this width, not to
   avoid a scrollbar. Width-keyed. Chips stay one per row throughout:
   two-up made "Adjusted for mobile" and "Continuous care" hard to tell
   apart from a passing glance at 390px. */
@media (max-width: 760px) {
  .pricing-body { gap: clamp(.85rem, 2.5vh, 1.25rem); }
  .estimator { gap: .5rem; padding: 1.1rem 1.1rem; line-height: 1.2; }
  .est-chips { gap: .4rem; }
  .est-seg { gap: .5rem; }
  #estTotal { font-size: clamp(2.2rem, 11vw, 2.8rem); }
}
