/* ==========================================================================
   Jakob Picciotto Portfolio - Stylesheet
   ========================================================================== */

/* ==========================================================================
   TABLE OF CONTENTS

   Jump to a section: grep '§ 14' (note the space after §) — each '§ N.'
   tag appears exactly twice in this file: on its TOC line here and on the
   section banner. Keep this list and the banners in sync; a test
   (tests/css-toc.test.js) enforces it.

   § 1.  CSS Variables — design tokens (colors, spacing, type) + theme logo swap
   § 2.  Reset & Base — box-sizing, reduced motion, element defaults, focus
   § 3.  Typography — heading/paragraph scale, section-label eyebrow
   § 4.  Layout — container widths, main-content offset, anchor margins
   § 5.  Header — fixed glass site header, nav links, monogram logo
   § 6.  Footer — site footer bar + contact links
   § 7.  Buttons — btn variants (primary, secondary, platform-branded)
   § 8.  Archive Page — /projects/ card grid + sentence filter UI
   § 9.  Project Page — project page background + hero section shell
   § 10. Above-Fold Project Summary — hero step: identity, metrics, info card
   § 11. Scrollytelling Hero — pinned scene stack + strategy context
   § 12. Design Polish — gradient text, Ken Burns, reveals, narrative sections
   § 13. Pre-launch Section — lead-gen storyboard + countable funnel finale
   § 14. Campaign Walkthrough — phone/browser-frame guided page tour
   § 15. Project Media & Social — screenshots, reviews, video, social embeds
   § 16. Project HUD — fixed wayfinding local-nav on project pages
   § 17. Pagination & Related — prev/next band + related-projects grid
   § 18. Education Acts — anchor + shared coursework chips (on About)
   § 19. Responsive — global small-screen overrides
   § 20. Large Screen Enhancements — 1400px+/2800px+ .container--wide bumps
   § 21. About Page — intro/bio, contact, metrics transparency note
   § 22. Mobile Walkthrough — synced + interleaved phone-width modes
   § 23. Home Page — scrollytelling pitch beats + reveal helpers
   § 24. Hero Metric Layouts: 3-up — labeled three-metric hero treatment
   § 25. Hero Metric Layouts: 2-up — uniform open-gap metric pair
   § 26. Chapter System — shared About chapter vocabulary + experience chapters
   § 27. Document Reader — full-page reader for fixed-layout print pieces
   § 28. Transcript — text version of a document-reader piece (noindex page)
   § 29. Campaign Highlights — alternating rows of shipped-page blocks
   § 30. Article Reader — published-post shelf + as-published reader pages
   ========================================================================== */

/* === § 1. CSS Variables [:root] === */
:root {
  /* Colors */
  --color-primary: #283593;
  --color-accent: #FFC107;
  --color-teal: #00796B;
  --color-text: #3D3833;
  --color-text-light: #5D5851;
  --color-text-muted: #8A847A;
  --color-bg: #FAF8F5;
  --color-bg-white: #FFFDF9;
  --color-border: #EDE9E3;
  --color-border-dark: #DDD8D0;

  /* Platform Colors */
  --color-kickstarter: #05CE78;
  --color-indiegogo: #E51075;

  /* Typography. The web fonts use font-display: optional, so a slow first
     visit commits to the fallback for the whole session — the fallback must
     be the explicit system stack (not bare sans-serif, which some browsers,
     notably Firefox iOS, resolve to a serifless default that reads nothing
     like the design). */
  --font-heading: 'Raleway', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  --font-body: 'Source Sans 3', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;

  /* Spacing */
  --space-xs: 0.25rem;
  --space-sm: 0.5rem;
  --space-md: 1rem;
  --space-lg: 1.5rem;
  --space-xl: 2rem;
  --space-2xl: 3rem;
  --space-3xl: 4rem;

  /* Layout */
  --container-max: 1140px;
  --header-height: 64px;  /* Static header height (sticky offsets and scroll margins key off this) */
  --hud-bar-height: 44px;  /* Project HUD bar (project-hud.js) */
  --hud-height: 0px;  /* Chrome offset for sticky/anchor rules; becomes the bar
                         height once project-hud.js stamps html.hud-active */
  /* Reading-progress fill. Deliberately neutral (cool near-white), NOT teal
     (the page's highlight/outline hue) or amber (--color-accent, the reserved
     attention color: hero stat figures, focus rings, emphasis). Progress is
     quiet meta-UI, so it gets its own restrained token. Project pages are
     always dark, so a single value suffices. */
  --hud-progress-fill: #E6EAF5;

  /* Title scaling */
  --title-size-min: 1.5rem;
  --title-size-max: 2.75rem;

  /* Filter panel scaling (for short viewports) */

  /* Shadows */
  --shadow-sm: 0 2px 8px rgba(0,0,0,0.08);
  --shadow-md: 0 6px 20px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.08);
  --shadow-lg: 0 20px 40px rgba(0,0,0,0.18), 0 8px 16px rgba(0,0,0,0.12);

  /* Transitions */
  --transition-fast: 0.15s ease;
  --transition-base: 0.3s ease;

  /* Border Radius */
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 12px;
  --radius-xl: 16px;

  /* Glass Effect (shared values for header, filter panel, card overlay) */
  --glass-blur: 28px;
  --glass-saturate: 200%;
  --glass-brightness: 1.05;
  --glass-bg-light: rgba(255, 253, 249, 0.78);
  --glass-bg-dark: rgba(36, 36, 66, 0.82);
  /* Same hue, less transmission — for a LITE surface that floats over
     arbitrary scrolling content rather than a known backdrop. At 0.82 a white
     card passing underneath composites through as visible blotching and drops
     muted text to ~5.3:1; 0.92 holds ~7.3:1 against any backdrop and cuts the
     light/dark variance across the surface by about two thirds. */
  --glass-bg-dark-strong: rgba(36, 36, 66, 0.92);
  --glass-highlight-opacity: 0.20;
  --glass-inset-light: rgba(255, 255, 255, 0.4);
  --glass-inset-dark: rgba(255, 255, 255, 0.15);

  /* Glass tiers — the two sanctioned recipes. Anything frosted must use one:
     REFRACT (primary surfaces: header, card overlays)
       url(#glass-refract) blur(--glass-blur) saturate(--glass-saturate)
       brightness(--glass-brightness) — needs the Firefox fallback, which is a
       duplicate-declaration cascade, not @supports: write the plain
       blur/saturate/brightness line FIRST, then the url() line. Firefox can't
       parse url() in backdrop-filter and drops the second declaration, landing
       on the first. Order matters; don't collapse the pair.
     LITE (chrome & repeated/scroll-animated panels: logo chips, story cards,
     mobile walkthrough dock, dark related cards)
       blur(--glass-blur-lite) saturate(--glass-saturate-lite) — no SVG
       refraction by design: cheap to composite in quantity and renders
       identically in Firefox (no url() step to fall back from). */
  --glass-blur-lite: 14px;
  --glass-saturate-lite: 150%;
  --glass-fill-deep: rgba(13, 15, 27, 0.78);   /* translucent deep-navy panel fill */
  --glass-fill-card: rgba(13, 18, 33, 0.93);   /* near-opaque story-card fill */
  --glass-edge-lite: rgba(255, 255, 255, 0.12); /* hairline edge for LITE surfaces */

  /* Type scale — three fluid tiers + one fixed label tier. Project-page type
     derives from these; don't hand-tune new clamp() formulas per element.
       headline: hero/stat figures        subhead: role lines, card leads
       body-lg:  emphasized body copy     label:   uppercase micro-labels */
  --type-headline: clamp(2rem, 1.5rem + 2.5vw, 3rem);
  --type-subhead: clamp(1.1rem, 0.9rem + 1vw, 1.5rem);
  --type-body-lg: clamp(1rem, 0.95rem + 0.25vw, 1.125rem);
  --type-label: 1rem;

  /* Container hierarchy (widest → narrowest). --container-max is the page
     shell and stays 1140px at every width; the rest are caps inside it.
       wide:      OPT-IN exception via .container--wide — the card grids and
                  the walkthrough, the only layouts that earn the extra
                  width (fluid: 1400px at ≥1400, 2000px at ≥2800 — see
                  Large Screen Enhancements)
       feature:   wide feature layouts (campaign walkthrough)
       narrative: media/reading columns (social stack, desktop frame, video)
       prose:     long-form text measure (approach, hero above-fold) */
  --container-wide: 1140px;
  --container-feature: 1100px;
  --container-narrative: 900px;
  --container-prose: 720px;
}

/* Dark Theme */
[data-theme="dark"] {
  --color-primary: #7986CB;
  --color-accent: #FFD54F;
  --color-teal: #4DB6AC;
  --color-text: #F0F0F0;
  --color-text-light: #C8C8C8;
  --color-text-muted: #C5CBD2;
  --color-bg: #1A1A2E;
  --color-bg-white: #242442;
  --color-border: #3A3A5C;
  --color-border-dark: #4A4A6A;
  --shadow-sm: 0 2px 8px rgba(0,0,0,0.25);
  --shadow-md: 0 6px 20px rgba(0,0,0,0.35), 0 2px 8px rgba(0,0,0,0.25);
  --shadow-lg: 0 20px 40px rgba(0,0,0,0.45), 0 8px 16px rgba(0,0,0,0.35);
}

/* Logo theme switching */
.site-logo-img {
  color: var(--color-text);
}
.logo-monogram-dark {
  display: none;
}
[data-theme="dark"] .logo-monogram-light {
  display: none;
}
[data-theme="dark"] .logo-monogram-dark {
  display: block;
}

[data-theme="dark"] .logo-invert {
  filter: brightness(0) invert(1);
}

/* System preference fallback (before JS loads or if JS disabled) */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-primary: #7986CB;
    --color-accent: #FFD54F;
    --color-teal: #4DB6AC;
    --color-text: #F0F0F0;
    --color-text-light: #C8C8C8;
    --color-text-muted: #C5CBD2;
    --color-bg: #1A1A2E;
    --color-bg-white: #242442;
    --color-border: #3A3A5C;
    --color-border-dark: #4A4A6A;
    --shadow-sm: 0 2px 8px rgba(0,0,0,0.25);
    --shadow-md: 0 6px 20px rgba(0,0,0,0.35), 0 2px 8px rgba(0,0,0,0.25);
    --shadow-lg: 0 20px 40px rgba(0,0,0,0.45), 0 8px 16px rgba(0,0,0,0.35);
  }

  .logo-invert {
    filter: brightness(0) invert(1);
  }

  :root:not([data-theme="light"]) .logo-monogram-light {
    display: none;
  }
  :root:not([data-theme="light"]) .logo-monogram-dark {
    display: block;
  }
}

/* === § 2. Reset & Base [* html body img a button :focus-visible .visually-hidden] === */
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html {
  font-size: 16px;
  scroll-behavior: smooth;
}

/* Animated multi-viewport glides are the large-field motion the OS setting
   opts out of (WCAG 2.3.3); JS scroll calls mirror this guard. */
@media (prefers-reduced-motion: reduce) {
  html {
    scroll-behavior: auto;
  }
}

body {
  font-family: var(--font-body);
  font-size: 1rem;
  line-height: 1.6;
  color: var(--color-text);
  background-color: var(--color-bg);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

img {
  max-width: 100%;
  height: auto;
  display: block;
  /* No click-drag ghosting/saving anywhere on the site — logos and artifact
     captures kept reading as draggable files. pointer-events stays untouched. */
  -webkit-user-drag: none;
  user-select: none;
  -webkit-user-select: none;
}

a {
  color: inherit;
  text-decoration: none;
}

button {
  font-family: inherit;
  cursor: pointer;
  border: none;
  background: none;
}

/* Visible keyboard focus (WCAG 2.4.7) — tuned for dark glass surfaces */
:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}
:focus:not(:focus-visible) {
  outline: none;
}

/* Reachable by screen readers, invisible on screen. The archive is a
   chrome-free grid by design, but a page with no <h1> gives heading
   navigation nothing to land on (WCAG 2.4.6) — this carries the page title
   without putting type back on the layout. Not clip:rect(); that is the
   deprecated form. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* === § 3. Typography [h1-h6 p .section-label] === */
h1, h2, h3, h4, h5, h6 {
  font-family: var(--font-heading);
  font-weight: 600;
  line-height: 1.3;
  color: var(--color-primary);
}

h1 { font-size: 2.5rem; }
h2 { font-size: 1.75rem; }
h3 { font-size: 1.25rem; }

p {
  margin-bottom: 1rem;
}

p:last-child {
  margin-bottom: 0;
}

/* Paragraph break inside copy fields that render as ONE HTML block (approach-
   story text/text2, walkthrough annotations & callouts — each wrapped in a
   single <p> by its template, with raw HTML from the data). Real <p> tags are
   not available here: the classed annotation <p> carries per-call-site styling
   AND the walkthrough engines pair callouts to focus points BY INDEX, so
   splitting one into two would desync the motion.
   It must be an empty <span>, NOT a <br> — margins do not apply to <br> at
   all (a styled `br.graf-break` measures as display:block with the margin set
   and still renders identically to a bare line break; verified in Chrome at
   40px, zero height change). An empty span set to display:block does take the
   margin, forces the break, and stays valid inside <p> (span is phrasing
   content). ~0.9em lands near the site's own 1rem paragraph rhythm while
   scaling with each context's font size. The copy-round applier emits this
   automatically for a literal "\n" a writer types in the board. */
.graf-break { display: block; margin-top: 0.9em; }

.section-label {
  display: block;
  font-size: 0.9rem;
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: var(--color-text-muted);
  margin-bottom: var(--space-sm);
}

/* === § 4. Layout [.container .main-content] === */
.container {
  max-width: var(--container-max);
  margin: 0 auto;
  padding: 0 var(--space-lg);
}

/* Opt-in wide shell. Only layouts that genuinely need the extra width take
   this — the archive/related card grids and the walkthrough's frame +
   annotation pair. Wide is the EXCEPTION: bumping --container-max globally
   at ≥1400 (what this replaced) dragged the header, footer and every reading
   column out with it, leaving 20px gutters at 1400-1500 — text pinned to the
   bezel on the widest, most common laptop band. */
.container--wide {
  max-width: var(--container-wide);
}

.main-content {
  min-height: calc(100vh - var(--header-height));
}

/* Keep in-page anchor targets clear of the sticky header and, on project
   pages with an active HUD, the HUD bar below it
   (more specific rules, e.g. #education, still override) */
[id] {
  scroll-margin-top: calc(var(--header-height) + var(--hud-height) + var(--space-md));
}

/* Project HUD activation: project-hud.js stamps this class at init on pages
   long enough to warrant the HUD, so sticky offsets and scroll-margins are
   already correct before any user-initiated anchor jump (no toggle races). */
html.hud-active {
  --hud-height: var(--hud-bar-height);
}

/* === § 5. Header [.site-header .header-container .site-logo .site-nav .nav-link .nav-item .nav-disclosure .nav-submenu .nav-sublink] === */
/* The site is dark-only — every template hardcodes data-theme="dark" and
   nothing writes it at runtime — so these rules carry the dark values
   directly instead of pairing a light base with a [data-theme] override. */
.site-header {
  position: sticky;
  top: 0;
  z-index: 100;
  height: var(--header-height);
  /* Liquid glass, LITE tier. No url(#…) refraction step by design: an SVG
     filter chained into backdrop-filter only paints in Chromium. Safari
     ignores it (WebKit #245510) and Firefox discards the ENTIRE
     backdrop-filter rather than just the url() (bugzilla #1961378), taking
     the blur with it — and it can't be feature-detected, because
     CSS.supports() reports true in all three. A plain blur/saturate renders
     the same bar everywhere, which is worth more than a Chrome-only warp.
     --header-fill is re-tinted by header-glass.js as bright content scrolls
     under; this is the rest value and the fallback if sampling is
     unavailable. */
  --header-fill: rgba(36, 36, 66, 0.72);
  background: var(--header-fill);
  -webkit-backdrop-filter: blur(20px) saturate(160%);
  backdrop-filter: blur(20px) saturate(160%);
  box-shadow:
    /* Top edge catches the light */
    inset 0 1px 0 rgba(255, 255, 255, 0.22),
    /* Underside of the slab */
    inset 0 -1px 0 rgba(255, 255, 255, 0.06),
    /* Cast onto the content passing below */
    0 8px 28px rgba(0, 0, 0, 0.38);
  transition: background-color var(--transition-base), box-shadow var(--transition-base);
}

/* Specular sheen — a raking highlight down the top half of the slab. A plain
   gradient, so it costs nothing to composite and looks identical in every
   engine. */
.site-header::before {
  content: '';
  position: absolute;
  inset: 0 0 auto 0;
  height: 52%;
  background: linear-gradient(
    180deg,
    rgba(255, 255, 255, 0.13),
    rgba(255, 255, 255, 0.02) 60%,
    transparent
  );
  pointer-events: none;
}

/* Meniscus — the glass edge bleeds a short blurred fade past the bar, so
   content dissolves under it instead of hard-cutting at a border line. Held
   at the rest tint rather than --header-fill: it is a soft fade, so it does
   not need to track the adaptive tint, and a fixed value keeps it from
   interpolating through grey.

   The mask is what makes this a fade instead of a band. backdrop-filter is not
   feathered by the element's own alpha, so the blur applies at full strength
   across the whole strip and then terminates at a hard edge — which moves the
   hard cut 24px down rather than removing it, and reads as a dark bar wherever
   the backdrop has texture. Masking fades the pseudo-element's composited
   output, blur included, so the backdrop eases back into focus. That also lets
   the tint be a flat colour: one gradient now drives the whole dissolve
   instead of a background ramp and a mask ramp compounding each other. */
.site-header::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 100%;
  height: 24px;
  background: rgba(36, 36, 66, 0.5);
  -webkit-backdrop-filter: blur(7px);
  backdrop-filter: blur(7px);
  -webkit-mask-image: linear-gradient(to bottom, #000, transparent);
  mask-image: linear-gradient(to bottom, #000, transparent);
  pointer-events: none;
  transition: opacity var(--transition-base);
}

/* The project HUD is a second chrome bar pinned directly under the header
   (fixed at top: var(--header-height), z-index 95 — behind the header, so the
   meniscus paints over it). While it is up the header no longer meets page
   content, and the fade just smears the HUD's own title: the strip covers 24px
   of the 44px bar, and the title sits at y 73–98. Drop it, in step with the
   HUD's own 0.28s reveal. Keyed to .is-visible, not html.hud-active — the
   latter is stamped at init on every long page, including while the HUD is
   still hidden over the hero, where the meniscus is doing its actual job. */
body:has(.project-hud.is-visible) .site-header::after {
  opacity: 0;
}

/* Hover state — subtle lift (functional feedback) */
.site-header:hover {
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.30),
    inset 0 -1px 0 rgba(255, 255, 255, 0.08),
    0 10px 34px rgba(0, 0, 0, 0.45);
}

/* The adaptive tint crossfades; with reduced motion it snaps instead. */
@media (prefers-reduced-motion: reduce) {
  .site-header,
  .site-header::after {
    transition: none;
  }
}

.header-container {
  max-width: var(--container-max);
  margin: 0 auto;
  padding: 0 var(--space-lg);
  height: 100%;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  /* Above the sheen: ::before is positioned, so without this it would paint
     over the logo and nav rather than under them. */
  position: relative;
  z-index: 1;
}

.site-logo {
  display: flex;
  align-items: center;
}

.site-logo-img {
  height: 32px;
  width: auto;
}

.site-nav {
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 0.75em;
}

.nav-link {
  font-family: var(--font-heading);
  font-size: var(--title-size-min);
  font-weight: 400;
  color: var(--color-text-light);
  text-decoration: none;
  transition: color var(--transition-fast), font-weight var(--transition-fast);
  letter-spacing: -0.02em;
  /* Keeps the links legible when bright imagery scrolls under the glass —
     works with the adaptive fill, which handles the rest. */
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}

.nav-link:hover {
  color: var(--color-text);
}

/* Active state via weight/color only — same size as inactive so link
   positions stay put across pages */
.nav-link.active {
  font-weight: 700;
  color: var(--color-text);
}

/* --- Nav submenu (About) ------------------------------------------------
   Open state is carried by four inherited custom properties set on
   .nav-item, so the three ways a menu opens — hover, keyboard focus, and the
   disclosure button's .is-open — are declared once each instead of once per
   affected child. The panel and the chevron just read the properties.
   --nav-vis-delay is why visibility is in here: it must flip immediately on
   open and wait out the fade on close, which a single static delay can't do. */
.nav-item:focus-within,
.nav-item.is-open {
  --nav-vis: visible;
  --nav-vis-delay: 0s;
  --nav-fade: 1;
  --nav-lift: 0;
  --nav-chevron: 0.5turn;
}

/* Hover-open is for cursors only. On touch the same rule would latch open on
   the tap that follows the link, and the disclosure button is that pointer's
   way in anyway. */
@media (hover: hover) and (pointer: fine) {
  .nav-item:hover {
    --nav-vis: visible;
    --nav-vis-delay: 0s;
    --nav-fade: 1;
    --nav-lift: 0;
    --nav-chevron: 0.5turn;
  }
}

/* Escape (and a second press of the disclosure) must shut a menu the user is
   still inside — otherwise :focus-within, which put focus on the button in the
   first place, immediately reopens it and Escape appears to do nothing. Equal
   specificity to the open states, so this wins on source order alone: it has
   to stay last in the group. nav-submenu.js clears it once the pointer or
   focus genuinely leaves. */
.nav-item.is-dismissed {
  --nav-vis: hidden;
  --nav-vis-delay: 0.15s;
  --nav-fade: 0;
  --nav-lift: 1;
  --nav-chevron: 0turn;
}

/* Full header height, so the panel hangs from the bar's bottom edge with no
   dead gap for the cursor to fall through on its way down. */
.nav-item {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.15em;
  height: var(--header-height);
}

.nav-disclosure {
  display: flex;
  align-items: center;
  justify-content: center;
  /* 36 x 44: the touch-target minimum on the axis a thumb actually misses. */
  width: 2.25rem;
  height: 2.75rem;
  padding: 0;
  color: var(--color-text-light);
  transition: color var(--transition-fast);
}

.nav-disclosure:hover {
  color: var(--color-text);
}

.nav-disclosure-icon {
  width: 0.625rem;
  height: 0.375rem;
  overflow: visible;
  transform: rotate(var(--nav-chevron, 0turn));
  transition: transform var(--transition-fast);
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.4));
}

.nav-submenu {
  position: absolute;
  top: 100%;
  left: 50%;
  display: flex;
  flex-direction: column;
  padding: 0.3rem;
  /* Inherited from .site-header, so the panel tracks the adaptive tint
     header-glass.js writes as bright content passes under the bar. */
  background: var(--header-fill);
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  border: 1px solid var(--glass-edge-lite);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-md);
  visibility: var(--nav-vis, hidden);
  opacity: var(--nav-fade, 0);
  transform: translateX(-50%) translateY(calc(var(--nav-lift, 1) * -4px));
  transition:
    opacity var(--transition-fast),
    transform var(--transition-fast),
    visibility 0s linear var(--nav-vis-delay, 0.15s);
}

@media (prefers-reduced-motion: reduce) {
  .nav-disclosure-icon,
  .nav-submenu {
    transition: none;
  }
  .nav-submenu {
    transform: translateX(-50%);
  }
}

.nav-sublink {
  font-family: var(--font-heading);
  font-size: 1rem;
  font-weight: 400;
  letter-spacing: -0.02em;
  color: var(--color-text-light);
  white-space: nowrap;
  padding: 0.55rem 0.85rem;
  border-radius: var(--radius-sm);
  transition: color var(--transition-fast), background-color var(--transition-fast);
}

.nav-sublink:hover,
.nav-sublink:focus-visible {
  color: var(--color-text);
  background: rgba(255, 255, 255, 0.09);
}

/* === § 6. Footer [.site-footer .footer-] === */
.site-footer {
  background: var(--color-bg-white);
  border-top: 1px solid var(--color-border);
  padding: var(--space-xl) 0;
  margin-top: var(--space-3xl);
}

.footer-container {
  max-width: var(--container-max);
  margin: 0 auto;
  padding: 0 var(--space-lg);
  text-align: center;
}

.footer-text {
  font-size: 0.9rem;
  color: var(--color-text-muted);
}

.footer-links {
  margin-top: var(--space-xs);
  display: flex;
  justify-content: center;
  align-items: baseline;
  gap: var(--space-sm);
  font-size: 0.9rem;
}
.footer-link {
  color: var(--color-text-muted);
  text-decoration: none;
  transition: color var(--transition-fast);
}
.footer-link:hover {
  color: var(--color-teal);
}
.footer-sep {
  color: var(--color-text-muted);
}

/* === § 7. Buttons [.btn .btn-] === */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0.75rem 1.5rem;
  font-size: 0.95rem;
  font-weight: 600;
  border-radius: var(--radius-md);
  transition: all var(--transition-base);
  text-transform: uppercase;
  letter-spacing: 0.5px;
}

.btn-primary {
  background: var(--color-primary);
  color: white;
}

.btn-primary:hover {
  background: #1a237e;
  transform: translateY(-2px);
  box-shadow: var(--shadow-md);
}

.btn-secondary {
  background: var(--color-bg);
  color: var(--color-primary);
  border: 2px solid var(--color-primary);
}

.btn-secondary:hover {
  background: var(--color-primary);
  color: white;
}

.btn-kickstarter {
  background: var(--color-kickstarter);
  color: white;
}

.btn-indiegogo {
  background: var(--color-indiegogo);
  color: white;
}

/* === § 8. Archive Page [.fs-sentence .fs-search .fss- .projects-grid .project-card .card- .overlay- .logo-chip .type-badge] === */

/* Archive filter sentence — the archive's only filter control.
   Markup is built by filter-sentence.js; see that file for the interaction
   model. .fs-sentence is the sticky container, .fss-* its internals. */

/* -- Sticky sentence container ------------------------------------ */
.fs-sentence {
  position: sticky;
  top: calc(var(--header-height) + 8px);
  z-index: 40; /* below the fixed header (100); choosers overlay at 200+ */
  margin: 0 0 var(--space-xl);
  border: 1px solid transparent;
  border-radius: var(--radius-lg);
  transition: background-color var(--transition-base), border-color var(--transition-base), padding var(--transition-base);

  /* Intro-roll tuning, in one place — see the .fss-rolling block below.
     The three move together: --roll-swell must cover the transform's
     overhang, which is (chip layout width x (--roll-scale - 1) / 2), ~12px
     on the ~205px locked chip at 1.12. Under-reserve it and the swell paints
     over the full stop that ends the sentence. --roll-settle is the single
     duration for the whole recovery — width, scale, margin and colour all
     ride it, and filter-sentence.js reads this very property so its cleanup
     timer can never drift out of sync with the curve. */
  --roll-scale: 1.12;
  --roll-swell: 14px;
  --roll-settle: 0.34s;
}
.fss-sentinel { height: 1px; margin-bottom: -1px; visibility: hidden; pointer-events: none; }

/* At rest: editorial intro type, no glass.
   Deliberately NO prose measure. A 45–82ch cap exists to shorten the return
   sweep between lines of a paragraph; this is a single line with no return
   sweep, and half its width is chip chrome (padding, border, caret) rather
   than characters — so a character-count cap wraps it while ~half the column
   sits empty. Unbounded, the sentence inherits the container's box, which is
   exactly the grid's width, so it wraps only on real overflow and its full
   line aligns with the cards beneath it. */
.fss-sentence {
  margin: 0;
  font-size: 1.08rem;
  line-height: 1.95;
  color: var(--color-text-muted);
}
/* Stuck: compact floating glass-LITE bar. Hairline border + inset
   highlight only — no blurred shadow on this large repeated-composite
   surface (matches the LITE tier contract in style.css). */
.fs-sentence.is-stuck {
  background: var(--glass-bg-dark-strong);
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  border-color: var(--glass-edge-lite);
  box-shadow: inset 0 1px 0 var(--glass-inset-dark); /* hairline, zero blur */
  padding: 0.55rem 0.95rem;
}
/* Compactness comes from the padding and the tighter line box, NOT from
   shrinking the type. This is the archive's only filter control, so it holds
   the 1rem body floor even when stuck; below that it reads as caption text
   the eye skips rather than a control it can operate. The step down from the
   1.08rem rest size is what signals the mode change. */
.fs-sentence.is-stuck .fss-sentence {
  max-width: none;
  font-size: 1rem;
  line-height: 1.9; /* chips need a taller line box than bare text did */
}
/* Chip edges carry the "this is a control" affordance, so they answer to the
   3:1 non-text threshold, not the text one. The rest-state 0.16 white reads
   at ~1.6:1 over the stuck glass — effectively invisible; these clear 3:1
   against the brightest backdrop the bar can float over. */
.fs-sentence.is-stuck .fss-slot {
  padding: 0 0.45em 0.06em;
  border-color: rgba(255, 255, 255, 0.38);
  background: rgba(255, 255, 255, 0.09);
}
.fs-sentence.is-stuck .fss-slot.fss-set { border-color: rgba(255, 213, 79, 0.5); }
.fs-sentence.is-stuck .fss-slot.fss-ghost {
  background: none;
  border-color: rgba(197, 203, 210, 0.5);
}

/* -- Slots: the words you can change. Styled as inline chips with a
   caret — the menu affordance — never as underlined text (link-alike). */
.fss-slot {
  position: relative;
  display: inline-block;
  margin: 0 0.05em;
  padding: 0.02em 0.52em 0.1em;
  border: 1px solid rgba(255, 255, 255, 0.16);
  background: rgba(255, 255, 255, 0.05);
  font: inherit;
  line-height: 1.5;
  color: var(--color-text);
  cursor: pointer;
  border-radius: 999px;
  -webkit-tap-highlight-color: transparent;
  /* transform + margin are listed for the intro roll's swell only (see
     .fss-rolling below) — hover deliberately does NOT scale these; they sit
     inside running prose. Both ride --roll-settle so the chip's shrink and
     the label's width settle are one motion rather than two. */
  transition: color var(--transition-fast), background-color var(--transition-fast), border-color var(--transition-fast),
              transform var(--roll-settle, 0.34s) ease, margin var(--roll-settle, 0.34s) ease;
}
.fss-slot:hover,
.fss-slot:focus-visible,
.fss-slot[aria-expanded="true"] {
  color: #FFFFFF;
  background: rgba(255, 213, 79, 0.1);
  border-color: rgba(255, 213, 79, 0.55);
}
/* Label color eases so the intro roll's gold (below) fades in and out
   rather than snapping. */
.fss-label { transition: color 0.25s ease; }
.fss-caret {
  display: inline-block;
  margin-left: 0.32em;
  font-size: 0.6em;
  opacity: 0.55;
  transform: translateY(-0.14em);
  transition: opacity var(--transition-fast), color var(--transition-fast);
}
.fss-slot:hover .fss-caret,
.fss-slot:focus-visible .fss-caret,
.fss-slot[aria-expanded="true"] .fss-caret { opacity: 1; color: var(--color-accent); }
/* The reset slot's ✕ — the caret's counterpart: a menu opens vs a state
   clears. Set in text metrics (not shrunk like the caret) so it reads as a
   dismiss mark, and it inherits the ghost chip's restrained hover instead of
   the caret's gold — clearing is the way out, not the emphasis. */
.fss-x {
  display: inline-block;
  margin-left: 0.34em;
  font-size: 0.95em;
  line-height: 1;
  opacity: 0.6;
  transition: opacity var(--transition-fast);
}
.fss-slot:hover .fss-x,
.fss-slot:focus-visible .fss-x { opacity: 1; }
.fss-slot.fss-set {
  font-weight: 600;
  background: rgba(255, 213, 79, 0.07);
  border-color: rgba(255, 213, 79, 0.4);
}
.fss-slot.fss-ph { color: var(--color-text-muted); }
.fss-slot.fss-ph:hover,
.fss-slot.fss-ph:focus-visible { color: #FFFFFF; }
/* Ghost = the clear control, the one slot that sits OUTSIDE the sentence
   (after its full stop) rather than inside the prose — so it opens with a
   wider gap than the inline slots' hairline margin. */
.fss-slot.fss-ghost {
  margin-left: 0.42em;
  color: var(--color-text-muted);
  background: none;
  border-style: dashed;
  border-color: rgba(197, 203, 210, 0.35);
}
.fss-slot.fss-ghost:hover,
.fss-slot.fss-ghost:focus-visible {
  color: var(--color-text);
  background: rgba(255, 255, 255, 0.06);
  border-color: var(--color-text-light);
}
@media (pointer: coarse) {
  .fss-slot::after { content: ''; position: absolute; left: -6px; right: -6px; top: -7px; bottom: -7px; }
  /* Compact stuck type has a shorter line box — extend the hit area to
     keep every slot at >= 44px tall. */
  .fs-sentence.is-stuck .fss-slot::after { top: -10px; bottom: -10px; }
}

/* -- Bar search — free-text narrowing (filter-search.js) ----------- */
/* The box leads the bar: the sentence recomposes to a new width on every
   pick, so the left edge is the bar's one fixed anchor — the input never
   moves while the prose glides beside it. Baseline-aligned so its text
   sits on the sentence's first line despite the differing line boxes. */
.fs-sentence.fs-has-search {
  display: flex;
  align-items: baseline;
  gap: 0.9rem;
}
.fs-sentence.fs-has-search .fss-sentence { flex: 1 1 auto; min-width: 0; }
.fs-search { position: relative; flex: 0 0 auto; }
/* pointer-events:none so the glass never eats a click aimed at the field —
   it sits over the input's own left padding, so the press falls through. */
.fs-search-icon {
  position: absolute;
  top: 50%;
  left: 0.68em;
  width: 0.95em;
  height: 0.95em;
  transform: translateY(-50%);
  color: var(--color-text-muted);
  pointer-events: none;
  transition: color var(--transition-fast);
}
.fs-search:hover .fs-search-icon,
.fs-search:focus-within .fs-search-icon,
.fs-searching .fs-search-icon { color: var(--color-accent); }
.fs-search-input {
  width: 13.2em; /* the icon eats ~1.2em of the box — hold the typing room */
  padding: 0.14em 1.55em 0.22em 2.1em; /* pads clear the glass and the ✕ */
  border: 1px solid rgba(255, 255, 255, 0.16);
  background: rgba(255, 255, 255, 0.05);
  font-family: inherit;
  font-size: 1rem; /* 16px floor — same iOS focus-zoom rule as the chooser search */
  line-height: 1.5;
  color: var(--color-text);
  border-radius: 999px;
  transition: background-color var(--transition-fast), border-color var(--transition-fast);
}
.fs-search-input::placeholder { color: var(--color-text-muted); opacity: 0.75; }
/* Our ✕ replaces the native cancel control so it styles the same everywhere. */
.fs-search-input::-webkit-search-decoration,
.fs-search-input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; }
.fs-search-input:hover,
.fs-search-input:focus {
  background: rgba(255, 213, 79, 0.06);
  border-color: rgba(255, 213, 79, 0.55);
}
/* Query live = a set filter — same gold as .fss-set chips. */
.fs-searching .fs-search-input {
  background: rgba(255, 213, 79, 0.07);
  border-color: rgba(255, 213, 79, 0.4);
}
.fs-search-clear {
  position: absolute;
  top: 50%;
  right: 0.3em;
  transform: translateY(-50%);
  display: none;
  width: 1.5em;
  height: 1.5em;
  font-size: 1rem;
  line-height: 1;
  color: var(--color-text-muted);
  border-radius: 50%;
}
.fs-searching .fs-search-clear { display: block; }
.fs-search-clear:hover,
.fs-search-clear:focus-visible { color: var(--color-text); }
@media (pointer: coarse) {
  .fs-search-clear::after { content: ''; position: absolute; inset: -10px; }
}
/* Stuck: same contrast bump the chips take — the rest-state hairline is
   invisible over the glass (see the 3:1 note above). */
.fs-sentence.is-stuck .fs-search-input {
  border-color: rgba(255, 255, 255, 0.38);
  background: rgba(255, 255, 255, 0.09);
}
.fs-sentence.is-stuck.fs-searching .fs-search-input {
  border-color: rgba(255, 213, 79, 0.5);
  background: rgba(255, 213, 79, 0.07);
}
/* Below the popover/sheet switch the row can't fit both — stack, box first. */
@media (max-width: 767px) {
  .fs-sentence.fs-has-search {
    flex-direction: column;
    align-items: stretch;
    gap: 0.55rem;
  }
  .fs-search-input { width: 100%; }
  /* Stuck: the compact bar has no room for an idle box — drop it (the full
     bar is one scroll-to-top away, same deal as the placeholder run). An
     active or focused query stays: a filter never hides its own state. */
  .fs-sentence.is-stuck:not(.fs-searching) .fs-search:not(:focus-within) { display: none; }
}

/* Narrow + stuck: hold to ~2 lines. Ellipsize slot labels; the JS adds
   .fss-tight (drops the unset-placeholder run) only if still over. */
@media (max-width: 520px) {
  .fs-sentence.is-stuck { padding: 0.5rem 0.75rem; }
  /* Same 1rem floor as the wide stuck bar — narrow screens are where the bar
     is stuck nearly the whole visit (the sentinel clears the header within a
     screen of scroll), so this size is what mobile mostly reads. It costs one
     line box against the two-line budget, which the measured worst case still
     fits without tripping .fss-tight. */
  .fs-sentence.is-stuck .fss-sentence { font-size: 1rem; line-height: 1.65; }
  .fs-sentence.is-stuck .fss-slot {
    max-width: 9em;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    vertical-align: bottom;
  }
}
.fss-sentence.fss-tight .fss-ph-run { display: none; }

/* The visible count — emphasized text, not a control (no chip, no caret) */
.fss-count { display: inline-block; font-weight: 600; color: var(--color-text); }

/* Signature moment: the count ticks when it changes */
.fss-tick { position: relative; display: inline-block; font-variant-numeric: tabular-nums; }
.fss-tick-old { position: absolute; left: 0; top: 0; animation: fssTickOut 0.3s ease forwards; }
.fss-tick-val { display: inline-block; }
.fss-tick-in { animation: fssTickIn 0.3s ease; }
@keyframes fssTickOut { to { opacity: 0; transform: translateY(-0.55em); } }
@keyframes fssTickIn { from { opacity: 0; transform: translateY(0.55em); } }

/* Intro roll: the industries chip is width-locked for the whole roll, so
   the sentence wraps exactly as it does at rest and the pill can never
   jump rows. Each swap fades up in place. */
.fss-roll { display: inline-block; animation: fssRollIn 0.26s ease; }
@keyframes fssRollIn { from { opacity: 0.15; transform: translateY(0.35em); } }
/* While rolling, the chip is a fixed-width centered window (inline styles set
   the width instantly, before this class lands, so nothing animates on the
   way in) and the cycling word is gold, so the roll reads as live rather
   than stuck. The width ease belongs to the settle, not here — .fss-settling
   below owns it. */
.fss-label.fss-rolling { color: var(--color-accent); }
/* The chip itself warms with its word for the roll's duration — same gold
   tint as hover/open above (graceful no-op where :has is unsupported) — and
   swells a hair, so the roll reads as an object being handled rather than
   text that happens to be changing.

   The margin is not decoration: a transform paints outside the layout box,
   and at 1.05 the chip's right edge lands ~5px past the full stop that ends
   this sentence — measured, it covered the period entirely. The margin
   reserves the overhang on both sides so the swell displaces the prose
   instead of painting over it, and it eases on the same curve as the
   transform so the two read as one gesture. Both release with the settle,
   leaving hover as the only lit state afterwards. */
.fss-slot:has(> .fss-label.fss-rolling) {
  border-color: rgba(255, 213, 79, 0.55);
  background: rgba(255, 213, 79, 0.1);
  transform: scale(var(--roll-scale, 1.12));
  margin-inline: calc(0.05em + var(--roll-swell, 14px));
}
/* The recovery. .fss-settling replaces .fss-rolling for exactly one
   transition, because the chip's gold would otherwise release on the fast
   HOVER timing while its shape took the slower settle curve — the colour
   arriving first is what made the old recovery read as two motions. A
   transition reads its timing from the after-change style, so overriding the
   whole list here puts colour, shape, spacing and the label's width on one
   curve, and hover keeps its fast timing every other moment. */
.fss-slot:has(> .fss-label.fss-settling) {
  transition: color var(--roll-settle, 0.34s) ease,
              background-color var(--roll-settle, 0.34s) ease,
              border-color var(--roll-settle, 0.34s) ease,
              transform var(--roll-settle, 0.34s) ease,
              margin var(--roll-settle, 0.34s) ease;
}
/* The width lock has to outlive the class that armed it: dropping
   .fss-rolling without this would strand an inline width with no transition
   on it and snap the chip to the final word. */
.fss-label.fss-settling {
  transition: width var(--roll-settle, 0.34s) ease, color var(--roll-settle, 0.34s) ease;
}

/* -- Chooser: shared ------------------------------------------------- */
.fss-chooser { z-index: 210; color: var(--color-text); font-family: var(--font-body); font-size: 1rem; line-height: 1.6; }
.fss-chooser-head {
  font-family: var(--font-heading);
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--color-text-muted);
  padding: 14px 18px 2px;
}
.fss-head-hint {
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: none;
  opacity: 0.75;
}
.fss-chooser-search {
  display: block;
  width: calc(100% - 28px);
  margin: 8px 14px 2px;
  padding: 8px 12px;
  font-family: inherit;
  font-size: 0.92rem;
  color: var(--color-text);
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--color-border-dark);
  border-radius: var(--radius-md);
}
.fss-chooser-search::placeholder { color: var(--color-text-muted); opacity: 0.65; }
.fss-listbox {
  position: relative;
  max-height: min(46vh, 350px);
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 8px;
}
.fss-listbox:focus-visible { outline-offset: -2px; }
.fss-option {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 38px;
  padding: 7px 10px;
  border-radius: var(--radius-md);
  font-size: 0.95rem;
  cursor: pointer;
  transition: background-color var(--transition-fast);
}
.fss-option:hover,
.fss-option.fss-active { background: rgba(121, 134, 203, 0.18); }
.fss-option[aria-disabled="true"] { opacity: 0.38; cursor: default; }
.fss-option[aria-disabled="true"]:hover { background: none; }
.fss-check { width: 1em; flex: none; color: var(--color-accent); font-weight: 700; }
.fss-opt-label { flex: 1; min-width: 0; }
.fss-option[aria-selected="true"] .fss-opt-label { font-weight: 600; }
.fss-opt-count { flex: none; font-size: 0.78rem; color: var(--color-text-muted); font-variant-numeric: tabular-nums; }
.fss-option.fss-clear { position: relative; margin-bottom: 9px; }
.fss-option.fss-clear::after {
  content: '';
  position: absolute;
  left: 10px; right: 10px; bottom: -5px;
  height: 1px;
  background: var(--color-border);
}
.fss-listbox-empty { padding: 10px 10px 12px; font-size: 0.88rem; font-style: italic; color: var(--color-text-muted); }

/* Popover (>= 768px): anchored glass panel near the word. Small transient
   overlay, so the full-glass recipe with a real shadow is fine here. */
.fss-popover {
  position: fixed;
  background: var(--glass-bg-dark);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  border: 1px solid var(--glass-edge-lite);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg), inset 0 1px 0 var(--glass-inset-dark);
  animation: fssPopIn 0.18s ease;
}
@keyframes fssPopIn { from { opacity: 0; transform: translateY(-6px) scale(0.98); } }

/* Bottom sheet (< 768px) */
.fss-scrim { position: fixed; inset: 0; z-index: 200; background: rgba(8, 9, 18, 0.5); animation: fssFadeIn 0.22s ease; }
.fss-sheet {
  position: fixed;
  left: 0; right: 0; bottom: 0;
  border-radius: var(--radius-xl) var(--radius-xl) 0 0;
  background: var(--glass-bg-dark);
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
  border-top: 1px solid var(--glass-edge-lite);
  box-shadow: 0 -18px 48px rgba(0, 0, 0, 0.5), inset 0 1px 0 var(--glass-inset-dark);
  padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 10px);
  animation: fssSheetIn 0.28s cubic-bezier(0.32, 0.72, 0.28, 1);
}
.fss-sheet-handle { width: 42px; height: 4px; margin: 10px auto 0; border-radius: 999px; background: rgba(255, 255, 255, 0.28); }
.fss-sheet .fss-listbox { max-height: 52vh; }
.fss-sheet .fss-option { min-height: 48px; }
.fss-sheet .fss-chooser-search { font-size: 16px; }
/* The sheet stays open across toggles (stacking values shouldn't cost a
   sheet cycle each), so dismissal gets an explicit thumb-sized control
   alongside scrim tap and Esc. */
.fss-sheet-done {
  position: absolute;
  top: 6px;
  right: 8px;
  min-height: 44px;
  padding: 0 16px;
  font-family: var(--font-heading);
  font-size: 0.82rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  color: var(--color-accent);
  background: none;
  border: none;
  border-radius: var(--radius-md);
  cursor: pointer;
}
@keyframes fssSheetIn { from { transform: translateY(100%); } }
@keyframes fssFadeIn { from { opacity: 0; } }

/* 16px on any coarse pointer (popover OR sheet) so iOS never auto-zooms */
@media (pointer: coarse) { .fss-option { min-height: 48px; } .fss-chooser-search { font-size: 16px; } }

@media (prefers-reduced-motion: reduce) {
  .fss-popover, .fss-sheet, .fss-scrim { animation: none; }
  .fss-tick-old { display: none; }
  .fss-tick-in, .fss-roll { animation: none; }
  .fss-slot, .fss-option, .fs-sentence { transition: none; }
}

.fss-visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

/* Projects Grid */
.projects-grid-section {
  padding-top: var(--space-3xl);
  padding-bottom: var(--space-3xl);
}

.projects-grid {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-xl);
}

/* Project Card */
.project-card {
  background: var(--color-bg-white);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-md);
  overflow: hidden;
  transition: all var(--transition-base);
  border: 2px solid var(--color-border);
  height: 100%;
}

.project-card:hover {
  transform: translateY(-6px);
  box-shadow: var(--shadow-lg);
  border-color: var(--color-teal);
}

.card-link {
  display: flex;
  flex-direction: column;
  position: relative;
  height: 100%;
}

.card-image {
  position: relative;
  aspect-ratio: 695 / 460;
  overflow: hidden;
}

.card-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform var(--transition-base);
}

.project-card:hover .card-image img {
  transform: scale(1.03);
}

/* Portrait card format — the landscape rectangle rotated (460:695 vs
   695:460), a first-class variant for projects whose source imagery is
   vertical (mobile screens, print, product covers). Same column width as
   every other card; the extra height is paid in grid rows (span 2), so
   neighbors flow around it and row heights stay uniform — a 1-column-wide
   row spanner can't create placement holes in sparse row flow, which is
   what keeps filter-motion.js's FLIP honest in a mixed grid. Flagged per
   project via cardOrientation: "portrait" in projects.json. At single
   column (§ 19) the span is a no-op: the card owns both rows, so it sizes
   to content like any other. */
.project-card--portrait {
  grid-row: span 2;
}

.project-card--portrait .card-image {
  aspect-ratio: 460 / 695;
}

/* Equal chins across the whole grid. A portrait card's total height is fixed
   by the grid (2 rows + gap), so image and chin split a constant — an aspect
   ratio on the image left whatever remained to the chin, which made the
   portrait chin ~2.8x a landscape one (267px vs 95px). Inverting the sizing
   fixes it by construction: the chin sizes to its content like every other
   card's, and the image absorbs the remainder. Self-correcting, so it holds
   when neighbouring rows change height.

   Scoped above the single-column breakpoint (§ 19, 768px): at one column the
   row is content-sized, so an image with neither aspect nor content to
   measure would collapse to zero. Below 768 the 460:695 aspect above stands.

   The image box this creates is ~1:1.74, taller than portrait art is usually
   authored (460:695 = 1:1.51), which is what --card-fill below answers. */
@media (min-width: 769px) {
  .project-card--portrait .card-image {
    aspect-ratio: auto;
    flex: 1 1 auto;
    min-height: 0;
  }

  .project-card--portrait .card-content {
    flex: 0 0 auto;
  }
}

/* Portrait fill: the art renders WHOLE (contain) over a blurred, darkened
   copy of itself, so nothing is ever cropped and the letterbox bands read as
   atmosphere rather than empty gutter. Art-independent and zero-authoring —
   it is the fallback for every portrait hero.

   A hero whose art already matches the ~1:1.74 box (extended at source, e.g.
   github-recruiting-hero) covers the fill entirely and never reveals it, so
   the two treatments compose: extend the art where the source allows, and
   this catches everything else. --card-fill is set inline on the element by
   the card partial, from the same hero filename the <img> uses.

   A plain filter on a background image, NOT backdrop-filter — no Firefox
   trap (see the header-glass note in § 5). */
.project-card--portrait .card-image::before {
  content: '';
  position: absolute;
  /* Overscanned so the blur's soft edge is pushed outside the visible box. */
  inset: -8%;
  background-image: var(--card-fill);
  background-size: cover;
  background-position: center;
  filter: blur(22px) saturate(1.1) brightness(0.7);
  transform: scale(1.04);
}

.project-card--portrait .card-image img {
  object-fit: contain;
  position: relative; /* above the ::before fill */
}

/* The chin's own content stays centred in whatever height it takes. */
.project-card--portrait .card-content {
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.card-content {
  /* Vertical padding is md, not lg: the chin height is paid twice by a
     portrait card (2 grid rows + gap), and the 24px version put portrait
     cards over the viewport budget — see the § 20 archive shell note. */
  padding: var(--space-md) var(--space-lg);
  text-align: center;
  flex: 1;
}

/* Seam between hero and chin. Nothing used to divide them, so a hero whose
   bottom edge is light merged into the light chin and the card read as one
   slab — measured at 1.03:1 (woof-meow-hello) through 1.39:1 (luvia) against
   the chin, i.e. no perceivable edge on 7 of 45 cards.
   The separation is drawn on the IMAGE side, not as a chin border, so it sits
   over the photo and cannot be scrolled under by the chin's own background:
   a hairline rule plus a short inward shadow that reads as the chin casting
   onto the image. Art-independent, so it holds for every hero. */
.card-image::after {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 12px;
  pointer-events: none;
  background: linear-gradient(to top, rgba(15, 18, 34, 0.16), transparent);
  border-bottom: 1px solid rgba(15, 18, 34, 0.22);
}

/* Light chin background in dark mode for logo visibility.
   --card-chin is declared once and reused by both dark-mode entry points
   (explicit [data-theme] and the prefers-color-scheme fallback) so the chin
   color has a single source of truth. */
:root {
  --card-chin: #E8ECEF;
  --card-chin-ink: #546E7A;
}

[data-theme="dark"] .card-content {
  background: var(--card-chin);
  border-radius: 0 0 var(--radius-xl) var(--radius-xl);
}

[data-theme="dark"] .card-content .type-badge,
[data-theme="dark"] .card-content .card-caption {
  color: var(--card-chin-ink);
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .card-content {
    background: var(--card-chin);
    border-radius: 0 0 var(--radius-xl) var(--radius-xl);
  }

  :root:not([data-theme="light"]) .card-content .type-badge,
  :root:not([data-theme="light"]) .card-content .card-caption {
    color: var(--card-chin-ink);
  }
}

.card-logos {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-lg);
  margin-bottom: var(--space-sm);
  min-height: 36px;
}

/* HEIGHT is the cap that should bind — it is what makes marks look like peers
   in the row. The old 100px width cap pre-empted it for anything wider than
   ~2.8:1, so most marks never reached the 36px box and the widest ones fell
   far short: Onewheel (12:1) landed at 8px, thin enough that the card's hover
   lift visibly shimmered it. translateY(-6px) composites the card and
   resamples its bitmap through sub-pixel offsets for the length of the
   transition; at 8px the strokes and counters are ~1px, so they merged and
   split frame to frame (half-pixel resampling delta 85.7 RMS, worst on the
   grid). Letting width run to 260px lets every mark but the ultra-wide ones
   reach a uniform 36px, and carries Onewheel to 22px — enough stroke weight
   to stop the shimmer and to survive the dark hover overlay.

   260px is the ceiling because the chin's inner width is 291px at the 3-col
   desktop grid; min() keeps it inside narrower cards. */
.client-logo {
  max-height: 36px;
  max-width: min(260px, 100%);
  object-fit: contain;
}

/* Two marks share one row (Hallmark + Warner Bros. is the only such card).
   They must fit side by side within the same 291px chin, so each gets half
   the allowance — otherwise flex-shrink resolves the overflow for us and
   sizes them unequally, by source aspect rather than by intent. */
.card-logos:has(.client-logo + .client-logo) .client-logo {
  max-width: min(120px, 100%);
}

/* Silhouette treatment for logos supplied only as white-on-transparent, which
   are invisible on the card's light chin (measured 1.11–1.19:1). Correct ONLY
   for marks that are genuinely monochrome — it flattens color, so a logo whose
   brand accent carries meaning takes .logo-darken below instead.

   Deliberately NOT called .logo-invert: that name is already taken by § 1,
   where it means the OPPOSITE (brightness(0) invert(1) — whiten a dark logo
   for a dark background). The card chin is light, so it needs the reverse.
   Two names because they are two different jobs, on two different grounds. */
.logo-silhouette {
  filter: brightness(0);
}

/* Backing pad for light logos whose accent color IS the brand (Marseille's
   orange square, Back Support's mint wedge). A silhouette would erase those
   accents, so instead of recoloring the mark, the mark keeps its real colors
   and gets the dark ground it was drawn for. Sized to the logo, not the chin,
   so the chin stays light for its neighbours.

   border-box so the pad is paid OUT OF the 36px height budget rather than
   added on top of it. Under the old 100px width cap these two marks were
   short enough that content-box padding still cleared 36px; once height
   became the binding cap they hit 36 + 12.8 = 48.8px and pushed their whole
   grid row ~13px taller, which is exactly the variable-chin-height problem
   the .card-types clamp below exists to prevent. */
.logo-darken {
  padding: 0.4rem 0.6rem;
  border-radius: var(--radius-md);
  background: #1F2438;
  box-sizing: border-box;
}

/* ── Liquid-glass logo chip ──────────────────────────────────────────────
   Client logos sit on the dark page background, where dark logos vanish and
   light ones blend in. Each hero client logo is wrapped in a frosted glass
   chip; main.js (initLogoChips) measures the logo's luminance and sets the
   --chip-* properties so the glass tone inverts the logo — light glass behind
   dark logos, dark glass behind light/white logos — keeping every brand
   legible in its real colors. The defaults below are a light glass (works for
   the dark/colored majority) used until JS refines, or if a logo can't be
   sampled. */
.logo-chip {
  display: inline-block;
  line-height: 0;
  vertical-align: middle;
  padding: 0.5rem 0.85rem;
  border-radius: 16px;
  background: var(--chip-bg, rgba(255, 255, 255, 0.7));
  border: 1px solid var(--chip-border, rgba(255, 255, 255, 0.5));
  /* LITE glass tier (see :root) — chips repeat per client logo */
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.22),
              inset 0 1px 0 var(--chip-sheen, rgba(255, 255, 255, 0.6)),
              inset 0 0 0 1px rgba(255, 255, 255, 0.04);
  transition: background 0.45s ease, border-color 0.45s ease;
}

/* Real logo colors inside the chip — overrides the white-silhouette filter the
   scrolly hero otherwise applies, and normalizes sizing across hero variants.
   max-height + width:auto preserves each logo's aspect ratio (no distortion). */
.hero-client .logo-chip .client-logo,
.hero-scrolly .logo-chip .client-logo,
.client-section .logo-chip .client-logo,
.logo-chip .client-logo {
  filter: none;
  display: block;
  height: 32px;       /* definite height — SVGs without intrinsic size scale to it */
  width: auto;
  max-width: 150px;
  object-fit: contain;
}

/* Labeled chip — a roomier card that wraps the "Client" label and the logo(s)
   together, giving the logo space to breathe. The label color (--chip-fg) is set
   by initLogoChips to contrast the adaptive glass tone. */
.logo-chip--labeled {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  gap: 0.7rem;
  line-height: normal;
  padding: 0.85rem 1.6rem 1rem;
}

.logo-chip-label {
  font-size: 0.72rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 1.5px;
  color: var(--chip-fg, rgba(22, 24, 38, 0.65));
}

.logo-chip-logos {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-lg);
  line-height: 0;
}

/* Bigger logo inside the labeled chip for more presence/breathing room.
   The compound .logo-chip.logo-chip--labeled selector outranks the hero and
   client-section logo-size overrides above. */
.logo-chip.logo-chip--labeled .client-logo {
  height: 42px;
  max-height: 42px;   /* override the base .client-logo max-height: 36px cap */
  max-width: 170px;
}

.card-caption {
  font-size: 0.8rem;
  font-style: italic;
  color: var(--color-text-muted);
  line-height: 1.35;
  margin-bottom: var(--space-xs);
}

/* Clamped to one line of badges. Chin height sets grid row height, so a
   wrapped badge row used to make its whole row ~23px taller — and a portrait
   card, spanning two rows, paid that twice. The full type list still shows in
   the hover/focus overlay and drives the filters via data attributes; the
   chin row is a teaser, not the record. Whole badges wrap to the hidden line
   (flex wrap moves complete items), so nothing ever shows half-clipped. */
.card-types {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  align-content: flex-start;
  gap: var(--space-xs);
  max-height: 20px; /* one 0.75rem badge line (19.2px computed) */
  overflow: hidden;
}

/* Badge Base (shared across card, overlay, project page) */
.type-badge,
.overlay-type-badge,
.deliverable-badge {
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.4px;
}

.type-badge {
  font-size: 0.75rem;
  color: var(--color-text-muted);
}

/* Separator precedes every badge after the first (instead of trailing every
   badge but the last) so the .card-types one-line clamp never ends a visible
   line with a dangling dot — a badge that wraps to the hidden line takes its
   separator with it. Renders identically for fully visible lists. */
.type-badge + .type-badge::before {
  content: '• ';
  margin-right: var(--space-xs);
}

.card-overlay {
  position: absolute;
  inset: 0;
  /* Liquid Glass effect - blurs the card image underneath */
  background: var(--glass-bg-light);
  backdrop-filter: blur(calc(var(--glass-blur) - 4px)) saturate(var(--glass-saturate)) brightness(var(--glass-brightness));
  backdrop-filter: url(#glass-refract) blur(calc(var(--glass-blur) - 4px)) saturate(var(--glass-saturate)) brightness(var(--glass-brightness));
  -webkit-backdrop-filter: url(#glass-refract) blur(calc(var(--glass-blur) - 4px)) saturate(var(--glass-saturate)) brightness(var(--glass-brightness));
  box-shadow:
    /* Inner top edge highlight */
    inset 0 1px 0 rgba(255, 255, 255, 0.5),
    /* Inner bottom subtle shadow */
    inset 0 -1px 0 rgba(0, 0, 0, 0.02),
    /* Fresnel edge glow — tight inner ring */
    inset 0 0 24px 0 rgba(255, 255, 255, 0.08),
    /* Fresnel edge glow — wider ambient wash */
    inset 0 0 30px rgba(255, 255, 255, 0.1),
    /* Soft outer glow */
    0 0 0 1px rgba(255, 255, 255, 0.2);
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  transition: opacity var(--transition-base);
}

/* The overlay carries information that exists nowhere else on the card (the
   raised figure, the platform, the full deliverable list), so it cannot be
   hover-only: keyboard users tabbing the grid would never reach it. Focus
   reveals it on the same terms as hover — but only KEYBOARD focus
   (:focus-visible): a mouse click also focuses the link, and browsers restore
   that focus on back-navigation, which left the clicked card's overlay stuck
   open while the reader hovered other cards.
   Touch is handled separately (§ below): a coarse pointer has no hover state,
   and hijacking the first tap would break the card's job of being a link. */
.project-card:hover .card-overlay,
.project-card:has(:focus-visible) .card-overlay {
  opacity: 1;
}

/* Touch: there is no hover, and stealing the first tap would break the card's
   job of being a link. Instead the overlay is revealed for the card the reader
   has scrolled to (.is-revealed, set by card-overlay-touch.js on coarse
   pointers), so the metrics arrive by reading rather than by interaction.
   Purely additive — without JS a touch card is exactly what it is today. */
.project-card.is-revealed .card-overlay {
  opacity: 1;
}

.project-card.is-revealed .overlay-content {
  opacity: 1;
  transform: translateY(0);
}

/* Specular highlight on glass overlay — fixed radial hot spot, high and centered */
.card-overlay::before {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(
    ellipse 80% 60% at 50% 30%,
    rgba(255, 255, 255, 0.15) 0%,
    rgba(255, 255, 255, 0.05) 30%,
    transparent 70%
  );
  pointer-events: none;
  border-radius: inherit;
}

/* Edge vignette - simulates thicker glass at edges (Apple's lens effect) */
.card-overlay::after {
  content: '';
  position: absolute;
  inset: 0;
  /* Radial gradient: transparent center, more opaque at edges */
  background: radial-gradient(
    ellipse 85% 85% at 50% 50%,
    transparent 0%,
    transparent 50%,
    rgba(255, 253, 249, 0.25) 80%,
    rgba(255, 253, 249, 0.4) 100%
  );
  pointer-events: none;
  border-radius: inherit;
}

/* Dark mode glass overlay - higher opacity + stronger blur to prevent logo bleed-through */
[data-theme="dark"] .card-overlay {
  background: rgba(36, 36, 66, 0.90);
  backdrop-filter: blur(32px) saturate(var(--glass-saturate)) brightness(var(--glass-brightness));
  backdrop-filter: url(#glass-refract) blur(32px) saturate(var(--glass-saturate)) brightness(var(--glass-brightness));
  -webkit-backdrop-filter: url(#glass-refract) blur(32px) saturate(var(--glass-saturate)) brightness(var(--glass-brightness));
  box-shadow:
    /* Inner top edge highlight */
    inset 0 1px 0 rgba(255, 255, 255, 0.15),
    /* Inner bottom subtle shadow */
    inset 0 -1px 0 rgba(0, 0, 0, 0.1),
    /* Fresnel edge glow — tight inner ring, more subtle in dark mode */
    inset 0 0 24px 0 rgba(255, 255, 255, 0.03),
    /* Fresnel edge glow — wider ambient wash */
    inset 0 0 30px rgba(255, 255, 255, 0.05),
    /* Soft outer glow */
    0 0 0 1px rgba(255, 255, 255, 0.08);
}

[data-theme="dark"] .card-overlay::before {
  background: radial-gradient(
    ellipse 80% 60% at 50% 30%,
    rgba(255, 255, 255, 0.12) 0%,
    rgba(255, 255, 255, 0.04) 30%,
    transparent 70%
  );
}

[data-theme="dark"] .card-overlay::after {
  background: radial-gradient(
    ellipse 85% 85% at 50% 50%,
    transparent 0%,
    transparent 50%,
    rgba(36, 36, 66, 0.3) 80%,
    rgba(36, 36, 66, 0.5) 100%
  );
}

/* Rich Hover Overlay Content */
.overlay-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: var(--space-md);
  padding: var(--space-xl);
  color: var(--color-text);  /* Dark text on light glass */
  width: 100%;
  opacity: 0;
  transform: translateY(10px);
  transition: opacity var(--transition-base), transform var(--transition-base);
}

.project-card:hover .overlay-content,
.project-card:has(:focus-visible) .overlay-content {
  opacity: 1;
  transform: translateY(0);
}

/* Dark mode: white text on dark glass */
[data-theme="dark"] .overlay-content {
  color: #fff;
}

/* Platform section (crowdfunding) */
.overlay-platform {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-sm);
  width: 100%;
}

.overlay-platform-badge {
  height: auto;
}

/* Platform-specific badge sizing for visual parity */
img.overlay-platform-badge[alt="kickstarter"] {
  max-width: 80%;
}

img.overlay-platform-badge[alt="indiegogo"] {
  max-width: 60%;
}

/* Metrics */
.overlay-metrics {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.overlay-amount {
  font-family: var(--font-heading);
  font-size: 2rem;
  font-weight: 700;
  /* Steady digit widths for the hover count-up. The shipped Raleway subset
     has no tabular figures (tnum measures identical to proportional), so
     card-metric-count.js seats mid-count digits in fixed 1ch slots; this
     declaration is free insurance if the face ever gains real tnum. */
  font-variant-numeric: tabular-nums;
}

/* Shared caption style under a big overlay figure — "from N backers"
   (crowdfunding) and revenue captions like "In the first 3 weeks"
   (non-crowdfunding, e.g. Reform RX) are the same visual role. */
.overlay-backers,
.overlay-metric-label {
  font-size: 0.9rem;
  opacity: 0.9;
}

/* Client logos (non-crowdfunding) */
.overlay-logos {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-lg);
}

.overlay-client-logo {
  max-height: 50px;
  max-width: 120px;
  /* Light mode: show original logo colors on light glass */
  filter: none;
}

/* Dark mode: white logos on dark glass */
[data-theme="dark"] .overlay-client-logo {
  filter: brightness(0) invert(1);
}

.overlay-client-name {
  font-family: var(--font-heading);
  font-size: 1.25rem;
  font-weight: 600;
}

/* Affordance hint on the revealed overlay. The whole card is already the
   link, so this is a visual cue, never a second control — hence aria-hidden
   in the partial and pointer-events:none here (a tap anywhere, including on
   this pill, follows the card's own href). It matters most on touch, where
   the reveal happens without any interaction and could otherwise read as the
   card having consumed the tap. */
.overlay-cta {
  display: inline-block;
  margin-top: var(--space-xs);
  padding: 0.32rem 0.9rem;
  border: 1px solid currentColor;
  border-radius: 999px;
  font-family: var(--font-heading);
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  opacity: 0.85;
  pointer-events: none;
}

/* Type badges in overlay */
.overlay-types {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-xs);
  max-width: 280px;
}

.overlay-type-badge {
  font-size: 0.7rem;
  padding: 0.25rem 0.5rem;
  background: rgba(0, 0, 0, 0.1);
  color: var(--color-text);
  border-radius: var(--radius-sm);
}

/* Dark mode: light badges on dark glass */
[data-theme="dark"] .overlay-type-badge {
  background: rgba(255, 255, 255, 0.2);
  color: #fff;
}

/* No Results */
.no-results {
  text-align: center;
  padding: var(--space-3xl);
  color: var(--color-text-muted);
}

.reset-filters-btn {
  margin-top: var(--space-md);
  padding: 0.5rem 1rem;
  background: var(--color-primary);
  color: white;
  border-radius: var(--radius-md);
}

/* === § 9. Project Page [.project-page .project-hero-section] === */
.project-page {
  background: var(--color-bg-white);
}

/* Hero */
.project-hero-section {
  padding: var(--space-2xl) 0;
}

/* === § 10. Above-Fold Project Summary [.project-summary-section .hero- .metrics- .project-info-card .featured-metric] === */
.project-summary-section {
  padding: calc(var(--header-height) + var(--space-xl)) 0 var(--space-2xl);
  background: var(--color-bg-white);
}

/* Above-fold hero step: one viewport when content fits, growing instead of
   clipping behind the fixed header when it doesn't (short/mobile viewports) */
.scrolly-step[data-step="hero"] {
  min-height: calc(100vh - var(--header-height));
  /* iOS Safari: 100vh is the LARGE viewport (extends behind the collapsed
     toolbar), which shoves the metrics floor under the browser chrome on
     load. svh sizes to what's actually visible with the toolbar showing. */
  min-height: calc(100svh - var(--header-height));
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding-top: var(--space-lg);
  padding-bottom: var(--space-lg);
  gap: var(--space-2xl);
}

.scrolly-step[data-step="hero"] > .container {
  will-change: transform, opacity;
}

.scrolly-step[data-step="hero"] .project-title {
  text-align: center;
  margin-bottom: var(--space-lg);
}

/* Single centered card: role+client → summary → deliverables. Shares the
   title's center axis so the fold reads top-down on one line of sight. */
.hero-above-fold {
  max-width: var(--container-prose);
  margin-inline: auto;
}

/* Hero glass card.
   The fill is kept dark and the bg-behind-glass is dimmed (--glass-brightness)
   so the light card text stays legible over any region of the hero — dark
   aurora or bright projector wall. The background transitions smoothly so the
   fill never reads as a hard edge.
   LITE glass tier on purpose: no url() SVG-refraction step. The url() chain
   forked the render three ways — Chrome warped+brightened the backdrop via
   feDisplacementMap, Safari dropped the url() unreliably, and Firefox
   (which can't do url() in backdrop-filter at all) fell to a separate darker
   -moz-element fallback that read best. Plain blur/saturate/brightness with
   the darker Firefox-tier values renders one identical, legible card in all
   three engines. */
.project-info-card {
  position: relative;
  --glass-brightness: 0.55;
  --glass-card-saturate: 150%;
  /* --card-fill is set per-card by initGlassCards() from the brightness of the hero
     background behind it (darker fill over brighter bg). Falls back to a safe dark
     fill. The transition makes the adaptive change a smooth fade, never a hard cut. */
  background: var(--card-fill, rgba(6, 8, 18, 0.45));
  transition: background 0.5s ease;
  backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-card-saturate)) brightness(var(--glass-brightness));
  -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-card-saturate)) brightness(var(--glass-brightness));
  border: 1px solid rgba(255, 255, 255, 0.2);
  border-radius: var(--radius-md);
  padding: var(--space-lg);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.15),
    inset 0 -1px 0 rgba(0, 0, 0, 0.05),
    inset 0 0 24px 0 rgba(255, 255, 255, 0.04),
    0 4px 24px rgba(0, 0, 0, 0.15);
}

/* Specular highlight — simulates light catching the glass surface */
.project-info-card::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: linear-gradient(
    135deg,
    rgba(255, 255, 255, 0.18) 0%,
    rgba(255, 255, 255, 0.05) 40%,
    transparent 60%
  );
  pointer-events: none;
}


.info-card-label {
  display: block;
  font-size: 0.72rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 1px;
  color: var(--color-teal);
  margin-bottom: var(--space-xs);
}

/* Project type, demoted to an eyebrow above the page title */
.project-type-eyebrow {
  text-align: center;
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 2px;
  color: var(--color-teal);
  margin: 0 0 var(--space-sm);
}

/* Deliverables sit as a compact footer row inside the card */
.hero-deliverables {
  margin-top: var(--space-lg);
}

.info-card-deliverables {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs);
}

.project-info-card .deliverable-badge {
  display: block;
  width: fit-content;
  font-size: 0.8rem;
  padding: 0.3rem 0.75rem;
  background: transparent;
  border: 1px solid rgba(255, 255, 255, 0.25);
  border-radius: var(--radius-sm);
  color: rgba(255, 255, 255, 0.85);
}

/* Badges that name an on-page section are jump links — a brighter border/fill
   at rest plus hover/cursor feedback, no arrow glyph: the hero CTA owns the
   scroll cue, and glyph-less chips keep unlinked siblings from reading as
   broken next to linked ones. */
/* Compound selector: must outrank the later .hero-scrolly .deliverable-badge
   restyle, which would otherwise flatten links back to the inert look. */
.project-info-card .deliverable-badge.deliverable-badge--link {
  display: inline-flex;
  align-items: center;
  text-decoration: none;
  cursor: pointer;
  /* Resting distinction from inert badges (0.25 border): links carry a
     brighter border and stronger fill */
  border-color: rgba(255, 255, 255, 0.45);
  background: rgba(255, 255, 255, 0.12);
  transition: border-color var(--transition-fast), background var(--transition-fast), color var(--transition-fast);
}

.project-info-card .deliverable-badge--link:hover {
  border-color: rgba(255, 255, 255, 0.65);
  background: rgba(255, 255, 255, 0.12);
  color: #fff;
}

/* Focus on glass: the accent outline alone can wash out over a light or
   photo-lit glass region — pair it with a dark background lift and extra
   offset so the ring always has a contrasting seat. */
.project-info-card .deliverable-badge--link:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 3px;
  background: rgba(0, 0, 0, 0.35);
  border-color: rgba(255, 255, 255, 0.65);
}

/* Role + client read as one left-aligned byline pair at the top of the card,
   divided by a hairline rule — not flung to opposite edges. Keeps the top row
   in the same left column as the impact copy and deliverables below it. */
.hero-card-top {
  display: flex;
  justify-content: flex-start;
  gap: var(--space-xl);
  align-items: stretch;
  margin-bottom: var(--space-lg);
}

.hero-role-value {
  display: block;
  font-family: var(--font-heading);
  font-size: var(--type-subhead);
  font-weight: 700;
  color: var(--color-primary-light, #8ba4f8);
}

/* Agency suffix ("· Rainfactory") — present so agency work never reads as
   freelance, muted so the role keeps the emphasis. Text-only fallback for
   agencies with no logo on file; when agencyLogo exists both heroes render
   the "at <lockup>" credit below instead. */
.hero-role-agency {
  font-weight: 500;
  font-size: 0.8em;
  color: var(--color-text-muted);
  white-space: nowrap;
}

/* Agency lockup folded into the role block — a quiet employer credit under
   the role value ("at <lockup>"), not a labeled logo column, so Clients stays
   the card's only peer-brand row. Dimmed and small: it must read subordinate
   to both the role text above it and the client logos beside it. White
   knockout matches the card's reversed-logo treatment. */
.hero-role-agency-credit {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-top: var(--space-xs);
  font-size: 0.8rem;
  color: var(--color-text-muted);
}

.hero-role-agency-logo {
  height: 18px;
  width: auto;
  filter: brightness(0) invert(1);
  opacity: 0.7;
}

/* Standard hero: the credit slots between the role line and the client chip,
   so it takes over the role's bottom gap rather than adding a second one —
   the credit hugs the role it qualifies, and the chip keeps its breathing
   room below. */
.project-role:has(+ .hero-role-agency-credit) {
  margin-bottom: var(--space-xs);
}

.project-role + .hero-role-agency-credit {
  margin-top: 0;
  margin-bottom: var(--space-md);
}

/* When the role block carries the agency credit it runs deeper than the
   client column's content, leaving dead space under the client logos. Grow
   the logos and center them in the stretched column so the two columns read
   as evenly weighted. Scoped via :has() to agency-credit cards only — every
   other scrolly hero keeps the standard 28px treatment. */
.project-info-card:has(.hero-role-agency-credit) .hero-client {
  justify-content: center;
}

.project-info-card:has(.hero-role-agency-credit) .hero-client .client-logos .client-logo {
  height: 34px;
  max-height: 34px;
  max-width: 175px;
}

.project-info-card:has(.hero-role-agency-credit) .hero-client .client-logos .client-logo--knockout {
  height: 27px;
  max-height: 27px;
}

.project-info-card .impact-summary {
  font-size: var(--type-body-lg);
  line-height: 1.7;
  /* 0.94 alpha + a soft shadow: at 0.85 the copy fell to ~4.4:1 against the
     brightest composite the adaptive glass can produce (projector-beam wall
     under the 0.44-alpha card fill) — below AA for body text. */
  color: rgba(255, 255, 255, 0.94);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
  max-width: 60ch;
}

/* The hero's only wayfinding — a quiet glass pill, not a bare underline
   (which vanished against the photo). LITE glass tier (see :root). */
.hero-campaign-link {
  display: inline-block;
  margin-top: var(--space-sm);
  padding: 8px 18px;
  color: rgba(255, 255, 255, 0.92);
  text-decoration: none;
  font-size: 0.9rem;
  border: 1px solid var(--color-teal);
  border-radius: 999px;
  background: rgba(13, 15, 27, 0.35);
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  transition: color var(--transition-fast), background var(--transition-fast);
}

.hero-campaign-link:hover {
  color: #fff;
  background: rgba(13, 15, 27, 0.55);
}

.hero-campaign-link--centered {
  display: block;
  width: fit-content;
  margin-left: auto;
  margin-right: auto;
  text-align: center;
  font-size: 0.95rem;
}

/* Between the title and the card — the hero's primary next step */
.hero-campaign-link--below-title {
  margin-bottom: var(--space-xl);
}

/* The work-sample jump is desktop wayfinding only: at phone widths the fold
   budget is tight and the campaign deliverable chip inside the card links to
   the same walkthrough, so the pill costs more space than it earns. */
@media (max-width: 768px) {
  .hero-campaign-link--sample {
    display: none;
  }
}

.hero-client {
  display: flex;
  flex-direction: column;
  padding-left: var(--space-xl);
  border-left: 1px solid rgba(255, 255, 255, 0.18);
}

/* Client logo sits bare inside the hero card (no chip), beneath the "Client"
   label like the role value. On the dark card it uses the reversed/white treatment
   (inherited from .hero-scrolly .client-logo). Kept small and slightly dimmed so
   it reads as attribution — the card headlines the role, not the client. */
.hero-client .client-logos .client-logo {
  height: 28px;
  max-height: 28px;
  max-width: 130px;
  width: auto;
  opacity: 0.85;
}

/* Sit the logo on the same line as the role value: match the role value's top
   offset (label margin-bottom only) and vertically center the logo against that
   line so the two byline items read as an even pair, not staggered heights. */
.hero-client .client-logos {
  gap: var(--space-md);
  margin-top: var(--space-xs);
  align-items: center;
  min-height: var(--type-subhead);
}

/* Desktop: a slightly smaller logo, alignment inherited from the base rule. */
@media (min-width: 769px) {
  .hero-client .client-logos .client-logo {
    height: 24px;
    max-height: 24px;
    max-width: 120px;
  }
}

/* Metrics — start subtle, JS drives to bright on scroll */
.hero-metrics-floor {
  text-align: center;
  transform-origin: center center;
  position: relative;
  will-change: transform;
}

/* Heading + attribution scale together as one unit — JS drives the group
   transform (growing with the figures, at half their rate) */
.metrics-heading-group {
  transform-origin: center center;
  will-change: transform;
  margin-bottom: var(--space-xl);
}

.metrics-heading {
  font-size: 1rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 1.5px;
  /* Visible-on-load floor (matches HEADING_FLOOR_OPACITY in main.js): 0.3 read
     as a rendering artifact over the mid-brightness wall texture, so rest at
     the legible minimum. JS brightens it to full during the pinned phase. */
  color: rgba(255, 255, 255, 0.5);
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
  margin-bottom: var(--space-xs);
}

/* Attribution subline — ties the figures to the work. The color here is the
   no-JS resting state; with motion enabled, main.js splits the line into
   per-character spans (hidden until typed, below) and drives the color
   brighter than the heading since it carries the claim, not just the label. */
.metrics-attribution {
  font-size: 1.15rem;
  font-style: italic;
  color: rgba(255, 255, 255, 0.5);
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
  margin: 0;
}

/* Typewriter reveal (motion-safe, JS-built): every glyph occupies its final
   position from first paint — the line's layout (and the pinned centering
   math that measures it) never shifts — and switches on per keystroke as the
   pinned metrics take focus. Words are atomic so the hidden text wraps
   exactly like the plain line. Without JS or under reduced motion the split
   never happens and none of these rules match. */
.metrics-attribution .tw-word {
  display: inline-block;
  white-space: nowrap;
}

.metrics-attribution .tw-char {
  opacity: 0;
}

.metrics-attribution .tw-char.tw-on {
  opacity: 1;
}

/* Caret trails the last typed character. Negative margin cancels its width
   so hopping between words never nudges the centered line. */
.metrics-attribution .tw-caret {
  display: inline-block;
  width: 2px;
  height: 1em;
  margin-right: -2px;
  background: currentColor;
  vertical-align: -0.12em;
  opacity: 0;
}

.metrics-attribution .tw-caret.tw-on {
  opacity: 1;
  animation: tw-caret-blink 0.9s steps(1) infinite;
}

@keyframes tw-caret-blink {
  50% { opacity: 0; }
}

/* No scroll ramp under reduced motion — the attribution must not stay at its
   dim resting state (the heading's 0.5 resting floor is already legible) */
@media (prefers-reduced-motion: reduce) {
  .metrics-attribution {
    color: rgba(255, 255, 255, 0.85);
  }
}

/* Pre-JS flash guard. main.js splits this line into per-glyph .tw-char spans
   that type in during the pinned phase, but that runs after first paint — so
   for one frame the raw text shows at its resting color. With motion enabled
   (:root.js-motion, set in <head>) hide the line up front via visibility (the
   box still reserves layout, so the pinned centering math is unaffected) and
   let the typed glyphs switch themselves back on. No-JS / reduced motion never
   get the class, so their static visible fallback above is untouched. */
:root.js-motion .metrics-attribution {
  visibility: hidden;
}
:root.js-motion .metrics-attribution .tw-char.tw-on,
:root.js-motion .metrics-attribution .tw-caret.tw-on {
  visibility: visible;
}

.hero-metrics-floor .metrics-grid {
  justify-content: center;
  gap: var(--space-3xl);
  /* JS scales the grid up as the figures take center stage */
  transform-origin: center center;
  will-change: transform;
}

.hero-metrics-floor .metric-value {
  font-size: var(--type-headline);
  /* Values are gradient-clipped (text-fill transparent — see the shared rule
     above), so a color alpha can't dim them — the resting state is element
     opacity, which JS drives to 1 as the figures reach center stage. Rest at
     0.85: confident on load, with a little room left for the on-scroll ramp */
  opacity: 0.85;
  /* drop-shadow (not text-shadow) so it follows the gradient-clipped glyphs */
  filter: drop-shadow(0 2px 10px rgba(0, 0, 0, 0.45));
}

/* No scroll ramp under reduced motion — figures render at full strength */
@media (prefers-reduced-motion: reduce) {
  .hero-metrics-floor .metric-value {
    opacity: 1;
  }
}

.hero-metrics-floor .metric-label {
  font-size: var(--type-label);
  font-weight: 600;
  letter-spacing: 1.5px;
  color: rgba(255, 255, 255, 0.75);
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 12px rgba(0, 0, 0, 0.6);
}

/* Over the hero photo the annotation needs the labels' legibility treatment,
   not the muted body token. main.js drives it on the label ramp at 0.75 of
   their alpha (including the 3up pair dim), which is what keeps the euro
   figure subordinate to the dollar one at every point in the scroll. This
   0.66 rest mirrors that ramp's first write, so first paint doesn't flash. */
.hero-metrics-floor .metric-original {
  color: rgba(255, 255, 255, 0.66);
  text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 12px rgba(0, 0, 0, 0.6);
}

/* Hero image inside metrics floor — absolutely positioned to avoid layout shifts.
   Sized to roughly the width of the figures row above it (~500px) so the funded
   shot reads as part of the same group, not a small afterthought. */
.hero-metrics-floor .scrolly-hero-image {
  position: absolute;
  top: calc(100% + 3rem);
  left: 50%;
  transform: translateX(-50%) translateY(20px);
  max-width: 520px;
  width: 100%;
  border-radius: var(--radius-lg);
  overflow: hidden;
  opacity: 0;
  transition: opacity 0.6s ease, transform 0.6s ease;
}

.hero-metrics-floor .scrolly-hero-image.visible {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

/* Featured metric (non-scrolly pages) */
.featured-metric {
  display: flex;
  align-items: baseline;
  gap: var(--space-sm);
  margin-bottom: var(--space-lg);
}

.featured-metric-value {
  font-family: var(--font-heading);
  font-size: var(--type-headline);
  font-weight: 700;
  color: var(--color-primary);
}

.featured-metric-label {
  font-size: var(--type-label);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: var(--color-text-muted);
}

@media (max-width: 768px) {
  .hero-card-top {
    flex-wrap: wrap;
    gap: var(--space-lg);
  }

  /* Stacked on mobile: the byline divider/indent only make sense side by side,
     so drop them and let the client sit flush under the role. */
  .hero-client {
    padding-left: 0;
    border-left: none;
  }

  /* Both figures fit side by side at full size on phones (the rendered
     values are ~130px + ~65px wide) — it's only the desktop 200px min-width
     that forces the wrap. nowrap keeps the pair on one row through the JS
     scale growth (transform-only, so the layout never re-wraps mid-grow). */
  .hero-metrics-floor .metric {
    min-width: 0;
  }

  .hero-metrics-floor .metrics-grid {
    flex-wrap: nowrap;
    gap: var(--space-2xl);
  }

  /* Three metrics on mobile: a 2+1 hierarchy, not three cramped peers. The
     money figure ("In pre-orders") owns a full-width row at the SAME clamp the
     proven two-metric hero uses (Screeneo U4) — that's what reads as a confident
     headline instead of a timid shrunk row. Backers + conversion sit side by
     side beneath it, clearly secondary. Two rows stays well short of the 3-tall
     stack that starved the pinned rise and pushed KEY RESULTS off-screen; JS
     caps the pin-grow + head-scale to match (isTriMobile in main.js). The money
     stat is DOM-first in project-hero.html, so :first-child targets it
     structurally — no per-project class or data edit. Two-figure pages have no
     3rd .metric, so :has() never matches and their tuned row is untouched. */
  .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) {
    flex-wrap: wrap;
    align-items: flex-start;
    justify-content: center;
    /* tight row leading between the featured number and the pair; roomy column
       gap between the two supporting stats */
    gap: var(--space-xs) var(--space-xl);
  }

  /* Featured money number owns its own row, at full two-metric size */
  .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:first-child {
    flex-basis: 100%;
  }

  .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:first-child .metric-value {
    font-size: clamp(1.5rem, 1.2rem + 1.5vw, 2rem);
  }

  /* Supporting pair (backers + conversion): legible but clearly secondary */
  .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:not(:first-child) .metric-value {
    font-size: clamp(1rem, 0.85rem + 1vw, 1.3rem);
  }

  .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:not(:first-child) .metric-label {
    font-size: 0.6rem;
    /* trimmed from the base 1.5px so "Landing-page conversion" fits its half */
    letter-spacing: 0.4px;
  }

  .hero-metrics-floor .metric-value {
    font-size: clamp(1.5rem, 1.2rem + 1.5vw, 2rem);
  }

  /* Tighter fold rhythm so the whole ensemble (title → card → KEY RESULTS →
     both figures) lands above the fold on modern phones */
  .scrolly-step[data-step="hero"] {
    gap: var(--space-lg);
  }

  .metrics-heading-group {
    margin-bottom: var(--space-md);
  }

  /* Single line at every phone width, with headroom for the 1.14x pinned
     scale (transform overflows the container, so size against the viewport) */
  .metrics-attribution {
    font-size: clamp(0.8rem, 3.5vw, 1.05rem);
  }
}

/* === § 11. Scrollytelling Hero [.hero-scrolly .scrolly- .strategy-section] === */
.hero-scrolly {
  position: relative;
  background: #000;
  padding: 0;
}

/* Sticky scene backgrounds */
.scrolly-scene {
  position: sticky;
  top: 0;
  height: 100vh;
  overflow: hidden;
}

.scrolly-bg {
  position: absolute;
  inset: 0;
  overflow: hidden;
}

.scrolly-bg img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center 50%;
}

/* JS-managed focal-point cropping — overrides object-fit with manual sizing */
.scrolly-bg img.focal-crop {
  position: absolute;
  width: auto;
  height: auto;
  max-width: none;
  object-fit: initial;
}

/* Optional motion background (heroBackground.video). Layered over the still
   <img>, which stays as the instant poster, the glass-morphism colour source
   (#glass-source-hero), and the reduced-motion fallback. Sits below the
   readability scrims (.scrolly-overlay + .scrolly-bg::after), so text contrast
   is unchanged. Desktop crops a landscape 1920x900 loop to the viewport; on
   phones the portrait crop is tunable per-project via --hero-video-pos-mobile. */
.scrolly-bg-video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: center 42%;
}

@media (max-width: 768px) {
  /* A narrow portrait hero (~0.46:1) crops the landscape loop to a thin central
     strip — the dome (far left) and wing-spread (far right) fall outside it. So
     instead of a fixed slice, slowly pan the crop across the scene: the full
     composition reveals over time and the width mismatch becomes a cinematic
     sweep. Paused for reduced motion (the video is display:none there anyway). */
  .scrolly-bg-video {
    object-position: var(--hero-video-pos-mobile, 50% 50%);
    animation: heroVideoPan 14s ease-in-out infinite alternate;
  }

  /* ...but a loop whose SUBJECT is one centred object is the opposite case: the
     sweep walks the product out of the strip and slices it in half at both ends
     (LUA's speaker, measured, loses a third of itself at 14% and again at 86%).
     Those loops opt out and hold the fixed slice — which is what
     --hero-video-pos-mobile above was always for. Note the pan had been
     silently overriding that var for EVERY video, since an animated
     object-position beats the static one; this modifier is the only way the
     authored value has ever taken effect. */
  .scrolly-bg-video--static-mobile {
    animation: none;
  }
}

@keyframes heroVideoPan {
  from { object-position: 14% 50%; }
  to   { object-position: 86% 50%; }
}

/* Motion off: drop the video entirely and let the still <img> show through. */
@media (prefers-reduced-motion: reduce) {
  .scrolly-bg-video {
    display: none;
  }
}

/* JS-controlled dark overlay — starts transparent, darkens as you scroll */
.scrolly-overlay {
  position: absolute;
  inset: 0;
  background: #000;
  opacity: 0;
  pointer-events: none;
}

/* Hero scene darkens to the page-background indigo, not pure black: at full
   opacity the scene, the bottom fade, the walkthrough entrance cover, and the
   walkthrough backdrop scrim (rgba(26,26,46,.92)) all share one hue, so the
   hero → walkthrough corridor reads as a single continuous color instead of a
   black-to-indigo shift. The strategy section keeps the base black scrim. */
.hero-scrolly .scrolly-overlay {
  background: #1A1A2E;
}

/* Hero→page crossfade layer (JS-created by main.js, motion mode only): a
   viewport-filling flat page-bg field, last child of the sticky scene, whose
   OPACITY is scroll-driven across the .scrolly-bottom-fade stretch. It
   replaces the flow div's gradient paint in motion mode (see the js-motion
   rule at that div): blending a static full-viewport layer has no moving
   luminance edge, so the handoff can't read as a sliding smoked-glass
   sheet, and the scene un-sticks already flat #242442 — the page's own
   background — leaving no release seam. Paints above the overlay and the
   readability scrim by tree order, under .scrolly-content (z-index 1). */
.scrolly-bg-blend {
  position: absolute;
  inset: 0;
  background: var(--color-bg-white);
  opacity: 0;
  pointer-events: none;
}

/* Gradient overlay for text readability over background image */
.scrolly-scene[data-scene="1"] .scrolly-bg::after {
  content: '';
  position: absolute;
  inset: 0;
  /* Readability scrim over the hero image. --hero-scrim-boost scales every stop
     (default 1 = unchanged); busy/bright backgrounds opt into a heavier scrim
     via heroBackground.scrim in the data, set as an inline var on the section.
     --hero-scrim-bottom (heroBackground.scrimBottom) additionally scales just
     the lower stops — for images whose bright detail sits behind the metrics
     floor, so Key Results legibility can be tuned without over-darkening the
     title zone up top.
     Indigo-tinted, not black: this pseudo paints ABOVE .scrolly-overlay (later
     in tree order), so heavy scrimBottom values (e.g. Harry Potter's 2.2
     clamps the bottom stop to opacity 1) used to drive the hero's bottom edge
     to pure #000 and defeat the overlay's darken-to-page-navy design — the
     handoff into the next section read as a black-to-indigo shift. Sharing the
     page-bg hue keeps the whole corridor one color at every scrim weight. */
  background: linear-gradient(to bottom,
    rgba(26, 26, 46, calc(0.7 * var(--hero-scrim-boost, 1))) 0%,
    rgba(26, 26, 46, calc(0.5 * var(--hero-scrim-boost, 1))) 45%,
    rgba(26, 26, 46, calc(0.25 * var(--hero-scrim-boost, 1) * var(--hero-scrim-bottom, 1))) 75%,
    rgba(26, 26, 46, calc(0.45 * var(--hero-scrim-boost, 1) * var(--hero-scrim-bottom, 1))) 100%);
  pointer-events: none;
}

/* Scrolling content layer — pulled up to overlap sticky scene */
.scrolly-content {
  position: relative;
  z-index: 1;
  margin-top: -100vh;
  pointer-events: none;
}

.scrolly-content > * {
  pointer-events: auto;
}

/* Pin region: provides scroll runway for sticky metrics pause */
.scrolly-pin-region {
  min-height: 120vh;
}

.scrolly-pin-region .scrolly-step[data-step="metrics"] {
  position: sticky;
  top: 35vh;
  padding-bottom: 0;
}

/* Scroll spacer: provides scroll runway for pinned metrics phase + exit.
   Sized so the figures hold center stage solo for a beat before the funded
   hero shot fades in (at 50% of the pinned phase), then the assembled
   composition — KEY RESULTS + funded image together — dwells for the back
   half before exiting. The JS choreography is proportional (thresholds are
   pin-progress fractions), so growing the runway lengthens every beat evenly;
   the reveal is pulled earlier (see main.js) so this added runway lands as
   assembled-composition dwell, not a longer solo-numbers beat (at 180vh the
   pin spent ~1,620px on two numbers alone and read as dead scroll). 155vh
   also keeps clearance for the summary step that follows: it transits the
   viewport across the spacer's tail, and the fixed metrics floor needs its
   dwell + exit + end-lead (see exitEndLead in main.js) to fully fade before
   that transit — shorter spacers put the floor on top of the summary text.
   Overrides live here (not in the earlier media blocks) — this base rule
   comes later in source order and would silently win otherwise. */
.scrolly-pin-spacer {
  height: 155vh;
}

@media (max-width: 768px) {
  .scrolly-pin-spacer {
    height: 155vh;
  }
}

/* No summary/context step after the pin (pre-launch pages absorb it into the
   storyboard): nothing transits the spacer's tail, so it runs tight — paired
   with the crossfade exit split in main.js (EXIT profile, 40vh+45vh lead
   with a summary step → 55vh+zero lead without) and the shorter bottom fade
   + section padding below, the first storyboard card is arriving on screen
   as the funded image finishes its fade, instead of leaving a dark
   half-viewport where nothing replaces it. Class set by main.js from the
   DOM (motion mode only, so the reduced-motion collapse below still wins). */
.hero-scrolly--no-context .scrolly-pin-spacer {
  height: 118vh;
}

/* JS skips the pinned-metrics phase entirely under reduced motion, so the
   runway would be viewports of empty scroll — collapse it. */
@media (prefers-reduced-motion: reduce) {
  .scrolly-pin-spacer {
    height: 0;
  }
}

/* Individual scroll steps */
.scrolly-step {
  padding: var(--space-md) 0;
}

.scrolly-step[data-step="context"] {
  min-height: 30vh;
  display: flex;
  align-items: flex-start;
}

/* The Background summary is the page's only setup of the challenge — give it
   real presence via size and measure (not decoration). */
.scrolly-step[data-step="context"] .hero-content {
  max-width: 680px;
}

.scrolly-step[data-step="context"] .project-summary {
  font-size: clamp(1.15rem, 1rem + 0.7vw, 1.45rem);
  line-height: 1.75;
}

/* Tail of the scrolly hero: dissolves the darkened scene into the page
   background (theme-aware) so the section boundary isn't a hard black edge.
   A flow child of .scrolly-content (z-index above the sticky scene), so it
   can never paint over the summary text above it. In MOTION mode this div is
   geometry only (the js-motion rule below strips its paint) — the visible
   crossfade is the .scrolly-bg-blend opacity ramp inside the sticky scene.
   The gradient here is the no-JS / reduced-motion fallback. */
.scrolly-bottom-fade {
  /* Stops sampled from smoothstep (0.06@15, 0.28@35, 0.72@65, 0.94@85), so
     the ramp has no derivative kink at either end — a piecewise-linear ramp
     starting flat at the 0% stop drew a Mach-band pseudo-edge exactly
     there. Ends at --color-bg-white, NOT --color-bg: .project-page paints
     --color-bg-white (#242442), and the old --color-bg (#1A1A2E) endpoint
     left a second color step exactly at the fade's bottom edge. One hue at
     every stop (the scrim above is indigo-tinted too), so the whole handoff
     is a lightness ramp. */
  height: 24vh;
  background: linear-gradient(to bottom,
    rgba(36, 36, 66, 0) 0%,
    rgba(36, 36, 66, 0.06) 15%,
    rgba(36, 36, 66, 0.28) 35%,
    rgba(36, 36, 66, 0.72) 65%,
    rgba(36, 36, 66, 0.94) 85%,
    var(--color-bg-white) 100%);
}

/* Motion mode: the crossfade is painted by .scrolly-bg-blend inside the
   sticky scene instead — neutralize this div's paint but KEEP its height.
   It is the crossfade's scroll runway (main.js measures fadeTop/fadeH off
   it) and spacerEnd/exit choreography depend on current layout. */
:root.js-motion .hero-scrolly .scrolly-bottom-fade {
  background: none;
}

/* Storyboard handoff (no summary step): the next thing on screen is a
   near-viewport-tall card, not text — the fade only has to blend the scene
   out, not carry a reading beat, so it runs shorter and the storyboard
   arrives while the metrics floor is still dissolving (see the exit split
   in main.js). */
.hero-scrolly--no-context .scrolly-bottom-fade {
  height: 14vh;
}

.scrolly-step[data-step="metrics"] {
  min-height: 30vh;
  display: flex;
  align-items: center;
}

/* Product card */
.hero-image-float {
  max-width: 360px;
  border-radius: var(--radius-lg);
  overflow: hidden;
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
  border: 2px solid rgba(255, 255, 255, 0.15);
}

/* White text styles for scrolly hero */
.hero-scrolly .project-title {
  color: #fff;
}

.hero-scrolly .project-role {
  color: var(--color-accent);
}

.hero-scrolly .section-label {
  color: rgba(255, 255, 255, 0.6);
}

/* Card body copy on the scrolly hero — 0.94 alpha + soft shadow keeps AA
   over the brightest composite the adaptive glass card can produce (see
   .project-info-card .impact-summary; this later rule wins the cascade). */
.hero-scrolly .impact-summary,
.hero-scrolly .project-summary,
.hero-scrolly .highlight-item {
  color: rgba(255, 255, 255, 0.94);
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
}

.hero-scrolly .client-logo {
  filter: brightness(0) invert(1);
}

.hero-scrolly .client-logo--color {
  /* Colorful brand-mark logos (e.g. Luvia's gradient Kickstarter avatar) must not be
     flattened to a white silhouette by the brightness(0) invert(1) above. Show them in
     full color as a rounded app-icon-style mark instead. */
  filter: none;
  border-radius: 7px;
}

/* Heroes that opt into a heavier scrim (busy/bright backgrounds, e.g. Harry
   Potter) also get stronger client-logo legibility: full opacity, a soft dark
   shadow so the white marks separate from bright patches showing through the
   glass card, and a slightly larger size. Scoped to --scrim-boost so calmer
   heroes (e.g. Screeneo) keep the dimmer attribution treatment. */
.hero-scrolly--scrim-boost .client-logo {
  filter: brightness(0) invert(1) drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
.hero-scrolly--scrim-boost .client-logo--color {
  filter: drop-shadow(0 1px 3px rgba(0, 0, 0, 0.5));
}

/* Detail-preserving reversed rendition for multi-tone logos whose flat
   white-out collapses interior artwork into a silhouette (e.g. the Warner
   Bros. Discovery crest: blue shield + gold WB monogram). Grayscale→invert
   maps the dark blue near-white and the gold monogram to a dark knockout —
   reading as the brand's standard one-color reversed lockup — without
   touching the asset. Opt-in per logo via client.logo2Knockout. */
.hero-scrolly .client-logo--knockout {
  filter: grayscale(1) invert(1) brightness(1.18) drop-shadow(0 1px 3px rgba(0, 0, 0, 0.6));
}
.hero-scrolly--scrim-boost .hero-client .client-logos .client-logo {
  opacity: 1;
}
@media (min-width: 769px) {
  .hero-scrolly--scrim-boost .hero-client .client-logos .client-logo {
    height: 28px;
    max-height: 28px;
    max-width: 150px;
  }
}

/* The WB Discovery lockup (solid shield + bold two-line wordmark) carries far
   more optical weight than Hallmark's airy script at the same height, so it
   reads as oversized. Balance by weight, not height: shrink it a few px so the
   pair sits evenly. 4-class selector placed after the size rule above, so it
   wins on both mobile and desktop; also drops WB under the width clamp. */
.hero-scrolly--scrim-boost .hero-client .client-logos .client-logo--knockout {
  height: 22px;
  max-height: 22px;
}

/* "Key Results" heading over a busy/bright hero: a stronger dark halo so the
   label separates from bright patches, plus a higher reduced-motion/no-JS color
   floor. (With JS, the at-rest fill is raised via HEADING_FLOOR_OPACITY in
   main.js, scoped to this same class.) */
.hero-scrolly--scrim-boost .metrics-heading {
  color: rgba(255, 255, 255, 0.85);
  filter: drop-shadow(0 1px 4px rgba(0, 0, 0, 0.85)) drop-shadow(0 0 2px rgba(0, 0, 0, 0.7));
}

/* The gradient-clipped gold figures can't take a text color, and over the bright
   parts of a busy hero (e.g. Harry Potter's lit dragon wing) their single soft
   drop-shadow leaves them low-contrast. Add a tight dark outline for crisp edge
   separation, keeping the softer shadows for depth. Filter only — JS drives the
   figures' opacity, not their filter, so this never fights the scroll ramp. */
.hero-scrolly--scrim-boost .hero-metrics-floor .metric-value {
  filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.85)) drop-shadow(0 2px 6px rgba(0, 0, 0, 0.7)) drop-shadow(0 3px 14px rgba(0, 0, 0, 0.45));
}

.hero-scrolly .client-caption {
  color: rgba(255, 255, 255, 0.6);
}

.hero-scrolly .deliverable-badge {
  background: rgba(255, 255, 255, 0.1);
  border-color: rgba(255, 255, 255, 0.25);
  color: rgba(255, 255, 255, 0.9);
}

/* Scrolly hero product image — animates in on scroll */
.scrolly-hero-image {
  max-width: 480px;
  margin: 0 auto;
  border-radius: var(--radius-lg);
  overflow: hidden;
  opacity: 0;
  transform: translateY(40px);
  transition: opacity 0.8s ease, transform 0.8s ease;
}

.scrolly-hero-image.visible {
  opacity: 1;
  transform: translateY(0);
}

.scrolly-hero-image img {
  width: 100%;
  height: auto;
  display: block;
}

/* ── Strategy section (relocated from the hero's scene 2) ──
   Standalone sticky-background section that hosts the strategy cards after the
   campaign walkthrough. Reuses .scrolly-scene (sticky) and .scrolly-bg sizing;
   the background is visible by default here (no JS reveal gate) and the overlay
   holds a dim floor for card legibility. */
.strategy-section {
  position: relative;
  background: var(--color-bg);
}

.strategy-section .strategy-overlay {
  opacity: 0.72;
}

/* Pinned "Strategy" eyebrow — sits at the top of the sticky scene so the
   section is labeled and the heading order goes h2 → the cards' h3s. */
.strategy-section-label {
  position: absolute;
  top: clamp(24px, 8vh, 80px);
  left: 0;
  right: 0;
  text-align: center;
  z-index: 2;
  pointer-events: none;
}

.strategy-section-label h2 {
  font-size: 1.1rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 4px;
  color: rgba(255, 255, 255, 0.82);
  margin: 0;
  text-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
}

/* Fade the bottom of the sticky bg into the page background as the cards end. */
.strategy-section .scrolly-bg::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(to bottom, transparent 55%, var(--color-bg) 100%);
  pointer-events: none;
}

/* Scene 2 scroll runway — gives sticky hold time */
.scrolly-scene-2-runway {
  height: 150vh;
  background: var(--color-bg-white);
}

/* ── Strategy beats: sequential cards over scene 2 ── */

.scrolly-scene-2-runway--has-cards {
  height: auto;
  min-height: 100vh;
  background: transparent;
}

.scrolly-cards-track {
  display: flex;
  flex-direction: column;
  gap: 28vh;
  padding: 22vh 0 32vh;
  max-width: 640px;
  margin: 0 auto;
  width: 90%;
}

.scrolly-card {
  opacity: 0;
  transform: translateY(30px);
  will-change: opacity, transform;
}

.scrolly-card-inner {
  /* LITE glass tier (see :root) — intentional, not a missing refraction:
     these cards scroll-animate in sequence over a sticky photo scene, and the
     near-opaque fill hides refraction anyway. LITE also renders identically
     in Firefox (no url() filter to fall back from). */
  background: var(--glass-fill-card);
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  border: 1px solid var(--glass-edge-lite);
  border-radius: 14px;
  padding: var(--space-xl);
  box-shadow:
    0 8px 32px rgba(0, 0, 0, 0.45),
    inset 0 1px 0 rgba(255, 255, 255, 0.08);
}

.scrolly-card-challenge {
  font-size: 1.25rem;
  font-weight: 700;
  font-style: italic;
  color: #fff;
  line-height: 1.4;
  margin: 0 0 0.75em;
}

.scrolly-card-body {
  font-size: 1rem;
  line-height: 1.7;
  color: rgba(255, 255, 255, 0.88);
  margin: 0;
}

@media (max-width: 768px) {
  .scrolly-cards-track {
    gap: 20vh;
    padding: 16vh 0 24vh;
  }
}

@media (max-width: 480px) {
  .scrolly-card-inner {
    padding: var(--space-lg) var(--space-md);
  }

  .scrolly-card-challenge {
    font-size: 1.1rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .scrolly-card {
    opacity: 1;
    transform: none;
  }
}

/* === § 12. Design Polish [.gradient-text .approach- .summary-section .highlight- .client-logos .project-disclaimer] === */
/* Gradient text, Ken Burns, scroll reveal support — plus the project-page
   narrative sections (summary, approach chapters, metrics, client logos,
   disclaimer) that the polish applies to. */

/* Hero metric values get the gold gradient-clip directly (not via a JS-added
   class) so they render gold at first paint — no indigo→gold flash while
   scripts load. The element-opacity ramp in main.js still works on top. */
.gradient-text,
.hero-metrics-floor .metric-value {
  background: linear-gradient(160deg, #fff 25%, var(--color-accent) 100%);
  -webkit-background-clip: text;
  -webkit-text-fill-color: transparent;
  background-clip: text;
}

.scrolly-bg {
  transform-origin: center center;
}

/* Approach chapters — structured case study sections */
.approach-chapter {
  margin-bottom: var(--space-2xl);
}

.approach-chapter:last-child {
  margin-bottom: 0;
}

.approach-chapter h3 {
  font-family: var(--font-heading);
  font-size: 1.15rem;
  font-weight: 700;
  margin-bottom: var(--space-sm);
  color: var(--color-primary);
}

.approach-chapter p {
  line-height: 1.75;
  color: var(--color-text-light);
}

.approach-chapter ul {
  list-style: disc;
  padding-left: 1.5em;
  margin-bottom: 1rem;
}

.approach-chapter li {
  line-height: 1.7;
  color: var(--color-text-light);
  margin-bottom: 0.4em;
}

.approach-chapter li:last-child {
  margin-bottom: 0;
}

@media (prefers-reduced-motion: reduce) {
  .approach-chapter,
  .social-embed,
  .collaborator-card,
  .hero-metrics-floor {
    opacity: 1 !important;
    transform: none !important;
  }
}

.hero-layout {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-2xl);
  align-items: start;
}

.project-title {
  font-size: clamp(1.75rem, 1.25rem + 2vw, 2.5rem);
  margin-bottom: var(--space-lg);
}

/* Hero subsections spacing */
.client-section,
.summary-section,
.impact-section,
.highlights-section,
.deliverables-section {
  margin-bottom: var(--space-lg);
}

.project-role {
  font-size: 1.1rem;
  font-weight: 600;
  color: var(--color-teal);
  text-transform: uppercase;
  letter-spacing: 0.5px;
  margin-bottom: var(--space-md);
}

.impact-summary {
  font-size: var(--type-body-lg);
  line-height: 1.6;
  color: var(--color-text);
  font-weight: 500;
}

.highlights-list {
  list-style: none;
  padding: 0;
}

.highlight-item {
  position: relative;
  padding-left: 1.5rem;
  margin-bottom: var(--space-sm);
  font-size: 1rem;
  color: var(--color-text-light);
}

.highlight-item::before {
  content: '\2192';
  position: absolute;
  left: 0;
  color: var(--color-teal);
  font-weight: 600;
}

.client-logos {
  display: flex;
  align-items: center;
  gap: var(--space-lg);
}

.client-logos .client-logo {
  max-height: 60px;
  max-width: 150px;
}

.client-caption {
  font-size: 0.85rem;
  font-style: italic;
  color: var(--color-text-muted);
  margin-top: var(--space-sm);
}

.project-summary {
  font-size: 1.1rem;
  line-height: 1.6;
  color: var(--color-text-light);
}

.deliverables-list {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
}

.deliverable-badge {
  padding: 0.4rem 0.8rem;
  font-size: 0.8rem;
  font-weight: 600;
  background: var(--color-bg);
  border: 1px solid var(--color-border-dark);
  border-radius: var(--radius-lg);
  color: var(--color-primary);
}

.cta-section {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-md);
  margin-bottom: var(--space-lg);
}

.project-date {
  font-size: 0.9rem;
  color: var(--color-text-muted);
}

.hero-image {
  border-radius: var(--radius-lg);
  overflow: hidden;
  border-bottom: 4px solid var(--color-accent);
}

.hero-image img {
  width: 100%;
}

/* Metrics */
.project-metrics-section {
  background: var(--color-bg);
  padding: var(--space-2xl) 0;
}

.metrics-grid {
  display: flex;
  justify-content: center;
  gap: var(--space-2xl);
  flex-wrap: wrap;
}

.metric {
  text-align: center;
  min-width: 200px;
}

.metric-value {
  display: block;
  font-family: var(--font-heading);
  font-size: var(--type-headline);
  font-weight: 700;
  color: var(--color-primary);
}

/* One scan anchor, not three equals: the lead metric (raised) steps up. */
.metrics-grid .metric:first-child .metric-value {
  font-size: calc(var(--type-headline) * 1.22);
}

.metric-label {
  font-size: var(--type-label);
  font-weight: 500;
  text-transform: uppercase;
  letter-spacing: 0.5px;
  color: var(--color-text-muted);
}

/* Native-currency annotation on euro-run campaigns (the Philips Indiegogo
   set). USD is the headline and stays dominant; this rides its baseline at a
   fraction of the size, so the pair reads as one figure with a gloss rather
   than two competing numbers. Inline-block (not inline) keeps it a single
   unwrappable token beside the value, and lets it drop to its own line
   whole when the row runs out of width. */
.metric:has(.metric-original) .metric-value {
  display: inline-block;
}

.metric-original {
  display: inline-block;
  margin-left: 0.35em;
  font-family: var(--font-heading);
  /* Grows a little with the viewport because the figure it annotates does
     (--type-headline is itself a clamp): a flat size that reads right beside a
     20px phone figure is a speck beside a 42px desktop one. The floor is the
     phone size, so narrow widths are untouched; the ceiling keeps the ratio
     to the value near 1:3 at the top end, well short of competing. */
  font-size: clamp(0.68rem, 0.5rem + 0.45vw, 0.95rem);
  font-weight: 500;
  letter-spacing: 0.2px;
  white-space: nowrap;
  color: var(--color-text-muted);
}

/* Disclaimer */
.project-disclaimer-section {
  padding: var(--space-md) 0;
}

.project-disclaimer {
  background: rgba(255, 193, 7, 0.1);
  border-left: 4px solid #ffc107;
  padding: var(--space-md) var(--space-lg);
  border-radius: 0 var(--radius-md) var(--radius-md) 0;
}

.project-disclaimer p {
  margin: 0;
  color: var(--color-text-light);
  font-size: 0.95rem;
}

[data-theme="dark"] .project-disclaimer {
  background: rgba(255, 193, 7, 0.15);
}

/* Approach */
.approach-section {
  padding: var(--space-3xl) 0;
}

.approach-content {
  max-width: var(--container-prose);
}

.approach-content h2 {
  margin-bottom: var(--space-lg);
}

/* === § 13. Pre-launch Section [.plsb- .pf- .prelaunch- .pl- .duel-] === */
/* The lead-gen story between hero and walkthrough.
   The snap-scroll storyboard (.plsb) opens as the setup spine (product →
   no-discount norm → the page), then the "countable funnel"
   (.prelaunch-funnel) is the finale: visitor dots pour into the landing page
   itself — clipped by a funnel-shaped SVG mask, the page IS the filter — and
   land as gold "launch list" dots. A centered launch-day strip
   (.pl-handoff-strip) closes the story. Dot geometry is precomputed in
   build.js; one shared layout at every viewport. */
.prelaunch-section {
  padding: var(--space-3xl) 0;
  scroll-margin-top: calc(var(--header-height) + var(--hud-height));
}

/* On a page with a scrolly hero the section arrives via the hero's bottom
   fade, which already provides the breathing room — full 3xl top padding on
   top of it just delays the storyboard. (~ not +: the inline metric-label
   <script> sits between the two sections in the markup.) */
.hero-scrolly ~ .prelaunch-section {
  padding-top: var(--space-xl);
}
/* When the hero runs the no-context exit, this padding is one half of the
   crossfade handoff between the Key Results ensemble and the arriving
   storyboard card — the other half is the EXIT profile in main.js (a 55vh
   fade running to spacerEnd exactly, zero lead, 1.8x rise). Two
   constraints pull against each other: the card's top must stay below the
   ensemble's band until the fade has BEGUN (from there the 1.8x rise
   outruns the card's 1x climb, so the still-solid floor never paints over
   it), and the viewport must never sit near-empty between the two. The
   previous tuning (max(36svh, 380px) under a 20vh fade that ended a 20vh
   lead early) bought the first constraint by breaking the second: a
   measured 582px blank-viewport run right at the seam, >400px blank for
   ~200px of travel, on 1440x900 and 390x844 alike. 20svh brings the
   card's entry up under the longer fade; the 230px floor keeps it below
   the still-solid band where a proportional term would not. Short
   viewports need a BIGGER floor than tall ones — the ensemble's PIXEL
   height eats most of the screen and the vh-proportional fade starts
   late (a 210px floor left 25px of solid clearance at 1280x720) — so the
   media rule below parks the card at 270px under 800px-tall viewports.
   It sits BEFORE the --no-floor rule so floorless heroes keep their
   compact seam. Measured worst-frame blank / solid-floor clearance:
   384px/100 @ 1440x900, 335/85 @ 1280x720, 454/142 @ 1728x1080 (42% of
   that viewport — the 900-tall equivalent of 378), 399/169 @ 390x844;
   the tallest floor measured (sens-ai, 3-up grid + cutout image)
   tightens to 315/60 @ 1280x720. Motion mode only — the class is JS-set,
   so reduced motion / no-JS keep the compact seam. */
.hero-scrolly--no-context ~ .prelaunch-section {
  padding-top: max(20svh, 230px);
}
@media (max-height: 800px) {
  .hero-scrolly--no-context ~ .prelaunch-section {
    padding-top: 270px;
  }
}
/* ...but every pixel of that clearance is bought for the ensemble's exit. A
   no-context hero whose project has no `metrics` renders no floor and no pin
   spacer (project-hero.html gates both on it), so nothing is fading through
   the band and nothing needs clearing: the reader gets ~380px of empty page
   between the fold and the first storyboard card, with no content in view at
   all for ~150px of it. Those heroes fall back to the ordinary scrolly seam.
   Keyed on a JS-set class (main.js, beside --no-context) rather than
   :has(.hero-metrics-floor) — same subset, no selector-support surface. */
.hero-scrolly--no-floor ~ .prelaunch-section {
  padding-top: var(--space-xl);
}
/* Approach-strip compat seams: a migrated approach storyboard (the unified
   `prelaunch` object with `approachSeam: true` — heading still in-flow, no
   establish slide yet) keeps the spacing the old .approach-story sections
   were tuned with. The section now carries .prelaunch-section, so without
   these it would inherit the pre-launch seams above (xl / 36svh), which were
   tuned for canvas-headed strips arriving under a metrics floor. First: the
   plain-3xl top the old .approach-section gave every strip with a scrolly
   hero + floor (equal specificity to the --no-context rule above; source
   order decides). Both rules retire per-project when the establish fold
   lands and the approachSeam flag is removed. */
.hero-scrolly ~ .approach-story {
  padding-top: var(--space-3xl);
}
/* Same seam, storyboard-strip flavour: an approach strip absorbs the
   Background step exactly like pre-launch does (project-hero.html gates the
   context step on the storyboard), so these heroes also run --no-context, and
   with no metrics they run --no-floor too. The ordinary seam is worse here
   than the ~380px the note above estimates, because the fold parallaxes away
   faster than the page scrolls, so the dead band outlives the static distance
   between the card and the heading. The section's own padding goes, and the
   strip is pulled up into the fold's trailing whitespace — which includes
   .scrolly-bottom-fade, 14vh of pure spacer here since :root.js-motion paints
   no background on it. The svh term eases the pull off on short viewports,
   where the fold has less trailing slack to borrow against. Scoped to
   .approach-story so the pre-launch seams above keep the values they were
   tuned to.

   Measure the longest run of viewport rows that are ALL page-background, from
   screenshots — not from element rects. A DOM metric reads a heading scrolled
   half out of frame as "covered" and reports the seam 200px tighter than it
   renders. Omnilux @1440x900: 703px -> 495px, @402x874: 630px -> 486px, which
   puts it ahead of the tuned pre-launch pages (be-cool 663px, source-timestrike
   547px). What is left is the fold's own trailing whitespace plus the parallax
   rate, shared by every scrolly hero — not tunable from this seam. */
.hero-scrolly--no-floor ~ .approach-story {
  padding-top: 0;
  margin-top: max(-366px, -41svh);
}

.prelaunch-heading {
  max-width: 680px;
  margin-bottom: var(--space-lg);
}
.prelaunch-heading--center {
  max-width: 720px;
  margin-left: auto;
  margin-right: auto;
  margin-bottom: var(--space-2xl);
  text-align: center;
}

.prelaunch-title {
  margin-top: var(--space-xs);
}

/* --- media slides: the film canvas inside a carousel card ----------------- */
/* The establishing film clip inside its .plsb-panel--media card. Fixed dark
   palette rather than theme vars — film frames are dark in both themes, like
   the walkthrough's artifact page. On desktop the media composes into the
   card's bottom-right ~75% and a corner mask dissolves its top-left into the
   card's own near-black, so the heading column reads on solid ground without
   a detectable scrim edge; mobile keeps the full-bleed crop + gradients. */
.plsb-canvas {
  position: absolute; inset: 0;
  overflow: hidden;
  background: #05070f;
}
.plsb-canvas-media {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
}
.plsb-canvas-scrim { position: absolute; inset: 0; }
/* Establish: a left column band for the heading + a floor band for the
   context chips. Static gradients only. */
.plsb-canvas-scrim--establish {
  background:
    linear-gradient(to bottom, rgba(11, 13, 28, 0.5), rgba(11, 13, 28, 0.06) 30%, rgba(11, 13, 28, 0.06) 60%, rgba(11, 13, 28, 0.74)),
    linear-gradient(105deg, rgba(11, 13, 28, 0.68) 0%, rgba(11, 13, 28, 0.3) 40%, rgba(11, 13, 28, 0) 62%);
}
@media (min-width: 761px) {
  /* The visual owns the bottom-right. A storm-sky glow in the canvas's
     upper-right corner (sampled from the film's sky, #3d5e6d region) lets
     the media's fade read as "more sky" instead of a black vignette. */
  .plsb-canvas--establish {
    background: radial-gradient(80% 90% at 100% 0%, #33505f 0%, #15202a 48%, #05070f 78%);
  }
  /* Three mask layers multiplied together (intersect): the wide low-set
     radial does the compositional fade toward the heading's top-left,
     while short linear ramps on the media's top and left edges take those
     edges to TRUE zero — a partially-faded rectangular edge prints as a
     hard seam (the box's top border showed as a line across the sky).
     The upper-right sky now dissolves over the top ramp into the canvas's
     matching corner glow instead of cutting off. */
  .plsb-canvas--establish .plsb-canvas-media {
    inset: auto 0 0 auto;
    width: 78%; height: 81%;
    object-position: 55% 32%;
    -webkit-mask-image:
      radial-gradient(140% 160% at 95% 100%, #000 52%, transparent 80%),
      linear-gradient(to bottom, transparent 0, #000 18%),
      linear-gradient(to right, transparent 0, #000 14%);
    -webkit-mask-composite: source-in;
    mask-image:
      radial-gradient(140% 160% at 95% 100%, #000 52%, transparent 80%),
      linear-gradient(to bottom, transparent 0, #000 18%),
      linear-gradient(to right, transparent 0, #000 14%);
    mask-composite: intersect;
  }
  /* With the copy confined to the left third, the diagonal only has to
     carry that band — it releases by ~22% along its axis, well short of
     the media box's masked left edge so the film's subject (the trio on
     the dragon, mid-frame) stays undarkened. The mask then punches the
     scrim out of the upper-right corner entirely without touching the
     rest of its shape. */
  .plsb-canvas-scrim--establish {
    /* Slightly deeper + longer reach than before (0.6→0.68, release 22%→26%):
       the product label/blurb live lower in the column than the title and
       were riding the diagonal's tail. Still released well short of the
       media box's masked edge, so the film's subject keeps its light. */
    background: linear-gradient(115deg, rgba(3, 4, 10, 0.68) 0%, rgba(3, 4, 10, 0.26) 16%, rgba(3, 4, 10, 0) 26%);
    -webkit-mask-image: radial-gradient(130% 130% at 100% 0%, transparent 34%, #000 60%);
    mask-image: radial-gradient(130% 130% at 100% 0%, transparent 34%, #000 60%);
  }
}

.prelaunch-heading--canvas {
  position: absolute; z-index: 2;
  left: clamp(24px, 5vw, 76px);
  top: 50%; transform: translateY(-50%);
  max-width: min(30rem, 48%);
  text-align: left;
  margin: 0;
}
@media (min-width: 761px) {
  /* Anchor the heading to the upper-left black field the media mask leaves
     open (vertical centering would drift it into the frame). Capped at the
     left third so the scrim can stay off the film's subject. */
  .prelaunch-heading--canvas {
    top: clamp(28px, 7vh, 72px);
    transform: none;
    max-width: min(26rem, 34%);
  }
}
/* Fixed on-canvas palette (theme-invariant — the canvas is always dark) */
.prelaunch-heading--canvas .section-label { color: #4DB6AC; }
.prelaunch-heading--canvas .prelaunch-title { color: #fff; }
.prelaunch-heading--canvas .prelaunch-thesis {
  color: rgba(255, 255, 255, 0.84);
  /* More air under the headline than the base --space-sm gives — the canvas
     title runs display-size here and needs the separation. */
  margin-top: var(--space-md);
}

/* Context chips: the establishing slide's fast-grounding row. In flow,
   directly below the thesis inside the heading column. */
.plsb-context {
  margin: 18px 0 0; padding: 0; list-style: none;
  display: flex; flex-direction: column; align-items: flex-start; gap: 10px;
}
.plsb-context li {
  padding: 8px 14px;
  border: 1px solid rgba(255, 255, 255, 0.16);
  border-radius: var(--radius-md);
  background: rgba(16, 16, 30, 0.55);
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
}
.plsb-context b {
  display: block;
  font-size: 0.68rem; font-weight: 700; letter-spacing: 1.6px;
  text-transform: uppercase; color: #4DB6AC;
}
.plsb-context span {
  display: block; margin-top: 2px;
  font-size: 0.93rem; color: rgba(255, 255, 255, 0.92);
}

/* Product card: the establishing slide's grounding artifact — the campaign's
   torn-paper reward card in its beauty-shot variant (product photo + "$199",
   background knocked out), floated on the canvas black below the thesis.
   Prop treatment, not info panel: slight tilt, deep shadow hugging the torn
   alpha edge, and a brightness/saturation knock so the parchment sits in the
   night scene instead of glowing studio white. The blurb above carries the
   facts the card's face doesn't. */
.plsb-product { margin: clamp(18px, 3vh, 30px) 0 0; }
.plsb-product img {
  display: block; width: min(360px, 100%); height: auto;
  transform: rotate(-1.5deg);
  filter: brightness(0.9) saturate(0.95)
    drop-shadow(0 14px 26px rgba(3, 6, 14, 0.55));
}
/* Names the artifact before it appears — the eyebrow's treatment (uppercase,
   500, loose tracking) scaled up to sit between the thesis and the title, so
   it reads as the artifact's own heading rather than fine print. Keeps its
   place under the thesis and the caption's white. */
.plsb-product .plsb-product-label {
  margin: clamp(48px, 10.5vh, 108px) 0 12px;
  font-size: clamp(1.35rem, 1.1rem + 0.8vw, 1.75rem);
  font-weight: 500; letter-spacing: 0.5px; line-height: 1.2;
  text-transform: uppercase; color: rgba(255, 255, 255, 0.78);
}
/* Between the label and the artifact: the facts the reward card's own face
   doesn't carry (exclusivity, what the two pieces do together). Measure-capped
   so it stays a caption to the card, not a second thesis. */
.plsb-product .plsb-product-blurb {
  margin: 0 0 16px;
  max-width: 46ch;
  font-size: 0.98rem; line-height: 1.55;
  color: rgba(255, 255, 255, 0.72);
}
.plsb-product figcaption {
  margin-top: 12px;
  font-size: 0.72rem; font-weight: 700; letter-spacing: 1.6px;
  text-transform: uppercase; color: rgba(255, 255, 255, 0.78);
}

/* Short desktop viewports (e.g. 1194x750): the absolute copy column outgrows
   the svh-capped panel behind its overflow: hidden, and the torn-paper card
   loses ~47px at the fold. Rein in the label's big vertical runway and step
   the card down a notch so the full torn edge stays inside the panel. */
@media (min-width: 769px) and (max-height: 800px) {
  .plsb-product .plsb-product-label {
    margin-top: clamp(16px, 4svh, 48px);
  }

  .plsb-product img { width: min(300px, 100%); }
}

/* Canvas arming: the product card (and any context chips) hold back until the
   slide is the focused card (.is-noted, via initCanvasSlides), then land on a
   stagger (--i). Armed-only states (JS + motion), so the resolved markup never
   hides content. */
.stage-armed .plsb-context li,
.stage-armed .plsb-product {
  opacity: 0; transform: translateY(10px);
  transition: opacity 0.5s ease calc(var(--i, 0) * 0.12s + 0.25s),
              transform 0.55s cubic-bezier(0.4, 0, 0.2, 1) calc(var(--i, 0) * 0.12s + 0.25s);
}
.stage-armed.is-noted .plsb-context li,
.stage-armed.is-noted .plsb-product { opacity: 1; transform: none; }

@media (max-width: 760px) {
  /* Portrait-ish cards: the column becomes the card's full height — heading
     centered at the top, and the product block pushed to the FLOOR (via the
     product's margin-top: auto), where the deepened bottom scrim gives its
     label and blurb real ground. The film's subject gets the freed middle
     band instead of fighting the reward card for it. */
  .prelaunch-heading--canvas {
    right: auto; left: 50%; top: 0; bottom: 0;
    transform: translateX(-50%);
    width: calc(100% - 2 * var(--space-md));
    max-width: none;
    padding-top: var(--space-lg);
    /* Enough floor that the torn card's hanging ornament clears the edge. */
    padding-bottom: var(--space-xl);
    text-align: center;
    display: flex; flex-direction: column;
  }
  .plsb-context { align-items: stretch; gap: 8px; }
  .plsb-context li { text-align: left; }
  /* Floor-anchored product block: auto margin does the pushing, so the big
     desktop label offset retires; the artifact scales down a notch to sit
     inside the floor band rather than mid-frame. */
  .plsb-product { margin-top: auto; }
  .plsb-product .plsb-product-label {
    margin: 0 0 8px;
    font-size: 1.02rem; letter-spacing: 1.6px;
  }
  .plsb-product .plsb-product-blurb {
    margin: 0 auto 12px;
    font-size: 0.9rem;
  }
  .plsb-product img { width: min(280px, 82%); margin-inline: auto; }
  /* Lift the film's crop so the banking shot rides the freed middle band,
     and deepen the floor band under the bottom-anchored block. */
  .plsb-canvas--establish .plsb-canvas-media { object-position: 50% 32%; }
  .plsb-canvas-scrim--establish {
    background:
      linear-gradient(to bottom, rgba(11, 13, 28, 0.55), rgba(11, 13, 28, 0.08) 26%, rgba(11, 13, 28, 0.08) 46%, rgba(11, 13, 28, 0.82) 74%, rgba(11, 13, 28, 0.9)),
      linear-gradient(105deg, rgba(11, 13, 28, 0.5) 0%, rgba(11, 13, 28, 0.22) 40%, rgba(11, 13, 28, 0) 62%);
  }
}

.prelaunch-thesis {
  font-family: var(--font-heading);
  font-size: clamp(1.05rem, 1rem + 0.5vw, 1.3rem);
  font-weight: 500;
  color: var(--color-text-light);
  margin: var(--space-sm) 0 0;
}
.prelaunch-thesis b { color: var(--color-accent); font-weight: 700; white-space: nowrap; }

/* --- the shared vertical funnel (all viewports) -------------------------- */
/* Labels are HTML (rem-sized everywhere); the SVG holds only the dot grid and
   the converge curves; the node is a browser-framed figure. The stage wrapper
   gives the finale its own act break below the carousel; with JS + motion,
   initFunnelCohort adds .pf-stage--pin and the diagram pins inside it while
   the reader's scroll scrubs the cohort through. */
.pf-stage {
  margin: var(--space-3xl) auto var(--space-2xl);
}
/* Sticky act bar: the heading and the live tally ride along the top of the
   viewport while the reader scrolls the full-size column beneath — the
   figures tick in place as the pour progresses. Same LITE glass chrome as
   the carousel's nav pill. */
.pf-bar {
  position: sticky; top: calc(var(--header-height) + var(--hud-height) + 8px); z-index: 5;
  /* Narrower than the column so the heading and tally sit close, not flung
     to opposite edges of a full-width bar. */
  width: max-content; max-width: 100%;
  display: flex; align-items: center; justify-content: center;
  flex-wrap: wrap; gap: 8px 28px;
  padding: 12px 22px;
  border-radius: 16px;
  border: 1px solid var(--color-border);
  background: var(--plsb-nav-fill);
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.22), inset 0 1px 0 var(--plsb-nav-sheen);
  margin: 0 auto var(--space-xl);
}
.pf-heading { text-align: left; }
.pf-heading h3 {
  font-family: var(--font-heading);
  font-size: clamp(1.05rem, 0.95rem + 0.6vw, 1.45rem);
  color: var(--color-text);
  margin: 3px 0 0;
}
.prelaunch-funnel {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: 0 auto;
}
/* The live tally: resolved figures in the markup; the scrub rewrites them. */
.pf-counters {
  display: flex; align-items: baseline; gap: 24px;
  font-family: var(--font-body); font-size: 0.92rem;
  color: var(--color-text-muted);
}
.pf-count b {
  font-family: var(--font-heading); font-weight: 700;
  font-size: 1.5rem; color: var(--color-text-light);
  font-variant-numeric: tabular-nums;
  margin-right: 4px;
  /* Right-aligned inside a JS-reserved min-width (set from the resolved
     figure's digit count), so the ticking tally never resizes the bar. */
  display: inline-block; text-align: right;
}
.pf-count--gold b { color: var(--color-accent); }
.pf-lbl { text-align: center; font-family: var(--font-body); }
.pf-lbl-h {
  display: block;
  font-size: 0.92rem; font-weight: 700; letter-spacing: 2.5px;
  text-transform: uppercase; color: var(--color-text-light);
}
.pf-lbl-h--gold { color: var(--color-accent); }
.pf-lbl-s { display: block; font-size: 0.95rem; color: var(--color-text-muted); margin-top: 5px; }

/* visitor field — muted dots, the destined-to-convert cluster ringed gold.
   The wrapper flex-centers its children: the label runs wider than the dot
   grid, and block flow would otherwise leave the grid hugging the left. */
.pf-inflow { display: flex; flex-direction: column; align-items: center; }
.pf-grid { display: block; width: min(360px, 94vw); height: auto; margin-top: var(--space-md); overflow: visible; }
.pf-in { fill: rgba(121, 134, 203, 0.20); stroke: rgba(121, 134, 203, 0.30); stroke-width: 1; }
.pf-in--primed { fill: rgba(255, 213, 79, 0.08); stroke: rgba(255, 213, 79, 0.5); }
.pf-current { fill: none; stroke: rgba(255, 213, 79, 0.22); stroke-width: 2; }

/* the landing page = the filter itself: browser bar on the rim, the capture
   clipped to a funnel silhouette (SVG clip + stroked outline, so it scales
   and keeps a crisp edge at every viewport) */
.pf-node {
  width: min(340px, 88vw); margin: 4px 0 0; position: relative;
}
.pf-node-bar {
  display: flex; align-items: center; gap: 10px; padding: 9px 12px;
  background: rgba(255, 255, 255, 0.05);
  border: 1px solid var(--color-border-dark); border-radius: 12px 12px 0 0;
}
/* No drop-shadow filter here: blurring the clipped full-res capture wedges
   the GPU rasterizer. The stroked outline alone keeps the silhouette crisp. */
.pf-funnel { display: block; width: 100%; height: auto; }
.pf-funnel-outline { fill: none; stroke: var(--color-border-dark); stroke-width: 1.5; }
.pf-bdots { display: flex; gap: 5px; }
.pf-bdots i { width: 8px; height: 8px; border-radius: 50%; background: rgba(255, 255, 255, 0.18); }
.pf-burl {
  font-size: 0.72rem; letter-spacing: 0.3px; color: var(--color-text-muted);
  background: rgba(255, 255, 255, 0.06); border-radius: 5px; padding: 3px 10px;
}

/* the gold drop out of the page + the launch list it lands as */
.pf-landing { display: flex; flex-direction: column; align-items: center; }
.pf-drop {
  display: block; width: 3px; height: 52px; border-radius: 2px; position: relative;
  background: linear-gradient(rgba(255, 213, 79, 0.9), rgba(255, 213, 79, 0.45));
}
.pf-travel {
  position: absolute; left: 50%; top: 0; width: 9px; height: 9px; margin-left: -4.5px;
  border-radius: 50%; background: #FFD54F; box-shadow: 0 0 6px rgba(255, 213, 79, 0.6); opacity: 0;
}
.pf-out-grid { display: grid; grid-template-columns: repeat(7, 15px); gap: 10px; margin: var(--space-md) 0 var(--space-sm); }
.pf-out { width: 15px; height: 15px; border-radius: 50%; background: #FFD54F; box-shadow: 0 0 9px rgba(255, 213, 79, 0.35); }

/* motion: the page pans once inside the funnel mask (down and back — it
   visibly *behaves* like a webpage), the drop grows, one convert falls twice,
   the launch list pops in — once, on scroll-in (JS adds .anim-armed then
   .is-in per scope). Without JS or with reduced motion the diagram stays
   fully static and legible. */
@media (prefers-reduced-motion: no-preference) {
  .anim-armed .pf-drop { transform: scaleY(0); transform-origin: top; }
  .anim-armed .pf-out { opacity: 0; }
  .is-in .pf-page { animation: pf-page-pan 5.5s ease-in-out 0.3s 2 alternate; }
  .is-in .pf-drop { animation: pf-grow 0.5s ease-out 0.1s forwards; }
  .is-in .pf-travel { animation: pf-fall 1.1s ease-in-out 0.55s 2; }
  .is-in .pf-out { animation: pf-pop 0.5s cubic-bezier(0.2, 0.9, 0.3, 1.4) both; animation-delay: calc(var(--i) * 35ms + 0.5s); }
  /* Act 1 — the visitor field pours in row by row, then the converge
     currents draw toward the node. */
  .pf-inflow.anim-armed .pf-in { opacity: 0; }
  .pf-inflow.anim-armed .pf-current { opacity: 0; }
  .pf-inflow.anim-armed.is-in .pf-in {
    opacity: 1; transition: opacity 0.45s ease;
    transition-delay: calc(var(--r, 0) * 90ms);
  }
  .pf-inflow.anim-armed.is-in .pf-current {
    opacity: 1; transition: opacity 0.7s ease 1s;
  }
  /* Act 2 — the landing-page node rises into place. */
  .pf-node.anim-armed { opacity: 0; transform: translateY(20px); }
  .pf-node.anim-armed.is-in {
    opacity: 1; transform: none;
    transition: opacity 0.6s ease, transform 0.6s ease;
  }
}

/* Desktop scale: the finale stops being a stranded mobile column. The node
   nearly doubles (vector clip + stroke — scaling stays crisp); the pan is
   retired at this size (an SVG-clipped raster transform at ~620px repaints
   on the main thread in mobile Safari — same rasterizer cliff as the
   forbidden drop-shadow; the staged acts carry the motion instead). */
@media (min-width: 1024px) {
  .pf-grid { width: min(480px, 42vw); }
  .pf-node { width: min(620px, 64vw); }
  .is-in .pf-page { animation: none; }
  .pf-lbl-h { font-size: 1.05rem; }
  .pf-lbl-s { font-size: 1.06rem; }
  .pf-drop { height: 64px; }
  .pf-out-grid { grid-template-columns: repeat(7, 18px); gap: 12px; }
  .pf-out { width: 18px; height: 18px; }
}
@keyframes pf-page-pan { to { transform: translateY(-150px); } }
@keyframes pf-grow { to { transform: scaleY(1); } }
@keyframes pf-fall {
  0% { opacity: 0; transform: translateY(0); }
  15% { opacity: 1; }
  85% { opacity: 1; }
  100% { opacity: 0; transform: translateY(44px); }
}
@keyframes pf-pop { from { opacity: 0; transform: scale(0.2); } to { opacity: 1; transform: scale(1); } }

/* --- the literal cohort (JS + motion only) -------------------------------- */
/* initFunnelCohort overlays a canvas on the full-size column; the funnel's
   own travel through the viewport scrubs the 100 visitor dots through the
   page-funnel — 72 absorbed by the page's fade, 28 riding the stem out —
   with the sticky bar's tally ticking and the whole pour rewinding on
   scroll back. While the cohort narrates, the old one-drop loop and the
   timer-based launch-list pop stand down: arrivals light the list dots
   instead. Reduced motion / no-JS never add .pf-cohort, so the static
   diagram is untouched. */
.prelaunch-funnel { position: relative; }
.pf-cohort-canvas {
  position: absolute; inset: 0; z-index: 3;
  /* Explicit CSS size: the canvas backing store is dpr-scaled, and a bare
     inset:0 leaves a replaced element at its intrinsic size — 2x on retina,
     which threw every drawn dot right and below the funnel. */
  width: 100%; height: 100%;
  pointer-events: none;
}
.pf-cohort .pf-travel { display: none; }
.pf-cohort .is-in .pf-out { animation: none; }
.pf-cohort .pf-out {
  opacity: 0; transform: scale(0.2);
  transition: opacity 0.3s ease, transform 0.35s cubic-bezier(0.2, 0.9, 0.3, 1.4);
}
.pf-cohort .pf-out.is-lit { opacity: 1; transform: none; }


/* --- the six beats: a near-full-width highlights carousel ---------------- */
/* Apple-highlights format: cards run almost viewport-wide (the strip lives
   OUTSIDE .container), neighbors peek in from the edges, and mandatory snap +
   snap-stop makes one flick advance exactly one card. CSS scroll-snap is the
   whole base layer — no-JS keeps a swipeable strip — and initStoryCarousel
   layers auto-advance, the play/pause control, and the desktop arrows on top. */
/* The stage wraps one strip + its floating slide nav + the arrows. It is the
   nav's sticky containing block (the pill pins to the viewport bottom only
   while the stage spans it) and carries the shared chrome fills the pill,
   arrows, and control all draw from (light defaults; dark overrides below
   mirror the token blocks). */
.plsb-stage {
  position: relative;
  --plsb-nav-fill: rgba(255, 253, 249, 0.6);
  --plsb-nav-sheen: rgba(255, 255, 255, 0.55);
  --plsb-dot-fill: rgba(61, 56, 51, 0.25);
  --plsb-dot-fill-hover: rgba(61, 56, 51, 0.45);
}
[data-theme="dark"] .plsb-stage {
  --plsb-nav-fill: rgba(22, 22, 42, 0.55);
  --plsb-nav-sheen: rgba(255, 255, 255, 0.12);
  --plsb-dot-fill: rgba(255, 255, 255, 0.28);
  --plsb-dot-fill-hover: rgba(255, 255, 255, 0.5);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .plsb-stage {
    --plsb-nav-fill: rgba(22, 22, 42, 0.55);
    --plsb-nav-sheen: rgba(255, 255, 255, 0.12);
    --plsb-dot-fill: rgba(255, 255, 255, 0.28);
    --plsb-dot-fill-hover: rgba(255, 255, 255, 0.5);
  }
}

/* Pinned linger (the Key Results pin grammar, applied to the carousel):
   initStoryCarousel adds --pin on desktop + motion (min 1024x700, re-derived
   on resize) and the stage sticks under the header, so the carousel holds
   the viewport for about a screen of extra scroll before releasing. The
   height is the single pacing knob (cf. .scrolly-pin-spacer): 200svh minus
   the ~80svh stage leaves ~120svh of travel — enough that the hold reads
   as deliberate, short enough that one or two flicks skip past. The
   autoplay timer / swipe / arrows advance the slides at every size;
   vertical scroll never does (a former 320svh dwell-band scrub read as
   scroll-jacking and skipped cards on fast flicks). svh, not vh, so the
   release point holds steady while mobile-ish browser chrome collapses.
   Without the class (no JS, reduced motion, small viewports) the runway is
   inert and the carousel keeps its autoplay/swipe behavior. */
.plsb-runway--pin {
  height: 200svh;
}
.plsb-runway--pin .plsb-stage {
  position: sticky;
  top: calc(var(--header-height) + var(--hud-height) + 8px);
}

.plsb {
  --plsb-card: min(1240px, calc(100vw - clamp(28px, 7vw, 110px)));
  /* The visible band: what a card can actually show at once under the sticky
     chrome (header + HUD). Every height FLOOR below caps to it via min(), so
     a floor can never push a card past a short viewport. Content taller than
     the band still grows the card (min-height, no clipping) — the band is the
     budget each beat's content is built to fit. */
  --plsb-band: calc(100svh - var(--header-height) - var(--hud-height) - 32px);
  --plsb-h: min(clamp(540px, 74svh, 780px), var(--plsb-band));
  display: flex; gap: clamp(14px, 2vw, 24px); overflow-x: auto;
  scroll-snap-type: x mandatory;
  padding: 6px max(10px, calc((100% - var(--plsb-card)) / 2)) 20px;
  font-family: var(--font-body); scrollbar-width: thin;
}
.plsb-panel {
  position: relative;
  flex: 0 0 var(--plsb-card); min-height: var(--plsb-h);
  scroll-snap-align: center; scroll-snap-stop: always;
  border: 1px solid var(--color-border); border-radius: var(--radius-lg);
  background: rgba(255, 255, 255, 0.02);
  padding: clamp(24px, 3.2vw, 48px);
  display: flex; flex-direction: column;
  overflow: hidden;
}
/* Film-canvas cards: the media fills the card; padding retires. */
.plsb-panel--media { padding: 0; border-color: rgba(255, 255, 255, 0.14); }

/* Copy-only beats: there's no artifact to sit beside, so the card stops
   reserving a column for one and the measure centers instead of hugging the
   left edge. Panels still match heights — the stage row stretches them. */
.plsb-panel--copy { min-height: min(420px, 46svh); }
.plsb-panel--copy .plsb-copy {
  flex: 1 1 auto; max-width: 44rem; margin-inline: auto;
}

/* Payoff heading (`title2`): completes the beat's h3 after the body copy —
   rendered in the accent gold because it names the engagement's objective
   (Omnilux: "Medical-grade light therapy" … "…Sold for the home"). Sized to
   rhyme with the h3 it completes; the --xl rule below matches its clamp. */
.plsb-copy .plsb-title2 {
  font-family: var(--font-heading);
  color: var(--color-accent);
  margin: clamp(16px, 2.4vh, 26px) 0 8px;
}
.plsb--xl .plsb-panel .plsb-title2 { font-size: clamp(1.3rem, 1rem + 0.9vw, 1.8rem); }
/* Marked payoff line (`text2Icon`): a pre-tinted light-on-transparent glyph
   beside the objective statement, lifting it above body-copy weight without
   adding words. Sized in ems so it tracks the paragraph across breakpoints;
   top-aligned against the first line rather than centered against the block,
   so a two-line statement doesn't float the mark into the gutter. */
.plsb-copy .plsb-text2--marked { display: flex; align-items: flex-start; gap: 14px; }
.plsb-text2-mark { flex: none; width: auto; height: 2.6em; opacity: 0.92; }

/* Opt-in stat emphasis inside beat copy: the one figure a beat exists to
   deliver reads in the accent gold (bold comes from the <b> it rides on). */
.plsb-copy .plsb-stat { color: var(--color-accent); }

/* Outbound beat link (`link` on a storyboard beat): for a beat whose artifact
   is still published somewhere the visitor can go read it. The base reset
   strips anchor colour and underline, so this rule is the only thing making it
   read as a link at all. A quiet accent line rather than a button — it closes
   the beat, it doesn't compete with the page's own calls to action. */
.plsb-copy .plsb-link {
  display: inline-block;
  margin-top: clamp(12px, 1.8vh, 20px);
  color: var(--color-accent);
  font-size: 0.95rem;
  border-bottom: 1px solid color-mix(in srgb, var(--color-accent) 45%, transparent);
  transition: color var(--transition-fast), border-color var(--transition-fast);
}

.plsb-copy .plsb-link:hover {
  color: var(--color-text);
  border-bottom-color: currentColor;
}

/* Content cards: copy column beside the instrument, centered as a pair. */
.plsb-body {
  flex: 1; min-height: 0;
  display: flex; align-items: center;
  gap: clamp(24px, 4vw, 64px);
}
.plsb-copy { flex: 1 1 0; max-width: 34rem; }
/* Beat text arrives from projects.json with \n\n paragraph breaks authored
   in the string itself — pre-line is what turns them into visible gaps. */
.plsb-copy p, .plsb-quad-center p, .plsb-swap-panel p { white-space: pre-line; }
.plsb--xl .plsb-vis {
  flex: 1.25 1 0; align-self: stretch;
  height: auto; min-height: 340px; margin: 0;
}
/* …except the survey, side by side: its instrument caps itself at 440px, so
   the 1.25 share spent the surplus on slack around the bars while squeezing
   the copy to a four-line brick. Equal columns give the paragraph its measure
   back and let the pair read as one row. Desktop only — stacked cards read
   this share as VERTICAL space, where the 1.25 is what keeps the intent line
   clear of the card's bottom edge. */
@media (min-width: 761px) {
  .plsb--xl .plsb-vis--survey { flex: 1 1 0; }
  /* The page beat opts out of the two-column pattern entirely — the same
     shape the stacked card already had, which is the one that read. One
     sentence never earned its own column: the split gave 390px to 109px of
     copy and left ~60% of that half as empty photo, and vertical centering
     stranded the phase chip 230px above its own title. Header across the
     top, artifact as a banner beneath it. */
  .plsb-body:has(.plsb-vis--shot) {
    flex-direction: column; align-items: stretch;
    justify-content: flex-start; gap: clamp(16px, 2.4vh, 28px);
  }
  .plsb-body:has(.plsb-vis--shot) .plsb-copy { flex: 0 0 auto; max-width: none; }
  /* The artifact takes the card's leftover height rather than a fixed one:
     .plsb-vis is already a flex column, so the shot growing from a 0 basis
     fills whatever the header and caption don't use, at any card height —
     and aspect-ratio derives the width from that, holding one crop shape as
     the card grows. Width has to come from the height, not the other way
     round: a landing page is a TALL capture, so a full-width band showed
     about a fifth of it and cut the masthead in half. */
  .plsb--xl .plsb-vis--shot { flex: 1 1 auto; min-height: 0; }
  .plsb-body:has(.plsb-vis--shot) .plsb-shot {
    flex: 1 1 0; min-height: 0; height: auto;
    aspect-ratio: 1.7; width: auto; max-width: 100%;
    align-self: center;
    /* the crop starts at the page's own top edge — the masthead is the
       first thing the page said, so it's the first thing the shot shows */
    object-position: 0 0;
  }
}
.plsb--xl .plsb-panel h3 { font-size: clamp(1.4rem, 1.1rem + 1vw, 2rem); }
.plsb--xl .plsb-panel .plsb-copy p { font-size: clamp(0.98rem, 0.9rem + 0.3vw, 1.12rem); }
/* the artifact gets the card's scale: deeper crop, hero portion forward */
.plsb--xl .plsb-shot { height: min(420px, 46svh); object-position: 0 10%; }
.plsb--xl .prelaunch-heading--canvas .prelaunch-title {
  font-size: clamp(1.9rem, 1.4rem + 1.5vw, 2.9rem);
  line-height: 1.15;
}

/* Desktop arrows: glass chevrons over the strip edges (JS-revealed). */
.plsb-arrow {
  position: absolute; z-index: 6; top: 50%;
  width: 44px; height: 44px; margin-top: -22px;
  display: grid; place-items: center;
  border-radius: 50%; border: 1px solid var(--color-border);
  background: var(--plsb-nav-fill);
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.22), inset 0 1px 0 var(--plsb-nav-sheen);
  color: var(--color-text-light); cursor: pointer;
}
.plsb-arrow:hover { color: var(--color-text); }
.plsb-arrow:focus-visible { outline: 2px solid var(--color-teal); outline-offset: 2px; }
.plsb-arrow svg {
  width: 20px; height: 20px; fill: none; stroke: currentColor;
  stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round;
}
.plsb-arrow--prev { left: clamp(6px, 1.6vw, 26px); }
.plsb-arrow--next { right: clamp(6px, 1.6vw, 26px); }
.plsb-arrow[hidden] { display: none; }

/* The slide nav, Apple-highlights style: a frosted pill of snap dots beside
   a round play/pause control, both centered BELOW the strip with their own
   clearance (no overlap with the cards). The template ships the assembly
   [hidden] (no-JS keeps the swipe-only strip); initStoryboardNav reveals and
   wires it. The active dot stretches into a dash — a 2D width morph — and
   while autoplay runs, a teal fill sweeps the dash over the slide's dwell
   (initStoryCarousel sets --dwell and .is-playing). */
.plsb-nav {
  /* Chrome fills inherit from .plsb-stage, which carries the light defaults
     and both dark overrides ([data-theme] toggle + system media query). */
  width: fit-content;
  margin: clamp(20px, 3.5vh, 36px) auto 0;
  display: flex;
  align-items: center;
  gap: 12px;
}
.plsb-nav[hidden] { display: none; }

.plsb-nav-pill {
  display: flex;
  align-items: center;
  padding: 8px 12px;
  border-radius: 999px;
  border: 1px solid var(--color-border);
  background: var(--plsb-nav-fill);
  /* LITE glass tier (see :root) — floating chrome near a scroll-animated strip */
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.22), inset 0 1px 0 var(--plsb-nav-sheen);
}

/* Dot buttons: a 9px dot inside a 27px hit target (content-box padding,
   background clipped to the dot itself). */
.plsb-dot {
  box-sizing: content-box;
  width: 9px;
  height: 9px;
  padding: 9px;
  border: 0;
  border-radius: 999px;
  background: var(--plsb-dot-fill);
  background-clip: content-box;
  cursor: pointer;
  position: relative;
  transition: width 0.28s ease, background-color 0.2s ease;
}
.plsb-dot:hover { background-color: var(--plsb-dot-fill-hover); }
.plsb-dot:focus-visible { outline: 2px solid var(--color-teal); outline-offset: -5px; }
.plsb-dot[aria-current="true"] { width: 34px; }
/* The dash's fill: full teal at rest (paused/reduced motion = current-slide
   marker), a dwell-timed sweep while autoplay runs. */
.plsb-dot[aria-current="true"]::after {
  content: '';
  position: absolute; inset: 9px;
  border-radius: 999px;
  background: var(--color-teal);
  transform-origin: left;
}
.plsb-nav.is-playing .plsb-dot[aria-current="true"]::after {
  animation: plsb-dotfill var(--dwell, 7s) linear forwards;
}
@keyframes plsb-dotfill {
  from { transform: scaleX(0.06); }
  to { transform: scaleX(1); }
}

@media (prefers-reduced-motion: reduce) {
  .plsb-dot { transition: none; }
  .plsb-nav.is-playing .plsb-dot[aria-current="true"]::after { animation: none; }
}

/* The autoplay control: its own frosted circle beside the pill (JS-revealed,
   motion only). Icon pair switched on data-state. */
.plsb-ctl {
  display: grid; place-items: center;
  width: 44px; height: 44px; padding: 0;
  border: 1px solid var(--color-border);
  border-radius: 50%;
  background: var(--plsb-nav-fill);
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  box-shadow: 0 6px 20px rgba(0, 0, 0, 0.22), inset 0 1px 0 var(--plsb-nav-sheen);
  color: var(--color-text-light);
  cursor: pointer;
}
.plsb-ctl:hover { color: var(--color-text); }
.plsb-ctl:focus-visible { outline: 2px solid var(--color-teal); outline-offset: 2px; }
.plsb-ctl svg { width: 16px; height: 16px; display: block; }
.plsb-ctl-play { fill: currentColor; stroke: none; margin-left: 2px; }
.plsb-ctl-pause { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; }
.plsb-ctl[data-state="playing"] .plsb-ctl-play { display: none; }
.plsb-ctl:not([data-state="playing"]) .plsb-ctl-pause { display: none; }
.plsb-ctl[hidden] { display: none; }

.plsb-phase {
  display: block; font-size: 0.78rem; font-weight: 700; letter-spacing: 1.4px;
  text-transform: uppercase; color: var(--color-teal); margin-bottom: 16px;
}
.plsb-vis {
  height: 340px; display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 4px; border: 1px solid var(--color-border); border-radius: var(--radius-md);
  background: rgba(255, 255, 255, 0.02); overflow: hidden; margin-bottom: 22px;
}
/* The approver list carries its own frame — no cage around an
   argument-carrying instrument (box-in-a-box read). */
.plsb-vis--gauntlet { border: 0; background: none; }
/* The plates/panels carry their own frames — same reasoning as the gauntlet
   route above. */
.plsb-vis--swap { border: 0; background: none; align-items: stretch; }

/* --- photo-backed content cards ------------------------------------------- */
/* A beat with a `bg` object gets a full-bleed campaign still behind the
   standard body: img + scrim under, content over. The scrim is a left band
   (the copy column's ground) over a light overall dim so the instrument on
   the right stays legible without flattening the photo. Fixed dark palette,
   like the film canvases — the photo is dark in both themes. */
.plsb-panel--bg { border-color: rgba(255, 255, 255, 0.14); background: #0b0d1c; }
.plsb-bg { position: absolute; inset: 0; }
.plsb-bg img {
  position: absolute; inset: 0;
  width: 100%; height: 100%; object-fit: cover;
}
.plsb-bg-scrim {
  position: absolute; inset: 0;
  /* The right side carries instruments (gates, bars, tags) directly over the
     photo, so it holds a real floor (~0.5) instead of fading nearly clear. */
  background:
    linear-gradient(100deg, rgba(6, 8, 20, 0.95) 0%, rgba(6, 8, 20, 0.78) 40%, rgba(6, 8, 20, 0.62) 66%, rgba(6, 8, 20, 0.54) 100%),
    linear-gradient(to bottom, rgba(6, 8, 20, 0.3), rgba(6, 8, 20, 0) 30%);
}
/* The duel centers its copy, so the left-band premise breaks on that beat:
   swap in a symmetric, center-weighted dim — darker top/bottom where the
   title and payoff sit, an even floor behind the columns.
   --duel-dim is that middle floor, and it is the one value that has to differ
   by form factor: landscape cards give BOTH columns their own panel, so the
   photo only shows around and between them and can run light enough to read
   as the tree it is. Portrait cards strip the norm column's panel (≤760px
   block), dropping its copy straight onto the photo — that column needs the
   heavier floor, so the mobile block puts it back. */
.plsb-panel--duel .plsb-bg-scrim {
  --duel-dim: 0.52;
  background:
    linear-gradient(to bottom,
      rgba(6, 8, 20, 0.82) 0%,
      rgba(6, 8, 20, var(--duel-dim)) 38%,
      rgba(6, 8, 20, var(--duel-dim)) 62%,
      rgba(6, 8, 20, 0.82) 100%);
}
.plsb-panel--bg .plsb-phase, .plsb-panel--bg .plsb-body,
.plsb-panel--bg .plsb-duel { position: relative; z-index: 1; }
/* On-photo palette (theme-invariant) */
.plsb-panel--bg h3 { color: #fff; }
.plsb-panel--bg .plsb-copy p { color: rgba(255, 255, 255, 0.82); }
.plsb-panel--bg .plsb-duel-title { color: #fff; }
.plsb-panel--bg .plsb-duel-col p { color: rgba(255, 255, 255, 0.8); }
.plsb-panel--bg .plsb-duel-label { color: rgba(255, 255, 255, 0.66); }
.plsb-panel--bg .plsb-duel-label--gold { color: var(--color-accent); }
.plsb-panel--bg .plsb-duel-cols::before {
  color: rgba(255, 255, 255, 0.72);
  border-color: rgba(255, 255, 255, 0.2);
  background: #0b0d1c;
}
.plsb-panel--bg .plsb-tag { background: rgba(16, 16, 30, 0.55); }
.plsb-panel--bg .plsb-tag b { color: #fff; }
.plsb-panel--bg .plsb-tag > span { color: rgba(255, 255, 255, 0.7); }
.plsb-panel--bg .plsb-tag--gold { background: color-mix(in srgb, var(--color-accent) 12%, rgba(16, 16, 30, 0.6)); }
.plsb-panel--bg .plsb-tag--gold b { color: var(--color-accent); }
.plsb-panel--bg .plsb-duel-col:not(.plsb-duel-col--page) {
  border-color: rgba(255, 255, 255, 0.14);
  background: rgba(11, 13, 28, 0.45);
}
.plsb-panel--bg .plsb-duel-col--page { background: color-mix(in srgb, var(--color-accent) 8%, rgba(11, 13, 28, 0.55)); }
.plsb-panel--bg .plsb-bridge { border-color: rgba(255, 255, 255, 0.2); }
.plsb-panel--bg .plsb-gate-name { color: #fff; }
.plsb-panel--bg .plsb-gate-role { color: rgba(255, 255, 255, 0.62); }
.plsb-panel--bg .plsb-survey-head b { color: #fff; }
.plsb-panel--bg .plsb-survey-head span { color: rgba(255, 255, 255, 0.66); }
.plsb-panel--bg .plsb-seg-label { color: rgba(255, 255, 255, 0.78); }
.plsb-panel--bg .plsb-shot-cap { color: rgba(255, 255, 255, 0.7); }
.plsb-panel--bg .plsb-seg-track { background: rgba(6, 8, 20, 0.6); }
.plsb-panel--bg .plsb-survey-intent {
  color: rgba(255, 255, 255, 0.78);
  border-top-color: rgba(255, 255, 255, 0.2);
}
/* Swap beat, on-photo: same fixed dark palette as the duel/gauntlet/survey
   beats above — the plates and panels sit directly over the campaign photo. */
.plsb-panel--bg .plsb-swap-tag { color: rgba(255, 255, 255, 0.55); }
.plsb-panel--bg .plsb-swap-plate--before {
  color: rgba(255, 255, 255, 0.55); border-color: rgba(255, 255, 255, 0.18);
  background: rgba(16, 16, 30, 0.4);
}
.plsb-panel--bg .plsb-swap-plate--after { background: color-mix(in srgb, var(--color-accent) 14%, rgba(16, 16, 30, 0.55)); }
.plsb-panel--bg .plsb-swap-arrow { color: rgba(255, 255, 255, 0.5); }
.plsb-panel--bg .plsb-swap-dates { color: rgba(255, 255, 255, 0.62); border-top-color: rgba(255, 255, 255, 0.2); }
.plsb-panel--bg .plsb-swap-panel { background: rgba(16, 16, 30, 0.45); border-left-color: rgba(255, 255, 255, 0.25); }
.plsb-panel--bg .plsb-swap-panel--gold { background: color-mix(in srgb, var(--color-accent) 10%, rgba(16, 16, 30, 0.5)); }
.plsb-panel--bg .plsb-swap-panel-label { color: rgba(255, 255, 255, 0.6); }
.plsb-panel--bg .plsb-swap-panel p { color: rgba(255, 255, 255, 0.85); }
.plsb-panel--bg .plsb-swap-panel cite { color: rgba(255, 255, 255, 0.55); }
.plsb-panel--bg .plsb-swap-panel .nm-old { color: rgba(255, 255, 255, 0.5); }
/* Type over photography gets a soft dark halo — a tight core for edge
   contrast plus a wide ambient. Legibility comes from the glyphs' own
   ground, not from pushing the scrims darker over the product shots. */
.plsb-panel--bg h3,
.plsb-panel--bg .plsb-copy p,
.plsb-panel--bg .plsb-duel-col p,
.plsb-panel--bg .plsb-duel-label,
.plsb-panel--bg .plsb-duel .plsb-duel-payoff,
.plsb-panel--bg .plsb-gate-name,
.plsb-panel--bg .plsb-gate-role,
.plsb-panel--bg .plsb-survey-head b,
.plsb-panel--bg .plsb-survey-head span,
.plsb-panel--bg .plsb-seg-label,
.plsb-panel--bg .plsb-survey-intent,
.plsb-panel--bg .plsb-shot-cap,
.plsb-panel--bg .plsb-swap-panel p,
.plsb-panel--bg .plsb-swap-dates b,
.plsb-canvas .prelaunch-title,
.plsb-canvas .prelaunch-thesis,
.plsb-canvas .plsb-product-label,
.plsb-canvas .plsb-product-blurb,
.plsb-canvas .plsb-context span {
  text-shadow: 0 1px 2px rgba(4, 6, 16, 0.6), 0 3px 14px rgba(4, 6, 16, 0.45);
}

.plsb-vis--shot {
  /* Centered, not bottom-anchored: flex-end dated from a receipts row that
     used to sit under the shot, and once that left it pinned the masked crop
     seam to the card's bottom edge — the artifact read as falling out of the
     card. The shot + its caption now center as one block against the copy. */
  padding: 18px; align-items: stretch; justify-content: center; position: relative;
  /* No cage around the capture — the shot floats bare on the card. */
  border: 0; background: none;
}
/* The artifact's own caption, closing the beat: it's the only place on the
   page that says where this page lived and that it's gone. Sits in the band
   the shot's mask fades into, so it reads as the fade's resolution. */
.plsb-shot-cap {
  margin-top: 10px; text-align: center;
  font-size: 0.82rem; line-height: 1.45; color: var(--color-text-muted);
}
/* The page beat shows the capture bare — rounded top, soft lift, no browser
   chrome (the funnel's node below already frames it as a URL). */
.plsb-shot--bare {
  border-radius: 12px 12px 0 0;
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
}
.plsb-browser { border: 1px solid var(--color-border); border-bottom: none; border-radius: 10px 10px 0 0; overflow: hidden; background: #10101e; }
.plsb-browser-bar { display: flex; align-items: center; gap: 10px; padding: 8px 10px; background: rgba(255, 255, 255, 0.05); border-bottom: 1px solid var(--color-border); }
/* the crop seam sinks out instead of tearing (mask), leaving a quiet band
   for the receipts row */
.plsb-shot {
  display: block; width: 100%; height: 288px; object-fit: cover; object-position: 0 18%;
  -webkit-mask-image: linear-gradient(black 68%, transparent 96%);
  mask-image: linear-gradient(black 68%, transparent 96%);
}
/* Complete-artifact variant (the approach-story diagrams): the image is a
   finished 1.7-ratio board, not a tall page capture — there is no crop seam
   to hide, so the fade mask retires, all four corners round, and small
   screens crop from the board's horizontal center instead of its left edge.
   Desktop's aspect-lock (1.7 cover via the :has(.plsb-vis--shot) rule)
   matches the boards' own ratio, so nothing is cropped there. Doubled
   selector so the variant outweighs .plsb--xl .plsb-shot's crop values. */
.plsb-shot--full,
.plsb--xl .plsb-shot--full {
  border-radius: 12px;
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45);
  object-position: 50% 0;
  -webkit-mask-image: none;
  mask-image: none;
}
/* Turntable variant: the clip is already composited onto the panel's field, so
   it wants the whole box rather than the boards' top-anchored crop — `contain`
   keeps the product whole at any card width, and the matching backdrop hides
   the letterbox the 1.7 lock leaves around a 1.7 clip on sub-pixel rounding. */
.plsb-shot-video {
  object-fit: contain;
  object-position: 50% 50%;
  background: #1d1d31;
}
/* A tall product in a 1.7 clip carries wide empty margins. Desktop has the
   height to absorb them; a phone does not — letterboxing that frame into a
   width-driven box left the grinder about 150px tall. Small screens take a
   squarer box and CROP the margins away instead (the subject is centred, so
   cover never clips it), which buys back roughly half the product's height. */
@media (max-width: 760px) {
  .plsb-shot-video { aspect-ratio: 1.15; height: auto; object-fit: cover; }
}
/* The audience-pair beat: two pre-tinted outline icons centered on the bare
   card, a label under each — the pair IS the visual, so no cage and no bg.
   Icons scale by HEIGHT so their stroke weights stay matched (they were
   drawn at near-identical stroke-to-height); the elderly-pair icon loses
   its glasses below ~110px tall, which sets the floor. */
.plsb-vis--duo {
  display: flex; align-items: center; justify-content: center;
  padding: 18px; border: 0; background: none;
}
.plsb-duo {
  display: flex; align-items: flex-end; justify-content: center;
  gap: clamp(28px, 8%, 64px);
}
.plsb-duo-item { margin: 0; text-align: center; }
.plsb-duo-item img {
  display: block; margin: 0 auto 12px;
  width: auto; height: clamp(120px, 19svh, 172px);
}
.plsb-duo-item figcaption {
  font-size: 0.85rem; line-height: 1.35;
  color: var(--color-text-light);
}
/* The audience beat: survey receipts as an instrument — headline count,
   segmentation bars (width from --pct), and the intent line recast as
   stakes. initSurveyBars arms .survey-armed (motion only): bars rest at
   scaleX(0) and grow on .is-go with a --d stagger; the intent line lands
   last. Reduced motion / no-JS read the resolved bars. */
.plsb-vis--survey { border: 0; background: none; padding: 20px; }
.plsb-survey {
  width: min(100%, 440px);
  display: flex; flex-direction: column; gap: 24px;
}
.plsb-survey-head b {
  display: block; line-height: 1;
  font-family: var(--font-heading); font-weight: 700;
  font-size: clamp(2.6rem, 2rem + 2vw, 3.6rem);
  color: var(--color-text);
}
.plsb-survey-head span {
  display: block; margin-top: 6px;
  font-size: 0.78rem; font-weight: 700; letter-spacing: 2px;
  text-transform: uppercase; color: var(--color-text-muted);
}
.plsb-segments {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; gap: 16px;
}
.plsb-seg {
  display: grid; grid-template-columns: 1fr auto;
  gap: 6px 12px; align-items: baseline;
}
.plsb-seg-label { font-size: 0.92rem; color: var(--color-text-muted); }
.plsb-seg-pct {
  grid-row: 1; grid-column: 2;
  font-family: var(--font-heading); font-weight: 700;
  font-size: 1.02rem; color: var(--color-teal);
}
.plsb-seg-track {
  grid-column: 1 / -1;
  display: block; height: 8px; border-radius: 999px;
  background: color-mix(in srgb, var(--color-teal) 12%, transparent);
  overflow: hidden;
}
.plsb-seg-fill {
  display: block; height: 100%; width: calc(var(--pct) * 1%);
  border-radius: inherit; transform-origin: left;
  background: linear-gradient(90deg, var(--color-teal), color-mix(in srgb, var(--color-teal) 55%, transparent));
}
.plsb-survey-intent {
  margin: 0; padding-top: 16px;
  border-top: 1px solid var(--color-border);
  font-size: 0.95rem; color: var(--color-text-muted);
}
.plsb-survey-intent b {
  font-family: var(--font-heading); font-weight: 700;
  font-size: 1.4rem;
  color: var(--color-accent); margin-right: 6px;
}
.survey-armed .plsb-seg-fill { transform: scaleX(0); }
.survey-armed .plsb-survey-intent { opacity: 0; transform: translateY(6px); }
.survey-armed.is-go .plsb-seg-fill {
  transform: scaleX(1);
  transition: transform 0.9s cubic-bezier(0.22, 0.8, 0.3, 1) calc(var(--d, 0) * 0.25s + 0.25s);
}
.survey-armed.is-go .plsb-survey-intent {
  opacity: 1; transform: none;
  transition: opacity 0.5s ease 1s, transform 0.5s ease 1s;
}
/* The catch beat: a two-column DUEL. Headline center-top, then the
   Kickstarter norm (a fanned stack of discount tags) beside this page's
   lone gold 0% tag — visual first, short copy below each — and the payoff
   line resolving both columns, centered underneath. Markup rests resolved;
   initDuel arms .duel-armed (motion only) and .is-go staggers the norm
   tags in, slams the gold tag (stamp vocabulary), then lands the payoff. */
.plsb-duel {
  flex: 1; min-height: 0;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: clamp(18px, 3vh, 30px);
  text-align: center;
}
.plsb-duel-title {
  font-family: var(--font-heading); color: var(--color-text);
  font-size: clamp(1.5rem, 1.15rem + 1.2vw, 2.1rem);
  margin: 0;
}
.plsb-duel-cols {
  position: relative;
  display: grid; grid-template-columns: 1fr 1fr;
  /* The gap lives in a prop so the armed desktop reveal can center the norm
     column exactly: its own 50% + half this gap = the container's middle. */
  --duel-gap: clamp(24px, 4.5vw, 72px);
  gap: var(--duel-gap);
  width: min(100%, 58rem);
  align-items: stretch;
}
/* the hinge between the two arguments: an explicit VS marker, so the two
   columns read as one comparison (norm first, then this page) instead of two
   equal-weight blocks competing for the first look */
.plsb-duel-cols::before {
  content: 'vs';
  /* Alt-text form: visually 'vs' but silent to screen readers — as a ::before
     it reads BEFORE "The Kickstarter norm", hinging the duel out of order.
     The plain declaration above stays as the fallback where the slash syntax
     isn't parsed (it'd otherwise drop the whole pseudo-element). */
  content: 'vs' / '';
  position: absolute; z-index: 1;
  /* 40%, not 50%: centers the hinge on the tag band (label + gap + half the
     shared 172px stack), not the panels' midpoint. The positioning transform
     lives in --vs-pos so the armed reveal can compose a scale onto it
     (mobile's static hinge overrides the prop, not the transform). */
  left: 50%; top: 40%; --vs-pos: translate(-50%, -50%); transform: var(--vs-pos);
  display: grid; place-items: center;
  width: 38px; height: 38px; border-radius: 50%;
  font-size: 0.72rem; font-weight: 700; letter-spacing: 1px;
  text-transform: uppercase; color: var(--color-text-muted);
  border: 1px solid var(--color-border);
  background: var(--color-bg);
}
.plsb-duel-col {
  display: flex; flex-direction: column; align-items: center;
  gap: 16px; min-width: 0;
}
/* Desktop symmetry: the norm column gets a matching neutral panel so its copy
   isn't bare on the photo beside the gold answer. Portrait cards strip it
   (≤760px block) — stacked boxes would tax every card's height. */
.plsb-duel-col:not(.plsb-duel-col--page) {
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: rgba(255, 255, 255, 0.02);
  padding: 16px 20px 20px;
}
/* The page's column is the answer — it gets a quiet gold-tinted panel so the
   eye lands title → norm stack → HERE → payoff, in that order. */
.plsb-duel-col--page {
  border: 1px solid rgba(255, 213, 79, 0.3);
  border-radius: var(--radius-md);
  background: color-mix(in srgb, var(--color-accent) 6%, transparent);
  padding: 16px 20px 20px;
}
.plsb-duel-label {
  font-size: 0.72rem; font-weight: 700; letter-spacing: 2px;
  text-transform: uppercase; color: var(--color-text-muted);
}
.plsb-duel-label--gold { color: var(--color-accent); }
.plsb-duel-col p {
  max-width: 23rem; margin: 0;
  font-size: 0.95rem; line-height: 1.6; color: var(--color-text-muted);
}
/* Both stacks share one visual band so the columns' copy starts level. The
   band yields on short windows (20svh: full 172px above ~860px tall) so the
   duel can fit inside --plsb-band, but it floors at the norm stack's own
   content height (~146px: three tags + two gaps, reached near ~730px) — any
   smaller and the stack holds its band anyway while the gold side keeps
   sinking, de-leveling the columns' copy for zero height saved. The plain
   172px line is the fallback where svh isn't parsed. */
.plsb-tags {
  min-height: 172px;
  min-height: clamp(146px, 20svh, 172px);
  display: flex; flex-direction: column; justify-content: center; gap: 10px;
}
.plsb-tag {
  display: flex; align-items: center; gap: 12px;
  padding: 9px 18px 9px 14px;
  border: 1px solid var(--color-border); border-radius: 10px;
  background: rgba(255, 255, 255, 0.03);
  transform: var(--tag-rest, none);
}
/* the tag's string hole */
.plsb-tag::before {
  content: ''; flex: 0 0 auto;
  width: 8px; height: 8px; border-radius: 50%;
  border: 2px solid var(--color-border);
}
.plsb-tag b {
  font-family: var(--font-heading); font-weight: 700; line-height: 1;
  font-size: 1.35rem; color: var(--color-text-light);
}
.plsb-tag > span { font-size: 0.85rem; color: var(--color-text-muted); }
/* The norm stack fans casually — a pile of hooks, not a system. The angles
   have to overshoot "slightly off" to read as intent: at ~2° the stack just
   looked misaligned, so the fan opens to ~4.5° and the offsets widen with it.
   Scoped off the page column, which is not a pile: unscoped, :nth-child(1)
   also matched the lone gold tag AND outranked .plsb-tag--gold's own rest
   transform (0,3,0 vs 0,1,0), quietly dragging it 7px left of its panel. */
.plsb-tags:not(.plsb-tags--page) .plsb-tag:nth-child(1) { --tag-rest: rotate(-4.5deg) translateX(-12px); }
.plsb-tags:not(.plsb-tags--page) .plsb-tag:nth-child(2) { --tag-rest: rotate(3.2deg) translateX(10px); }
.plsb-tags:not(.plsb-tags--page) .plsb-tag:nth-child(3) { --tag-rest: rotate(-2.4deg) translateX(-4px); }
.plsb-tag--gold {
  --tag-rest: rotate(-2deg);
  padding: 16px 30px 16px 22px;
  border-color: rgba(255, 213, 79, 0.55);
  background: color-mix(in srgb, var(--color-accent) 9%, transparent);
  box-shadow: 0 0 24px color-mix(in srgb, var(--color-accent) 12%, transparent);
}
.plsb-tag--gold::before { border-color: color-mix(in srgb, var(--color-accent) 70%, transparent); }
.plsb-tag--gold b { font-size: clamp(1.9rem, 1.5rem + 1vw, 2.4rem); color: var(--color-accent); }
.plsb-tag--gold > span { font-size: 0.95rem; color: var(--color-text-light); }
/* Scoped under .plsb-duel: the generic `.plsb-panel p` rule (class + element)
   outranks a lone class, so an unscoped .plsb-duel-payoff loses its size and
   gold to the body-copy defaults. */
.plsb-duel .plsb-duel-payoff {
  margin: 0;
  font-family: var(--font-heading); font-weight: 600;
  font-size: clamp(1.4rem, 1.15rem + 1vw, 1.9rem);
  color: var(--color-accent);
  text-wrap: balance; /* no widowed last word when the line wraps (phones) */
}
/* Armed (JS + motion): the whole argument rests hidden and .is-go plays it
   in reading order — norm tags settle, the norm's copy explains, the vs
   hinge pivots, the gold column walks on, its 0% tag slams inside it, its
   copy lands, and the payoff closes. One timeline, tuned here (delays only —
   durations ride each element's transition below): */
.duel-armed {
  --t-tag: 0.6s;       /* first norm tag; siblings step by --t-tag-step */
  --t-tag-step: 0.5s;
  --t-norm-copy: 2.3s;
  --t-vs: 2.9s;
  --t-page: 3.3s;      /* the gold column (panel + label) arrives */
  --t-gold: 3.6s;      /* the 0% tag slams inside it */
  --t-page-copy: 4.3s;
  --t-payoff: 5s;
}
.duel-armed .plsb-tag { opacity: 0; transform: translateY(16px) var(--tag-rest, none); }
.duel-armed .plsb-tag--gold { transform: scale(1.2) var(--tag-rest); }
.duel-armed .plsb-duel-col p { opacity: 0; transform: translateY(8px); }
.duel-armed .plsb-duel-cols::before { opacity: 0; transform: var(--vs-pos) scale(0.6); }
.duel-armed .plsb-duel-col--page { opacity: 0; transform: translateY(10px); }
.duel-armed .plsb-duel-payoff { opacity: 0; transform: translateY(12px); }
.duel-armed.is-go .plsb-tag {
  opacity: 1; transform: var(--tag-rest, none);
  transition: opacity 0.4s ease calc(var(--i, 0) * var(--t-tag-step) + var(--t-tag)),
              transform 0.5s cubic-bezier(0.22, 0.8, 0.3, 1) calc(var(--i, 0) * var(--t-tag-step) + var(--t-tag));
}
.duel-armed.is-go .plsb-duel-col p {
  opacity: 1; transform: none;
  transition: opacity 0.5s ease var(--t-norm-copy), transform 0.5s ease var(--t-norm-copy);
}
.duel-armed.is-go .plsb-duel-cols::before {
  opacity: 1; transform: var(--vs-pos);
  transition: opacity 0.45s ease var(--t-vs),
              transform 0.45s cubic-bezier(0.22, 0.8, 0.3, 1) var(--t-vs);
}
.duel-armed.is-go .plsb-duel-col--page {
  opacity: 1; transform: none;
  transition: opacity 0.5s ease var(--t-page), transform 0.5s ease var(--t-page);
}
.duel-armed.is-go .plsb-tag--gold {
  transition: opacity 0.18s ease var(--t-gold),
              transform 0.38s cubic-bezier(0.2, 1.5, 0.35, 1) var(--t-gold);
}
/* After the generic col-p rule (equal specificity): the page column's copy
   waits for its own beat, not the norm's. */
.duel-armed.is-go .plsb-duel-col--page p {
  opacity: 1; transform: none;
  transition: opacity 0.5s ease var(--t-page-copy), transform 0.5s ease var(--t-page-copy);
}
.duel-armed.is-go .plsb-duel-payoff {
  opacity: 1; transform: none;
  transition: opacity 0.7s ease var(--t-payoff), transform 0.7s ease var(--t-payoff);
}
/* Desktop restaging: the norm argument holds the floor ALONE first — its
   column centered on the card (own 50% + half the grid gap = dead center),
   the page column absent — long enough to be read. Then the norm slides left
   into its grid cell and "This page" walks into the space it opened, gold
   slam and payoff following. Timeline vars override the base set (mobile
   keeps the stacked timing above); the shift beat waits on the norm copy —
   landed at ~2.8s, ~18 words — so an average reader is on its last line
   when the move starts, and the 0.8s glide keeps the text readable while
   it travels. Armed-only (JS + motion): no-JS and reduced motion rest on
   the resolved two-column comparison. */
@media (min-width: 761px) {
  .duel-armed {
    --t-shift: 6.2s;
    --t-vs: 6.75s;
    --t-page: 6.95s;
    --t-gold: 7.3s;
    /* Close on the gold slam's heels — at 7.9s the outlined page panel sat
       visibly empty for a beat, reading as a gap rather than anticipation. */
    --t-page-copy: 7.5s;
    --t-payoff: 8.9s;
  }
  .duel-armed .plsb-duel-col:not(.plsb-duel-col--page) {
    transform: translateX(calc(50% + var(--duel-gap) / 2));
  }
  /* The page column enters rightward as the norm clears leftward. */
  .duel-armed .plsb-duel-col--page { transform: translateX(18px); }
  .duel-armed.is-go .plsb-duel-col:not(.plsb-duel-col--page) {
    transform: none;
    transition: transform 0.8s cubic-bezier(0.65, 0, 0.35, 1) var(--t-shift);
  }
}
/* The gauntlet beat: an approval ROUTE. Five gates share one horizontal rail
   (badge + name + role per gate — identities, never invented review scopes),
   and a small "The copy" doc chip travels it when the card is focused,
   pausing at each gate on one even beat as its check clears. Gate delays
   ride --d (seconds, from the data) so the checks land as the chip arrives.
   initGauntlet arms .gaunt-armed (motion only); no-JS / reduced motion keep
   the resolved state — every gate cleared, chip parked at the last gate,
   stamp landed. */
.plsb-vis--gauntlet { padding: 20px; overflow: visible; }
/* Desktop: five gates need room to read, so this beat breaks from the
   shared side-by-side .plsb-body pattern — copy sits above, the route runs
   full-width below it, larger than the shared .plsb-vis instrument size. */
@media (min-width: 761px) {
  .plsb--xl .plsb-body--gauntlet {
    flex-direction: column; align-items: stretch;
    /* center the copy + route pair in the card's leftover height */
    justify-content: center; gap: clamp(16px, 3.5vh, 36px);
  }
  /* Natural height (base .plsb-copy's flex: 1 would swallow the card's
     leftover space and shove the route to the bottom edge), and centered on
     the ROUTE's spine: this is the one content beat whose instrument runs
     full-width and centers itself, so a left-pinned copy column put two
     alignments in one card. Capped near 62ch — the card is wide enough to
     run this paragraph as a two-line slab, which is a measure problem, not
     a length one. */
  .plsb--xl .plsb-body--gauntlet .plsb-copy {
    flex: 0 0 auto;
    max-width: 30rem; margin-inline: auto; text-align: center;
  }
  .plsb--xl .plsb-body--gauntlet .plsb-vis--gauntlet {
    flex: none; align-self: stretch; min-height: 0;
  }
  .plsb--xl .plsb-body--gauntlet .plsb-route { width: min(100%, 860px); margin: 0 auto; }
  .plsb--xl .plsb-body--gauntlet .plsb-gates { gap: 10px; }
  .plsb--xl .plsb-body--gauntlet .plsb-gate-badge { width: 34px; height: 34px; margin-bottom: 8px; }
  .plsb--xl .plsb-body--gauntlet .plsb-gate-badge svg { width: 19px; height: 19px; }
  .plsb--xl .plsb-body--gauntlet .plsb-gate-name { font-size: 1.05rem; }
  .plsb--xl .plsb-body--gauntlet .plsb-gate-role { font-size: 0.82rem; }
  .plsb--xl .plsb-body--gauntlet .plsb-route-doc { font-size: 0.78rem; padding: 6px 14px; }
  .plsb--xl .plsb-body--gauntlet .plsb-route-doc svg { width: 15px; height: 15px; }
  .plsb--xl .plsb-body--gauntlet .plsb-gate-stamp { font-size: 0.66rem; padding: 3px 9px 4px; }
}
.plsb-route {
  position: relative;
  width: min(100%, 560px);
  /* bottom padding clears the gate stamps hanging under the rail */
  padding: 58px 0 30px;
}
.plsb-gates {
  list-style: none; margin: 0; padding: 0;
  position: relative;
  display: grid; grid-template-columns: repeat(5, 1fr);
  gap: 6px;
}
/* the rail threading the five gates (drawn across on .is-go) */
.plsb-gates::before {
  content: ''; position: absolute; left: 10%; right: 10%; top: 13px;
  height: 1px;
  background: linear-gradient(90deg,
    color-mix(in srgb, var(--color-teal) 40%, transparent),
    color-mix(in srgb, var(--color-teal) 14%, transparent));
}
.plsb-gate {
  position: relative;
  display: flex; flex-direction: column; align-items: center;
  gap: 2px; text-align: center;
  min-width: 0;
}
.plsb-gate-badge {
  width: 26px; height: 26px; border-radius: 999px;
  display: grid; place-items: center;
  border: 1.5px solid var(--color-teal);
  background: color-mix(in srgb, var(--color-teal) 16%, transparent);
  position: relative; z-index: 1;
  margin-bottom: 6px;
}
.plsb-gate-badge svg {
  width: 15px; height: 15px; fill: none; stroke: var(--color-teal);
  stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round;
  stroke-dasharray: 22; stroke-dashoffset: 0;
}
.plsb-gate-name {
  font-family: var(--font-body); font-size: 0.88rem; letter-spacing: 0.2px;
  color: var(--color-text-light);
}
/* who each gate IS — identity, not an invented review scope */
.plsb-gate-role {
  font-size: 0.7rem; letter-spacing: 0.4px; color: var(--color-text-muted);
}
/* The doc chip that runs the route — the writing itself, kept alive. */
.plsb-route-doc {
  position: absolute; top: 6px; left: 90%;
  transform: translateX(-50%);
  display: inline-flex; align-items: center; gap: 6px;
  padding: 5px 12px;
  border: 1px solid rgba(255, 213, 79, 0.45);
  border-radius: 8px;
  background: color-mix(in srgb, var(--color-accent) 10%, transparent);
  color: var(--color-accent);
  font-size: 0.7rem; font-weight: 600; letter-spacing: 1px;
  text-transform: uppercase; white-space: nowrap;
  z-index: 2;
}
.plsb-route-doc svg {
  width: 13px; height: 13px; fill: none; stroke: currentColor;
  stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round;
}
/* Armed: gates un-cleared (empty badge, undrawn check, dim identity,
   undrawn rail), chip waiting at the first gate. */
.plsb-route.gaunt-armed .plsb-gate { opacity: 0.32; }
.plsb-route.gaunt-armed .plsb-gate-badge { border-color: var(--color-border); background: transparent; }
.plsb-route.gaunt-armed .plsb-gate-badge svg { stroke: var(--color-border); stroke-dashoffset: 22; }
.plsb-route.gaunt-armed .plsb-gates::before { transform: scaleX(0); transform-origin: left; }
.plsb-route.gaunt-armed .plsb-route-doc { left: 10%; }
/* Go: the rail draws across, the chip runs the route on an even beat, and
   each gate clears as the chip reaches it. */
.plsb-route.is-go .plsb-gates::before { transform: scaleX(1); transition: transform 3.6s cubic-bezier(0.3, 0, 0.4, 1) 0.15s; }
.plsb-route.is-go .plsb-route-doc { animation: plsb-route-run 4.6s cubic-bezier(0.45, 0, 0.25, 1) forwards; }
.plsb-route.is-go .plsb-gate {
  opacity: 1; transition: opacity 0.4s ease; transition-delay: calc(var(--d, 0) * 0.7s + 0.45s);
}
.plsb-route.is-go .plsb-gate-badge {
  border-color: var(--color-teal); background: color-mix(in srgb, var(--color-teal) 16%, transparent);
  transition: border-color 0.4s ease, background 0.4s ease; transition-delay: calc(var(--d, 0) * 0.7s + 0.45s);
}
.plsb-route.is-go .plsb-gate-badge svg {
  stroke: var(--color-teal); stroke-dashoffset: 0;
  transition: stroke 0.4s ease, stroke-dashoffset 0.5s ease; transition-delay: calc(var(--d, 0) * 0.7s + 0.45s);
}
/* The chip's itinerary: five equal dwells joined by four equal moves — one
   even beat down the rail. Percentages of 4.6s. */
@keyframes plsb-route-run {
  0%     { left: 10%; }
  13.6%  { left: 10%; }
  21.6%  { left: 30%; }
  35.2%  { left: 30%; }
  43.2%  { left: 50%; }
  56.8%  { left: 50%; }
  64.8%  { left: 70%; }
  78.4%  { left: 70%; }
  86.4%  { left: 90%; }
  100%   { left: 90%; }
}

/* Per-gate stamps: each sign-off leaves its own small mark, pressed in just
   after that gate's check clears (same --d beat), and left standing — the
   five marks together are the payoff. Base state = landed (resolved: no-JS /
   reduced motion); only the armed class hides them so motion can press them
   in. --tx/--rot let the shared keyframes serve both the desktop row
   (absolutely centered under each gate) and the portrait rows (static, end
   of line). */
.plsb-gate-stamp {
  --rot: -6deg;
  position: absolute; top: 100%; left: 50%;
  z-index: 1; pointer-events: none; white-space: nowrap;
  padding: 2px 7px 3px;
  border: 1.5px solid var(--color-teal);
  border-radius: 5px;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--color-teal) 38%, transparent);
  color: var(--color-teal);
  font-family: var(--font-heading); font-weight: 800;
  font-size: 0.56rem; letter-spacing: 1.4px; text-transform: uppercase;
  opacity: 0.85;
  transform: translate(var(--tx, -50%), var(--ty, 8px)) rotate(var(--rot));
}
/* hand-pressed, not machine-set: alternate the tilt down the rail */
.plsb-gate:nth-child(even) .plsb-gate-stamp { --rot: 5deg; }
.plsb-route.gaunt-armed .plsb-gate-stamp { opacity: 0; }
.plsb-route.is-go .plsb-gate-stamp {
  animation: plsb-gate-stamp-in 0.3s cubic-bezier(0.2, 1.5, 0.35, 1)
             calc(var(--d, 0) * 0.7s + 0.55s) both;
}
@keyframes plsb-gate-stamp-in {
  from { opacity: 0; transform: translate(var(--tx, -50%), var(--ty, 8px)) rotate(var(--rot)) scale(1.7); }
  to   { opacity: 0.85; transform: translate(var(--tx, -50%), var(--ty, 8px)) rotate(var(--rot)) scale(1); }
}

/* The swap beat: a before -> after reveal shared by two shapes — literal
   name plates (swap.plates: dated rows, old struck out, new lit gold) and
   one to three labeled panels (a quote pair, supporting notes). A beat can
   carry either or both, so the translation beat (panels only) and the
   rename beat (plates + panels) share one component instead of forking it.
   initSwap arms .swap-armed (motion only) and .is-go reveals each row/panel
   in reading order, staggered by --i; the "after" plate gets its own small
   pop so the swap reads as landing, not just fading in. Reduced motion /
   no-JS read the fully resolved comparison. */
.plsb-swap {
  display: flex; flex-direction: column; gap: 14px;
  width: 100%; max-width: 30rem; margin: 0 auto; padding: 4px 0;
}
.plsb-swap-plates { display: flex; flex-direction: column; gap: 10px; }
.plsb-swap-row {
  display: grid; grid-template-columns: 4.4rem 1fr auto 1fr;
  align-items: center; gap: 10px;
}
.plsb-swap-tag {
  font-size: 0.7rem; font-weight: 700; letter-spacing: 1.3px;
  text-transform: uppercase; color: var(--color-text-muted); text-align: right;
}
.plsb-swap-plate {
  font-family: var(--font-heading); font-weight: 700; text-transform: uppercase;
  font-size: clamp(0.92rem, 0.78rem + 0.5vw, 1.1rem); text-align: center;
  padding: 8px 6px; border: 1px solid var(--color-border); border-radius: var(--radius-sm);
  letter-spacing: 0.3px;
}
.plsb-swap-plate--before {
  color: var(--color-text-muted); text-decoration: line-through; text-decoration-thickness: 1.5px;
  background: rgba(255, 255, 255, 0.02);
}
.plsb-swap-plate--after {
  color: var(--color-accent); border-color: rgba(255, 213, 79, 0.4);
  background: color-mix(in srgb, var(--color-accent) 8%, transparent);
}
.plsb-swap-arrow { color: var(--color-text-muted); font-size: 1rem; text-align: center; }
.plsb-swap-dates {
  display: flex; flex-wrap: wrap; justify-content: space-between; gap: 6px 14px;
  margin-top: 2px; padding-top: 10px; border-top: 1px solid var(--color-border);
  font-size: 0.78rem; color: var(--color-text-muted);
}
.plsb-swap-dates b { color: var(--color-accent); font-weight: 700; }
.plsb-swap-panel {
  margin: 0; padding: 12px 16px; border-left: 2px solid var(--color-border);
  background: rgba(255, 255, 255, 0.02); border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
.plsb-swap-panel--gold { border-left-color: var(--color-accent); background: color-mix(in srgb, var(--color-accent) 6%, transparent); }
.plsb-swap-panel-label {
  display: block; font-size: 0.66rem; font-weight: 700; letter-spacing: 1.3px;
  text-transform: uppercase; color: var(--color-text-muted); margin-bottom: 6px;
}
.plsb-swap-panel--gold .plsb-swap-panel-label { color: var(--color-accent); }
.plsb-swap-panel p { margin: 0; font-size: 0.92rem; line-height: 1.55; color: var(--color-text-light); }
.plsb-swap-panel cite {
  display: block; margin-top: 6px; font-style: normal; font-size: 0.74rem; color: var(--color-text-muted);
}
/* Inline highlight spans for the seam quotes, used inside a panel's <p>. */
.plsb-swap-panel .nm-old { color: var(--color-text-muted); text-decoration: line-through; }
.plsb-swap-panel .nm-new { color: var(--color-accent); font-weight: 600; }
/* Armed (JS + motion): rows/panels rest hidden and reveal staggered by --i;
   the "after" plate pops in on its own beat just after its row lands. */
.swap-armed .plsb-swap-row,
.swap-armed .plsb-swap-panel { opacity: 0; transform: translateY(10px); }
.swap-armed .plsb-swap-plate--after { opacity: 0; transform: scale(0.85); }
.swap-armed.is-go .plsb-swap-row,
.swap-armed.is-go .plsb-swap-panel {
  opacity: 1; transform: none;
  transition: opacity 0.5s ease calc(var(--i, 0) * 0.22s + 0.15s),
              transform 0.5s cubic-bezier(0.22, 0.8, 0.3, 1) calc(var(--i, 0) * 0.22s + 0.15s);
}
.swap-armed.is-go .plsb-swap-plate--after {
  opacity: 1; transform: scale(1);
  transition: opacity 0.3s ease calc(var(--i, 0) * 0.22s + 0.45s),
              transform 0.4s cubic-bezier(0.2, 1.5, 0.35, 1) calc(var(--i, 0) * 0.22s + 0.45s);
}

/* The test beat: an A/B/n specimen bench. One browser-framed card stands
   in for the N parallel test pages — the funnel node's exact chrome
   (.pf-node-bar and friends) on top, one variant's headline + tagline as
   the page below, and a path pill per variant under the frame. Variants
   stack in one grid cell and cross-fade as .is-on walks (visibility rides
   the fade so only the shown variant reaches the accessibility tree); the
   :not(.is-cycling) rules pin the first variant so reduced motion and
   no-JS read one resolved specimen.
   --page mode (data `test.page`): the view stops being a text card and
   becomes the hero the variants actually shipped on — the page's own
   background art under a scrim, eyebrow above the headline, the page's
   CTA row below — so the A/B/n copy compares in context. The pills are
   real buttons (enabled by initTestCycler): press one to hold a variant. */
.plsb-vis--test { border: 0; background: none; align-items: stretch; }
.plsb-test { width: 100%; max-width: 30rem; margin: 0 auto; }
.plsb-test--page { max-width: 34rem; }
.plsb-test-view {
  display: grid; padding: 20px 22px 22px;
  background: rgba(16, 16, 30, 0.55);
  border: 1px solid var(--color-border-dark); border-top: 0;
  border-radius: 0 0 12px 12px;
}
.plsb-test-stack { display: grid; }
.plsb-test-variant {
  grid-area: 1 / 1; opacity: 0; visibility: hidden;
  transition: opacity 0.45s ease, visibility 0.45s;
}
.plsb-test:not(.is-cycling) .plsb-test-variant:first-child { opacity: 1; visibility: visible; }
.plsb-test-variant.is-on { opacity: 1; visibility: visible; }
.plsb-test-head {
  display: block; font-weight: 700; font-size: 1.06rem; line-height: 1.3;
  color: var(--color-text-light);
}
.plsb-test-tag {
  display: block; margin-top: 8px;
  font-size: 0.84rem; line-height: 1.5; color: var(--color-text-muted);
}
/* --page mode: the recreated hero. The view keeps a fixed hero aspect so
   variant swaps never resize the frame; the bg art fills it (cover, focal
   point from data), the scrim buys copy legibility from the bottom up, and
   the copy column parks on the lower-left like the page it recreates. */
.plsb-test--page .plsb-test-view {
  position: relative; overflow: hidden; padding: 0; aspect-ratio: 8 / 5;
}
.plsb-test-bg {
  position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover;
}
.plsb-test-scrim {
  position: absolute; inset: 0;
  background: linear-gradient(to top,
    rgba(8, 9, 20, 0.86) 0%, rgba(8, 9, 20, 0.52) 42%,
    rgba(8, 9, 20, 0.18) 72%, rgba(8, 9, 20, 0.28) 100%);
}
.plsb-test-hero { display: flex; flex-direction: column; align-items: flex-start; }
.plsb-test--page .plsb-test-hero {
  position: relative; height: 100%; justify-content: flex-end;
  padding: 0 clamp(16px, 5%, 26px) clamp(14px, 4.5%, 24px);
}
.plsb-test-eyebrow {
  font-size: 0.66rem; font-weight: 700; letter-spacing: 1.6px;
  text-transform: uppercase; color: rgba(255, 255, 255, 0.72);
  margin-bottom: 7px;
}
.plsb-test--page .plsb-test-head { color: #fff; font-size: 1.12rem; text-wrap: balance; }
.plsb-test--page .plsb-test-tag { color: rgba(255, 255, 255, 0.82); margin-top: 6px; }
/* The page's CTA row, faithful but inert (aria-hidden in markup). */
.plsb-test-ctas { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
.plsb-test-cta {
  display: inline-flex; align-items: center; gap: 7px;
  font-size: 0.72rem; font-weight: 700; letter-spacing: 0.2px;
  padding: 7px 14px; border-radius: 6px;
  background: #fff; color: #16161e;
}
.plsb-test-cta--ghost {
  background: transparent; color: #fff;
  border: 1px solid rgba(255, 255, 255, 0.55);
}
.plsb-test-play {
  width: 0; height: 0; border-style: solid; border-width: 4px 0 4px 7px;
  border-color: transparent transparent transparent currentColor;
}
/* The URL bar shows only the current path, lit at the domain's end. */
.plsb-test-path { display: none; font-weight: 700; color: var(--color-accent); }
.plsb-test:not(.is-cycling) .plsb-test-path:first-of-type,
.plsb-test-path.is-on { display: inline; }
.plsb-test-pills { display: flex; justify-content: center; gap: 8px; margin-top: 14px; }
.plsb-test-pill {
  appearance: none; cursor: pointer;
  font-family: ui-monospace, 'SF Mono', Menlo, monospace;
  font-size: 0.78rem; font-weight: 700; color: var(--color-text-muted);
  padding: 3px 10px; border: 1px solid var(--color-border);
  border-radius: 999px; background: rgba(255, 255, 255, 0.02);
  transition: color 0.3s ease, border-color 0.3s ease, background-color 0.3s ease;
}
.plsb-test-pill[disabled] { cursor: default; }
.plsb-test-pill:hover:not([disabled]) {
  color: var(--color-text-light); border-color: rgba(255, 255, 255, 0.35);
}
.plsb-test:not(.is-cycling) .plsb-test-pill:first-child,
.plsb-test-pill.is-on,
.plsb-test-pill.is-on:hover {
  color: var(--color-accent); border-color: rgba(255, 213, 79, 0.5);
  background: rgba(255, 255, 255, 0.05);
}
/* Armed (JS + motion): frame rises, pills follow. Entrance transitions sit
   on the containers, not the pills, so the pill's own color transition
   isn't clobbered while the cycler runs. */
.test-armed .plsb-test-frame, .test-armed .plsb-test-pills { opacity: 0; transform: translateY(12px); }
.test-armed.is-go .plsb-test-frame {
  opacity: 1; transform: none;
  transition: opacity 0.55s ease 0.15s, transform 0.55s cubic-bezier(0.22, 0.8, 0.3, 1) 0.15s;
}
.test-armed.is-go .plsb-test-pills {
  opacity: 1; transform: none;
  transition: opacity 0.55s ease 0.32s, transform 0.55s cubic-bezier(0.22, 0.8, 0.3, 1) 0.32s;
}

/* The quad beat: a full-canvas 2x2 of audience scenes, one segment name
   centered per quadrant. The staging is multi-stage (quadrants land
   clockwise, headings hold center, then park small against their outer
   edge as the copy panel rises), so it runs on keyframes rather than the
   other beats' one-shot transitions — the base rules below ARE the
   resolved end-frame (headings parked, panel up, film dimmed to backdrop),
   which is what reduced motion and no-JS read; .quad-armed.is-go replays
   how it got there. Fixed dark palette like the film canvas: quadrants
   are photos in both themes. */
.plsb-panel--quad { padding: 0; border-color: rgba(255, 255, 255, 0.14); }
.plsb-quad { position: absolute; inset: 0; background: #05070f; }
.plsb-quad-grid {
  position: absolute; inset: 0;
  display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr;
  gap: 2px;
}
/* DOM order is the clockwise sweep (--i): TL, TR, BR, BL. Grid auto-places
   row-major, so the third cell is re-routed to bottom-RIGHT and the fourth
   closes the loop at bottom-left. */
.plsb-quad-cell:nth-child(3) { grid-area: 2 / 2; }
.plsb-quad-cell:nth-child(4) { grid-area: 2 / 1; }
.plsb-quad-cell { position: relative; margin: 0; overflow: hidden; }
.plsb-quad-cell img {
  position: absolute; inset: 0; width: 100%; height: 100%;
  object-fit: cover;
  /* Resolved = backdrop duty: the scenes drop well back so the landed center
     panel is what the eye lands on. brightness() rather than a veil layer —
     on a black backdrop the two are the same multiply, and this needs no
     extra element in the stack. The floor is legibility of the SCENES, not
     of the type over them: past ~0.4 the gaming quadrant (darkest source)
     goes to mud and the four stop reading as four different audiences. */
  filter: brightness(0.48) saturate(0.85);
}
/* Legibility floor for the headings in BOTH states: soft band at each
   quadrant's outer edge (parked) plus a light overall veil (centered). */
.plsb-quad-cell::after {
  content: ''; position: absolute; inset: 0; z-index: 1;
  background: linear-gradient(to bottom,
      rgba(6, 8, 20, 0.5), rgba(6, 8, 20, 0.1) 34%,
      rgba(6, 8, 20, 0.1) 62%, rgba(6, 8, 20, 0.5));
}
/* The heading rides a full-cell overlay: the overlay's own -/+50% translate
   is what carries it from dead center to the outer edge, so the parked
   offset needs no cell-size math. Top row parks up, bottom row parks down. */
.plsb-quad-labelbox {
  position: absolute; inset: 0; z-index: 2;
  display: flex; align-items: center; justify-content: center;
  pointer-events: none;
}
.plsb-quad-cell:nth-child(-n+2) .plsb-quad-labelbox { transform: translateY(calc(-50% + 32px)); }
.plsb-quad-cell:nth-child(n+3) .plsb-quad-labelbox { transform: translateY(calc(50% - 32px)); }
/* Type rests at the PARKED size and the big centered phase scales it UP
   (--grow) — transient softness while enlarged, crisp once parked. */
.plsb-quad-label {
  --grow: 2.2;
  display: block;
  font-size: clamp(0.84rem, 1.1vw, 1rem);
  font-weight: 700; letter-spacing: 1.8px; text-transform: uppercase;
  color: #fff;
  text-shadow: 0 1px 4px rgba(6, 8, 20, 0.6), 0 2px 18px rgba(6, 8, 20, 0.8);
}
.plsb-quad-center {
  position: absolute; z-index: 3;
  left: 50%; top: 50%; transform: translate(-50%, -50%);
  width: min(560px, 68%);
  padding: clamp(18px, 2.6vw, 30px) clamp(20px, 3vw, 38px);
  text-align: center;
  background: rgba(11, 13, 28, 0.88);
  border: 1px solid rgba(255, 255, 255, 0.16);
  border-radius: var(--radius-md);
  box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
}
.plsb-quad-center h3 {
  margin: 0 0 10px;
  font-size: clamp(1.1rem, 1.6vw, 1.35rem); color: #fff;
}
.plsb-quad-center p {
  margin: 0;
  font-size: 0.95rem; line-height: 1.6; color: rgba(255, 255, 255, 0.82);
}
/* Armed & waiting: the card rests dark until the carousel centers it. */
.quad-armed:not(.is-go) .plsb-quad-cell,
.quad-armed:not(.is-go) .plsb-quad-center { opacity: 0; }
/* The replay. Every `from` is a departure state; each animation's implicit
   `to` is the resolved base above, so the end-frame can never drift from
   what no-JS ships. Beats: quadrants land clockwise (0.5s apart), each
   heading stamps in just after its scene, everything holds, then at ~3s
   the headings park + the film dims (same cue) and the panel rises last. */
.quad-armed.is-go .plsb-quad-cell {
  animation: plsbQuadCell 0.7s cubic-bezier(0.22, 0.8, 0.3, 1) both;
  animation-delay: calc(var(--i, 0) * 0.5s + 0.15s);
}
.quad-armed.is-go .plsb-quad-cell img {
  animation: plsbQuadImg 0.9s ease-out both, plsbQuadDim 0.8s ease both;
  animation-delay: calc(var(--i, 0) * 0.5s + 0.15s), 3.05s;
}
.quad-armed.is-go .plsb-quad-label {
  animation: plsbQuadLabelIn 0.55s ease both,
             plsbQuadLabelPark 0.65s cubic-bezier(0.4, 0, 0.2, 1) both;
  animation-delay: calc(var(--i, 0) * 0.5s + 0.4s), 3.05s;
}
.quad-armed.is-go .plsb-quad-labelbox {
  animation: plsbQuadBoxPark 0.65s cubic-bezier(0.4, 0, 0.2, 1) both;
  animation-delay: 3.05s;
}
.quad-armed.is-go .plsb-quad-center {
  animation: plsbQuadCenter 0.6s cubic-bezier(0.22, 0.8, 0.3, 1) both;
  animation-delay: 3.2s;
}
@keyframes plsbQuadCell { from { opacity: 0; } }
@keyframes plsbQuadImg { from { transform: scale(1.05); } }
@keyframes plsbQuadDim { from { filter: none; } }
@keyframes plsbQuadLabelIn { from { opacity: 0; } }
@keyframes plsbQuadLabelPark { from { transform: scale(var(--grow)); } }
@keyframes plsbQuadBoxPark { from { transform: translateY(0); } }
@keyframes plsbQuadCenter {
  from { opacity: 0; transform: translate(-50%, calc(-50% + 18px)) scale(0.97); }
}
@media (max-width: 760px) {
  /* Portrait cells: tighter park band, smaller grown phase so a long name
     ("Streaming") still fits a half-width quadrant, wider center panel. */
  .plsb-quad-cell:nth-child(-n+2) .plsb-quad-labelbox { transform: translateY(calc(-50% + 26px)); }
  .plsb-quad-cell:nth-child(n+3) .plsb-quad-labelbox { transform: translateY(calc(50% - 26px)); }
  .plsb-quad-label { --grow: 1.7; font-size: 0.8rem; letter-spacing: 1.4px; }
  .plsb-quad-center { width: min(480px, 86%); }
}

/* One horizontal itinerary: list chip → gold-dot trail → Kickstarter chip.
   Wraps to a centered stack (trail turning vertical) when the row can't fit. */
.plsb-handoff {
  display: flex; flex-wrap: wrap; justify-content: center; align-items: center;
  gap: 12px 16px;
}
.plsb-chip {
  font-size: 0.92rem; letter-spacing: 0.3px; color: var(--color-text-light);
  border: 1px solid var(--color-border); border-radius: 999px; padding: 8px 18px;
  background: rgba(255, 255, 255, 0.04); white-space: nowrap;
}
.plsb-chip--ks { border-color: rgba(255, 213, 79, 0.45); color: var(--color-accent); }
/* Launch-list chip: a gold dot marker ties the chip to the formation the
   funnel just landed above it. */
.plsb-chip--list { display: inline-flex; align-items: center; gap: 9px; }
.plsb-chip--list::before {
  content: ''; width: 10px; height: 10px; border-radius: 50%;
  background: #FFD54F; box-shadow: 0 0 8px rgba(255, 213, 79, 0.5);
}
/* The trail: three of the funnel's gold dots in transit, brightening toward
   Kickstarter — the list traveling, not an arrow glyph. */
.plsb-trail { display: inline-flex; align-items: center; gap: 8px; }
.plsb-trail i {
  width: 6px; height: 6px; border-radius: 50%;
  background: #FFD54F; box-shadow: 0 0 6px rgba(255, 213, 79, 0.4);
}
.plsb-trail i:nth-child(1) { opacity: 0.35; }
.plsb-trail i:nth-child(2) { opacity: 0.6; }
.plsb-trail i:nth-child(3) { opacity: 0.9; }
/* Narrow screens: the itinerary stacks and the trail turns vertical — the
   same dots, now dropping down to the Kickstarter chip. */
@media (max-width: 560px) {
  .plsb-handoff { flex-direction: column; gap: 10px; }
  .plsb-trail { flex-direction: column; gap: 7px; }
}
.plsb-panel h3 { font-family: var(--font-heading); font-size: 1.3rem; color: var(--color-text); margin: 0 0 10px; }
.plsb-panel p { font-size: 0.98rem; line-height: 1.6; color: var(--color-text-muted); margin: 0; }
.plsb-panel p + p { margin-top: 10px; }
.plsb-bridge { font-size: 0.9rem; border-top: 1px solid var(--color-border); padding-top: 12px; }
.pl-link {
  color: inherit; text-decoration: underline; text-underline-offset: 3px;
  text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
}
.pl-link:hover { text-decoration-color: currentColor; }

/* --- closing strip, below the funnel -------------------------------------- */
/* A centered column bridging into the walkthrough. Slim form = bridge line +
   chevron; a data `title` opts back into the full launch-day itinerary
   (phase chip, heading, storyboard chip vocabulary). */
.pl-handoff-strip {
  max-width: 560px; margin: var(--space-2xl) auto 0;
  text-align: center; font-family: var(--font-body);
}
.pl-handoff-strip h3 { font-family: var(--font-heading); font-size: 1.3rem; color: var(--color-text); margin: 0 0 10px; }
.pl-handoff-strip p { font-size: 0.98rem; line-height: 1.6; color: var(--color-text-muted); margin: 0; }
.pl-handoff-strip .plsb-handoff { margin-top: 24px; }
/* The bridge reads as a quiet coda here — no divider rule; the itinerary row
   above is the strip's one graphic element. */
.pl-handoff-strip .plsb-bridge { margin-top: 22px; border-top: 0; padding-top: 0; }
/* Funnel lead-in variant: the strip introduces the funnel instead of closing
   the section, so the funnel stage beneath gives up its act-break margin and
   the pair read as one unit. */
.pl-funnel-intro + .pf-stage { margin-top: var(--space-xl); }
/* The lead-in's artifact: a small, deliberately-static evidence token in a
   browser frame sharing the funnel node's exact chrome (.pf-node-bar and
   friends), so this page and the funnel's filter below read as the same
   object. SMALL is the design: at full column width the capture invited
   reading, and its scroll-pan (initFunnelIntroPan, removed 2026-08-21) made
   it worse — the beat's whole job is "a landing page existed and I wrote
   it"; the funnel beneath carries the setup. The frame shows the page TOP,
   cropped, at a width where the hero reads but body copy doesn't. No filter
   effects on the capture — blurring the full-res image wedges the GPU
   rasterizer (same lesson as .pf-funnel). */
.pl-funnel-intro { max-width: 760px; }
.pl-funnel-intro > p { max-width: 560px; margin-left: auto; margin-right: auto; }
.pf-intro-frame { margin: var(--space-xl) auto 0; max-width: 400px; }
.pf-intro-view {
  overflow: hidden;
  height: clamp(220px, 30svh, 300px);
  background: #10101e;
  border: 1px solid var(--color-border-dark); border-top: 0;
  border-radius: 0 0 12px 12px;
}
.pf-intro-view img { display: block; width: 100%; height: auto; }
.pf-intro-frame .plsb-shot-cap { margin-top: 12px; }
/* Slim strips (no title/itinerary): the chevron is the one graphic — the
   bridge line's ↓ carried on, pointing the reader into the walkthrough. */
.pl-handoff-arrow {
  display: inline-flex; margin-top: 14px;
  color: var(--color-text-muted);
}
.pl-handoff-arrow:hover { color: var(--color-text); }
.pl-handoff-arrow:focus-visible { outline: 2px solid var(--color-teal); outline-offset: 3px; border-radius: 6px; }
.pl-handoff-arrow svg {
  width: 22px; height: 22px;
  fill: none; stroke: currentColor;
  stroke-width: 2; stroke-linecap: round; stroke-linejoin: round;
}

@media (max-width: 760px) {
  .prelaunch-section { padding: var(--space-2xl) 0; }
  .prelaunch-heading--center { margin-bottom: var(--space-xl); }
  /* Cards go portrait: copy stacks above the instrument; the film canvases
     keep a fixed stage height; arrows retire (swipe is the native gesture).
     The height floor caps to --plsb-band so it can never push a card past
     the phone's visible band; the tallest card's CONTENT (the duel, compacted
     below to fit the band on modern phones) sets the real strip height. */
  .plsb { --plsb-card: calc(100vw - 36px); --plsb-h: min(clamp(460px, 70svh, 640px), var(--plsb-band)); }
  .plsb-panel { padding: 16px 20px; }
  .plsb-panel--media { padding: 0; }
  .plsb-body { flex-direction: column; align-items: stretch; gap: 16px; }
  .plsb-copy { max-width: none; }
  .plsb--xl .plsb-vis { min-height: 0; }
  /* The test bench keeps its natural height when stacked: the shared
     flex split (basis 0, grow) sized the vis to the leftover band and its
     overflow:hidden ate the URL bar and the pills. The card grows instead. */
  .plsb--xl .plsb-vis--test { flex: 0 0 auto; }
  .plsb-arrow { display: none; }
  /* The page beat stacked: its copy is one short line, so the shared copy/vis
     flex split spent ~350px of the card on dead photo between the sentence
     and the artifact. Natural-height copy hands that surplus to the shot. */
  .plsb-body:has(.plsb-vis--shot) .plsb-copy { flex: 0 0 auto; }
  /* Matches .plsb--xl .plsb-shot's specificity on purpose: a bare .plsb-shot
     here loses to it wherever it sits in the file, and the phone shot has
     been silently running at the desktop's 46svh — overflowing its column
     and getting clipped by the panel. This budget is the stacked column
     minus the vis padding and the caption. */
  .plsb--xl .plsb-shot { height: 300px; }
  /* Portrait cards: the test bench compacts beneath its copy — tighter
     frame padding and smaller type; the pills keep their tap-sized row.
     The --page hero keeps its aspect but leans a touch taller so the
     headline + tagline + CTA column clears the shorter measure. */
  .plsb-test { max-width: none; }
  .plsb-test-view { padding: 16px 16px 18px; }
  /* No fixed aspect on the stacked card — the desktop ratio overflowed the
     band and clipped the pills. Height follows the hero copy instead, with
     a padded band of art above the eyebrow keeping the scene readable. */
  .plsb-test--page .plsb-test-view { padding: 0; aspect-ratio: auto; }
  .plsb-test--page .plsb-test-hero { padding-top: clamp(64px, 26vw, 120px); }
  .plsb-test-head { font-size: 0.95rem; }
  .plsb-test-tag { font-size: 0.78rem; }
  .plsb-test--page .plsb-test-head { font-size: 1rem; }
  .plsb-test-ctas { margin-top: 12px; }
  .plsb-test-pills { gap: 6px; margin-top: 12px; }
  .plsb-test-pill { font-size: 0.72rem; padding: 3px 12px; }
  /* Portrait cards: the duel stacks — norm column above the page column,
     payoff still closing the card. This is the strip's height driver, so
     every dimension here is tuned against --plsb-band: the whole card fits
     the visible band on modern phones (SE-class with toolbars open remains
     the accepted shortfall). */
  .plsb-duel { justify-content: flex-start; gap: 12px; }
  .plsb-duel-title { font-size: 1.32rem; line-height: 1.25; }
  .plsb-duel-cols { grid-template-columns: 1fr; gap: 14px; width: 100%; }
  .plsb-duel-col { gap: 10px; }
  /* The hinge goes inline on the stack: a static grid item ordered between
     the two halves, so "The Kickstarter norm" reads as side A of a duel
     instead of a subtitle. */
  .plsb-duel-cols::before {
    position: static; order: 1; --vs-pos: translate(0, 0);
    justify-self: center; width: 28px; height: 28px; font-size: 0.66rem;
  }
  /* The answer keeps its gold box, on a tighter block budget. */
  .plsb-duel-col--page { order: 2; padding: 12px 16px 14px; }
  /* Stacked cards shed the norm column's desktop panel (the scrim carries its
     copy): this is the strip's tallest card, so its padding taxes all five.
     The --bg variant is named too — its on-photo tint outranks a bare reset. */
  .plsb-duel-col:not(.plsb-duel-col--page),
  .plsb-panel--bg .plsb-duel-col:not(.plsb-duel-col--page) { border: 0; border-radius: 0; background: none; padding: 0; }
  /* …which is exactly why the scrim's middle floor goes back up here: with
     that panel gone the norm copy reads straight off the photo, so it can't
     ride the lighter landscape dim. */
  .plsb-panel--duel .plsb-bg-scrim { --duel-dim: 0.7; }
  /* below the desktop clamp's floor, but still the card's loudest gold line */
  .plsb-duel .plsb-duel-payoff { font-size: 1.1rem; line-height: 1.35; }
  /* The norm's tag pile was the duel's tallest block: three STACKED tags
     (~140px) that the visible band can't afford. It turns into a compact
     chip ROW — percent over name, string-hole and fan retired at chip scale
     (the same retire-the-flourish move as the gauntlet's traveling doc).
     The gold tag keeps its wide hero form and its tilt. */
  .plsb-tags { min-height: 0; flex-direction: row; justify-content: center; gap: 8px; }
  .plsb-tags:not(.plsb-tags--page) .plsb-tag {
    flex: 1 1 0; min-width: 0;
    flex-direction: column; gap: 2px;
    padding: 8px 6px; text-align: center;
  }
  .plsb-tags:not(.plsb-tags--page) .plsb-tag::before { display: none; }
  /* A no-op component, not `none`: the armed reveal composes
     translateY(16px) with this var, and a `none` keyword inside the list
     would invalidate the whole transform (IACVT), dropping the rise. */
  .plsb .plsb-tags:not(.plsb-tags--page) .plsb-tag { --tag-rest: translate(0, 0); }
  .plsb-tag b { font-size: 1.15rem; }
  .plsb-tag > span { font-size: 0.72rem; line-height: 1.3; }
  /* hold the gold tag at its pre-scale phone size */
  .plsb-tag--gold { padding: 12px 22px 12px 16px; }
  .plsb-tag--gold b { font-size: 1.8rem; }
  .plsb-tag--gold > span { font-size: 0.88rem; }
  .plsb-duel-col p { font-size: 0.9rem; line-height: 1.5; }
  /* Portrait cards: the route goes vertical — gates stack as rows on a
     left rail, the traveling chip retires (no horizontal runway), and each
     row carries its own sign-off stamp at the end of the line. */
  .plsb-route { padding: 10px 0; width: max-content; max-width: 100%; }
  .plsb-gates { grid-template-columns: 1fr; gap: 14px; }
  .plsb-gates::before {
    left: 12.5px; right: auto; top: 14px; bottom: 14px;
    width: 1px; height: auto;
    background: linear-gradient(
      color-mix(in srgb, var(--color-teal) 40%, transparent),
      color-mix(in srgb, var(--color-teal) 14%, transparent));
  }
  .plsb-route.gaunt-armed .plsb-gates::before { transform: scaleY(0); transform-origin: top; }
  .plsb-route.is-go .plsb-gates::before { transform: scaleY(1); transition: transform 3.6s cubic-bezier(0.3, 0, 0.4, 1) 0.15s; }
  .plsb-gate {
    flex-direction: row; align-items: center; gap: 12px;
    text-align: left;
  }
  .plsb-gate-badge { margin-bottom: 0; flex: 0 0 auto; }
  .plsb-gate-name { font-size: 0.98rem; }
  .plsb-gate-role { margin-left: auto; }
  /* Stacked rows: the gate's stamp joins the flow at the end of its line
     (riding right beside the role) instead of hanging beneath the column. */
  .plsb-gate-stamp {
    --tx: 0px; --ty: 0px;
    position: static; flex: 0 0 auto;
    transform: rotate(var(--rot));
  }
  .plsb-route-doc { display: none; }
  /* Swap beat: the plate row's tag moves above a tighter before/arrow/after
     strip — the 4.4rem tag column has no room on a phone-width grid. */
  .plsb-swap-row { grid-template-columns: 1fr auto 1fr; row-gap: 4px; }
  .plsb-swap-tag { grid-column: 1 / -1; text-align: left; font-size: 0.64rem; }
  .plsb-swap-plate { font-size: 0.85rem; padding: 6px 4px; }
  .plsb-swap-panel { padding: 10px 12px; }
  .plsb-swap-panel p { font-size: 0.86rem; }
}

.approach-text {
  font-size: 1.1rem;
  line-height: 1.7;
  color: var(--color-text-light);
}

/* === § 14. Campaign Walkthrough [.campaign-walkthrough-section .walkthrough- .igg- .wt-] === */
/* Phone-frame guided tour (plus the macOS browser-frame variant below). */

.campaign-walkthrough-section {
  position: relative;
  /* Jump-link target for the hero CTA and "Campaign Page" badge. On desktop the
     scroll lands via a JS-computed offset (past the runway top), so this only
     applies to the static/mobile fallback that uses scrollIntoView — keeping the
     heading clear of the fixed header there. */
  scroll-margin-top: calc(var(--header-height) + var(--hud-height));
}

.campaign-walkthrough-runway {
  min-height: 200vh;
}

.campaign-walkthrough-sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  display: flex;
  align-items: flex-start;
  padding-top: 80px;
  overflow: hidden;
}

.campaign-walkthrough-sticky > .container {
  width: 100%;
  height: 100%;
  /* Keep content above the optional .walkthrough-bg photo layer */
  position: relative;
  z-index: 1;
}

/* Optional act backdrop — photo fills the pinned frame behind a dark overlay */
.walkthrough-bg {
  position: absolute;
  inset: 0;
  overflow: hidden;
}

.walkthrough-bg img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.walkthrough-bg-overlay {
  position: absolute;
  inset: 0;
  background: rgba(26, 26, 46, 0.92);
}

/* Desktop: the backdrop collage (bright video-call cells, saturated accents)
   still competed with the teal annotations through the 0.92 scrim — mute the
   photo itself and deepen the overlay. Mobile keeps the lighter treatment
   (smaller cells read as texture there). */
@media (min-width: 769px) {
  .walkthrough-bg img {
    filter: saturate(0.6) brightness(0.85);
  }

  .walkthrough-bg-overlay {
    background: rgba(26, 26, 46, 0.94);
  }
}

/* Texture backdrops (e.g. HP's torn-kraft) are already dark, matte, and
   low-contrast, so they don't fight the teal annotations the way U4's bright
   collage did. Drop the saturate/brightness muting and use a lighter, warm-dark
   scrim (near-black espresso, not navy) so the paper grain and torn edges read
   through instead of being flattened to the section background. */
.campaign-walkthrough-section--bg-texture .walkthrough-bg-overlay {
  background: rgba(18, 13, 11, 0.8);
}

@media (min-width: 769px) {
  .campaign-walkthrough-section--bg-texture .walkthrough-bg img {
    filter: none;
  }

  .campaign-walkthrough-section--bg-texture .walkthrough-bg-overlay {
    background: rgba(18, 13, 11, 0.82);
  }
}

/* Animated effect backdrop (e.g. embers) — the canvas paints its own opaque
   dark warm base, so the scrim is only a light unifying tint that lets the
   embers glow through while keeping the annotations readable. */
.walkthrough-bg-fx {
  display: block;
  width: 100%;
  height: 100%;
}

.campaign-walkthrough-section--bg-effect .walkthrough-bg-overlay {
  background: rgba(8, 5, 3, 0.3);
}

@media (min-width: 769px) {
  .campaign-walkthrough-section--bg-effect .walkthrough-bg-overlay {
    background: rgba(8, 5, 3, 0.42);
  }
}

/* Fade-through-black entrance cover (JS-created for backdrop acts): starts
   solid so the section scrolls in as a page-bg-colored block — invisible
   against the body background the hero faded to — then dissolves in place to
   reveal the backdrop photo. Later sibling of .walkthrough-bg, below the
   z-index:1 content container. */
.walkthrough-fade-cover {
  position: absolute;
  inset: 0;
  background: var(--color-bg);
  pointer-events: none;
}

/* Backdrop acts render over a dark scrim in every context; the site is
   dark-theme-only (all templates set data-theme="dark"), so the theme
   variables ARE the dark palette these values were copied from. */
.campaign-walkthrough-section--bg .walkthrough-annotation-text {
  color: var(--color-text);
}

.campaign-walkthrough-section--bg .walkthrough-heading-subtitle {
  color: var(--color-text-muted);
}

.campaign-walkthrough-section--bg .walkthrough-live-link {
  color: var(--color-text-muted);
}

.campaign-walkthrough-section--bg .walkthrough-live-link:hover {
  color: #fff;
}

.campaign-walkthrough-section--bg .walkthrough-annotation-label,
.campaign-walkthrough-section--bg .walkthrough-annotation-text a {
  color: var(--color-teal);
}

.campaign-walkthrough-layout {
  display: grid;
  /* Fixed annotation column + centered group: with the fit-to-height phone now
     narrower on typical laptops, a stretched `1fr` column left a wide dead band
     between the annotation copy and the far-right progress rail. A fixed column
     keeps the rail a constant distance from the copy, and justify-content:center
     balances the phone + annotations as a group. (Tablet-frame acts restore the
     stretched layout below — their device already fills a wider container.) */
  grid-template-columns: auto 440px;
  grid-template-rows: 1fr;
  justify-content: center;
  gap: var(--space-3xl);
  align-items: stretch;
  height: calc(100vh - 80px - var(--space-lg));
  padding-top: var(--space-lg);
  position: relative;
  max-width: var(--container-feature);
  margin: 0 auto;
  /* Device sizing vars live on the LAYOUT (not .wt-phone) so BOTH grid
     columns can read them — the 769–1024px block keys the annotation
     column's height to the phone's. .wt-phone consumes var(--wt-phone-h);
     the full sizing rationale sits on that rule. */
  --wt-phone-max-h: 1044px;
  --wt-phone-h: min(var(--wt-phone-max-h), max(560px, calc(100vh - 80px - 2 * var(--space-lg))));
  /* Browser-window frame height (the 0.75x of the 944x736 window geometry;
     0.875x from 1400px — see the .wt-phone--browser blocks, which consume
     this). On the LAYOUT for the same reason as --wt-phone-h: the annotation
     column keys its height to the frame when a browser act centers. */
  --wt-browser-h: 552px;
}

.campaign-walkthrough-phone {
}

/* Phone frame — walkthrough (fully standalone; the old static .phone-screen
   mockup it once deliberately avoided inheriting from is gone) */
.campaign-walkthrough-phone {
  flex-shrink: 0;
}

.wt-phone {
  position: relative;
  /* Fit-to-height. The device is sized from the available pinned-viewport height
     (never larger than its natural size) so it is never clipped by the 100vh
     sticky frame. A fixed 1044px phone ran ~250-380px off the bottom of most
     laptops; worse, the pan choreography frames content against the FULL screen
     height (visibleH = phoneScreen.offsetHeight in main.js), so that clip drove
     highlights below the fold. --wt-phone-max-h caps the natural size per width
     breakpoint; width tracks height at the device aspect ratio (520:1044). The
     ~560px floor keeps very short viewports usable (a small clip there beats a
     doll-house phone). No JS change is needed: measurePositions() re-reads
     offsetHeight and re-derives visibleH / segments / spotlight scale live.
     --wt-phone-max-h / --wt-phone-h are DECLARED on .campaign-walkthrough-layout
     (so the annotation column can share them); this rule only consumes. */
  width: calc(var(--wt-phone-h) * 520 / 1044);
  height: var(--wt-phone-h);
}

.wt-phone-screen {
  position: absolute;
  /* viewBox-relative to the 520x1044 frame (screen inset 14,14; 492x1016; radius
     28) so the CSS screen tracks the scaling SVG bezel cutout at any rendered
     size — mirrors the fluid browser-frame variant below. */
  top: 1.341%;
  left: 2.6923%;
  width: 94.6154%;
  height: 97.318%;
  border-radius: 5.6911% / 2.7559%;
  overflow: hidden;
  z-index: 1;
  background: #fff;
}

.wt-phone-bezel {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  z-index: 2;
  pointer-events: none;
}

.wt-phone-scroll {
  /* Status-bar safe area: the dynamic island bottom sits at 38px in screen
     space (bezel y 24-52, screen offset 14), so push the page content below
     it — otherwise the first block's highlight box runs under the island */
  padding-top: 48px;
}

/* KS-bar acts: the rebuilt Kickstarter logo bar opens the scroll and pulls
   itself up with a -12px top margin, landing its top edge at 36px — under the
   island graphic. Extra headroom (scoped to the drawn frame: the synced panel
   reuses .wt-phone-scroll with no bezel) parks the bar just below the island
   at walkthrough start. */
.wt-phone .wt-phone-scroll:has(.igg-ks-bar) {
  padding-top: 64px;
}

/* IGG content recreation styles.
   Horizontal padding is ZERO: capture slices are full-bleed screenshots (the
   phone screen showed every 364px-wide capture at 340px — a 12px white bar
   down each side, same letterbox the desktop-capture acts already shed).
   Text blocks re-add their own gutters below (p/h2/hero/funding), matching
   the real IGG mobile layout: images edge-to-edge, copy inset. */
.igg-content {
  background: #fff;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  font-size: 13px;
  line-height: 1.6;
  color: #333;
  padding: 12px 0;
}

.igg-content img {
  width: 100%;
  height: auto;
  display: block;
  margin-bottom: 10px;
}

.igg-content video {
  width: 100%;
  height: auto;
  display: block;
  margin-bottom: 10px;
}

/* When an act ENDS on a capture block, the recreation's bottom edge should be
   the image's bottom edge — the trailing 10px img margin + 12px content pad
   painted a bare background strip below the final screenshot (flagged on the
   Pint X financing close, but true of every capture-ending act). Acts ending
   in text keep the pad. */
.igg-content:has(> .igg-block:last-child) { padding-bottom: 0; }
.igg-block:last-child > img:last-child,
.igg-block:last-child > video:last-child,
.igg-block:last-child > div:last-child { margin-bottom: 0; }

.igg-content p {
  margin: 0 0 14px;
  padding: 0 16px; /* was 4px on a 12px-padded parent — same effective inset */
}

.igg-content h2 {
  padding: 0 12px; /* parent gutter is 0 now; keep headings off the screen edge */
}

.igg-headline-img {
  margin-top: 20px;
}

/* Kickstarter site-nav bar — thin white top chrome with the green wordmark,
   full-bleed across the phone screen. Negative margins cancel .igg-content's
   12px padding so the bar sits flush at the top of the scroll, right below the
   frame's dynamic island — mirroring the real KS mobile page header. */
.igg-ks-bar {
  display: flex;
  align-items: center;
  justify-content: center;
  margin: -12px 0 0; /* cancels the parent's top pad; sides are full-bleed now */
  padding: 9px 14px;
  background: #fff;
  border-bottom: 1px solid #ececec;
}

.igg-content .igg-ks-logo {
  width: auto;
  height: 16px;
  margin: 0;
}

/* With the KS bar occupying the top, the hero heading no longer needs its
   island-clearing top pad — tighten to a normal gap below the bar. */
.igg-ks-bar + .igg-hero-heading {
  padding-top: 14px;
}

/* IGG mobile hero recreation — title + funding blocks in period (2021) IGG
   styling. The mobile archive never rendered these, so they're rebuilt as
   HTML; the values live in the campaign-content partial. */
.igg-hero-heading {
  /* Top padding clears the phone frame's dynamic island (ends 38px below
     screen top; .igg-content contributes 12px) — this block always sits at
     the very top of the phone screen. */
  padding: 34px 12px 2px;
}

.igg-content .igg-hero-title {
  font-size: 19px;
  font-weight: 700;
  line-height: 1.3;
  color: #1f1f1f;
  margin-bottom: 5px;
}

.igg-content .igg-hero-byline {
  font-size: 12px;
  color: #6a6a6a;
  margin-bottom: 6px;
}

.igg-content .igg-hero-tagline {
  font-size: 14px;
  color: #444;
  margin-bottom: 12px;
}

.igg-funding {
  padding: 2px 12px 6px;
}

.igg-content .igg-funding-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  margin-bottom: 9px;
}

.igg-funding-raised {
  font-size: 18px;
  font-weight: 700;
  color: #1f1f1f;
}

.igg-funding-raised small {
  font-size: 12px;
  font-weight: 400;
  color: #6a6a6a;
}

.igg-funding-backers {
  font-size: 12px;
  color: #6a6a6a;
}

.igg-funding-bar {
  height: 5px;
  margin: 0 4px;
  border-radius: 3px;
  background: #e8e8e8;
  overflow: hidden;
}

.igg-funding-bar span {
  display: block;
  width: 100%;
  height: 100%;
  background: #2a2a2a;
}

.igg-content .igg-funding-goal {
  font-size: 12px;
  color: #6a6a6a;
  margin: 9px 0 0;
}

.igg-funding-goal-type {
  border-bottom: 1px dotted #9a9a9a;
}

/* Kickstarter-style funding column (post-campaign mobile state): stacked
   stat/label pairs behind a light left rule, per the KS mobile layout. */
.igg-funding--ks {
  border-left: 4px solid #e8e8e8;
  padding-left: 12px;
  margin: 6px 16px 10px;
}

.igg-content .igg-funding--ks .igg-funding-row {
  justify-content: flex-start;
  margin-bottom: 0;
}

.igg-content .igg-funding--ks .igg-funding-goal {
  margin: 0 0 12px;
}

/* Campaign share buttons are narrow assets — don't stretch to column width */
.igg-content .igg-share-btn {
  width: 49%;
  margin: 0 auto 10px;
}

/* Guided-excerpt elisions — the walkthrough's own editorial voice standing in
   for omitted page sections. Site font marks the register switch; colors are
   fixed (like .igg-content's #fff/#333) because the artifact page never
   follows the site theme — the teal is the light-theme accent for the same
   reason. Dashed rules + a midline ⋯ read as a deliberate cut, not a break. */
.igg-content .igg-elide {
  margin: 26px 16px;
  padding: 12px 8px 14px;
  border-top: 1px dashed #c9cdd6;
  border-bottom: 1px dashed #c9cdd6;
  text-align: center;
  font-family: var(--font-body);
}
.igg-elide-dots {
  display: block;
  font-size: 18px;
  line-height: 1;
  margin-bottom: 6px;
  color: #b6bcc9;
}
.igg-content .igg-elide p {
  margin: 0;
  padding: 0;
  font-size: 11px;
  letter-spacing: 0.6px;
  text-transform: uppercase;
  color: #8a90a0;
}
.igg-content .igg-elide-link {
  display: inline-block;
  margin-top: 10px;
  font-size: 12px;
  font-weight: 600;
  letter-spacing: 0.3px;
  color: #00796B;
  text-decoration: none;
}
.igg-content .igg-elide-link:hover {
  text-decoration: underline;
}
.igg-content .igg-elide--end {
  border-bottom: 0;
  margin-bottom: 4px;
}

.igg-block {
  position: relative;
  margin-bottom: 16px;
  transition: box-shadow 0.5s ease;
}

/* One stretch of campaign page split across two blocks because it carries two
   focuses (a focus must be its own .igg-block: both engines measure it with
   el.offsetTop and this rule is position:relative, and interleaved mode only
   slices direct .igg-block children). --joined drops the seam margin on the
   first half so the pair still reads as the single continuous run it is on
   the real page. */
.igg-block--joined {
  margin-bottom: 0;
}

/* The block's own gap is not the whole seam: .igg-content img carries its own
   margin-bottom, so a pair split mid-IMAGE (be-cool's tech module, cut at the
   panel border so the proof and the buyer can be separate stops) would still
   show a white band through one continuous graphic. Collapse both and the
   halves reassemble pixel-exact. Scoped to the LAST image in the block so a
   joined block holding a stack of images keeps its internal rhythm. */
.igg-block--joined img:last-child,
.igg-block--joined video:last-child {
  margin-bottom: 0;
}

/* A block holding contiguous cuts of ONE continuous stretch — a mega-slice
   split so each callout can highlight the section it discusses (omnilux's
   hero) — keeps no internal rhythm: every img margin would paint a 10px
   white band straight through the artwork. Zeroes every img margin inside
   the block; the block's own 16px bottom margin still separates the stretch
   from the next one. Distinct from --joined, which welds two BLOCKS. */
.igg-block--flush img {
  margin-bottom: 0;
}

/* LUA's recreated hero (campaign-content-lua): the live hero was the CDN's
   24s 3D render video with the lockup overlaid as page text, so the act
   rebuilds it as video + HTML instead of shipping a frozen frame (Jakob,
   2026-08-07 round — "make it actually play"). Sized entirely in cqi so the
   composition holds at any engine layout width (pinned lays the content out
   at frame width, synced at panel width; both then transform-scale it, which
   container units ignore). The aspect box is the hero band of the 2000-wide
   capture space; the scrim buys the overlay its contrast over whatever frame
   the loop is on, per Jakob's instruction. Overlay geometry re-derives the
   capture's own lockup positions (copy column at 10% from the left edge). */
.wt-lua-hero {
  position: relative;
  aspect-ratio: 2000 / 846;
  overflow: hidden;
  background: #1e252d;
  container-type: inline-size;
  font-family: 'Lato', 'Helvetica Neue', Helvetica, Arial, sans-serif;
}

.wt-lua-hero video {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: 62% 45%;
  margin: 0;
}

.wt-lua-hero-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(90deg, rgba(20, 27, 34, 0.88), rgba(20, 27, 34, 0.5) 45%, rgba(20, 27, 34, 0) 72%);
}

.wt-lua-hero-copy {
  position: absolute;
  left: 10%;
  top: 50%;
  transform: translateY(-50%);
  color: #fff;
}

.wt-lua-hero-lockup {
  display: flex;
  align-items: center;
}

.wt-lua-hero-wordmark {
  font-size: 10.6cqi;
  font-weight: 700;
  line-height: 1;
  letter-spacing: 0.02em;
}

.wt-lua-hero-divider {
  width: 0.2cqi;
  height: 7.2cqi;
  background: #4a545e;
  margin: 0 1.9cqi 0 2.1cqi;
}

.wt-lua-hero-2in1 {
  display: flex;
  flex-direction: column;
  gap: 0.9cqi;
}

.wt-lua-hero-2in1-num {
  font-size: 4.6cqi;
  font-weight: 700;
  line-height: 1;
}

.wt-lua-hero-2in1-icons {
  display: flex;
  align-items: center;
  gap: 0.9cqi;
}

.wt-lua-hero-2in1-icons svg {
  height: 2.6cqi;
  width: auto;
  display: block;
}

.wt-lua-hero-plus {
  font-size: 2cqi;
  font-weight: 300;
  line-height: 1;
}

.wt-lua-hero-sub {
  margin: 2.6cqi 0 0;
  padding: 0;
  font-size: 2.25cqi;
  line-height: 1.55;
}

.wt-lua-hero-sub strong {
  font-weight: 700;
}

.wt-lua-hero-sub span {
  font-weight: 300;
  color: #c6cdd4;
}

.wt-lua-hero-cta {
  display: inline-block;
  margin-top: 3.2cqi;
  padding: 1.3cqi 2.9cqi;
  border-radius: 999px;
  background: #e2564b;
  font-size: 1.5cqi;
  font-weight: 700;
  letter-spacing: 0.1em;
}

/* The LAST block has nothing below to separate itself from, but still pays
   both gaps — its own 16px plus the image's 10px — so every recreation ends
   on 26px of panel white that reads as a dead strip rather than the page
   simply ending. Same collapse --joined performs at a seam, applied at the
   tail. */
.igg-content > .igg-block:last-child,
.igg-content > .igg-block:last-child > img:last-child,
.igg-content > .igg-block:last-child > video:last-child {
  margin-bottom: 0;
}

/* Box outlines for active callouts are now rendered as SVG bloom paths
   (.walkthrough-bloom) drawn into .walkthrough-lines, so they share the same
   stroke-dashoffset draw-on motion language as the connecting lines. The CSS
   `outline:` approach was replaced because (a) it can't draw on with
   stroke-dashoffset, and (b) the SVG approach unifies DOM-anchored and
   rect-anchored callouts under a single render path. */

/* Stacked callout copy in the annotation slot — fades in per sub-step */
.walkthrough-callout-text {
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 0.5s ease, transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
}

.walkthrough-callout-text.revealed {
  /* De-emphasized: revealed but not in the callout group currently in focus.
     Held at 0.6 (not lower) so superseded callouts stay above the WCAG 4.5:1
     contrast floor while still reading as backgrounded relative to .emphasized. */
  opacity: 0.6;
  transform: translateY(0);
}

/* When a callout is superseded (revealed but no longer emphasized) it dims to
   0.6 on a short delay, so the incoming callout reaches full emphasis first and
   the reader tracks one focal change at a time rather than a simultaneous
   brighten-here / dim-there. The delay only applies to the dimming direction:
   the end state of a re-emphasis or first reveal is .emphasized, which keeps
   the un-delayed base transition. */
.walkthrough-callout-text.revealed:not(.emphasized) {
  transition: opacity 0.5s ease 0.15s,
              transform 0.55s cubic-bezier(0.4, 0, 0.2, 1);
}

/* The callout(s) currently in focus stay at full emphasis. `.emphasized` is the
   current sub-step plus any earlier parts of the same multi-part callout, so a
   split callout's first sentence does not dim when its second sentence enters. */
.walkthrough-callout-text.revealed.emphasized {
  opacity: 1;
}

.walkthrough-callout-text:not(:first-of-type) {
  margin-top: var(--space-lg);
}

/* Optional per-callout eyebrow — set via a callout's `label` field in
   projects.json, for multi-callout focuses where a later callout shifts
   topic under the same focus heading. It lives INSIDE the callout <p>, so
   it inherits every reveal/dim/current state (desktop cascade, synced
   dock swap, static fallback) for free — no JS involvement. Subordinate
   to .walkthrough-annotation-label: same eyebrow language, smaller, and it
   must STAY subordinate — a callout that needs a peer-level heading needs
   its own focus instead (that is also the only way its predecessor clears
   the column first), which is what the HP `crafted` / `crafted-credits`
   split does. No callout in projects.json carries a label as of July 2026;
   the rule stays as the documented sub-topic affordance. */
.walkthrough-callout-label {
  display: block;
  font-size: 0.75rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 2px;
  color: var(--color-teal);
  margin-bottom: var(--space-xs);
}

/* Liquid spotlight overlay — sits inside .wt-phone-screen.
   Two stadium-shaped "blobs" (lead/trail) plus a connecting line tendril
   inside an SVG mask cut through a dim overlay. A goo filter (blur + alpha
   threshold) unifies the three shapes into one liquid form. The morph
   between sub-steps is JS-driven (rAF in main.js) — there are no CSS
   transitions on the blobs themselves. */
.walkthrough-spot-svg {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 2;
  opacity: 0;
  transition: opacity 0.4s ease;
}

.walkthrough-spot-svg.visible {
  opacity: 1;
}

/* Dim scrim outside the goo cutout. Tuned via one variable so the figure/ground
   strength is adjustable in a single place; 0.62 pulls the eye to the lit region
   more firmly than the prior 0.55 while keeping the surrounding campaign copy —
   the work being demonstrated — readable. (CSS `fill` overrides the template's
   presentation-attribute fallback.) */
.wt-spot-overlay {
  fill: var(--wt-spot-dim, rgba(26, 26, 46, 0.62));
}

.wt-spot-blob {
  /* Initial collapsed state — set to real bounds by JS on first reveal.
     Bridge line uses x1/y1/x2/y2 attributes instead. */
  x: 0;
  y: 0;
  width: 0;
  height: 0;
}

/* Synced-mode highlight stroke — the outline twin of the mask cutout above,
   giving the mobile highlight the stroked edge desktop draws via the bloom.
   walkthrough-mobile.js mirrors the blob rect's geometry onto it every apply
   and toggles .visible with the spotlight; pinned/static modes never touch
   it, so outside synced it stays at the opacity-0 default. */
.wt-spot-stroke {
  fill: none;
  stroke: var(--color-teal);
  stroke-width: 2.5px;
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}

.campaign-walkthrough-section.is-synced .wt-spot-stroke.visible {
  opacity: 1;
}

/* Authored underline inside a highlight — a .wt-underline span in the campaign
   content draws a lilac rule under its phrase once its callout's highlight has
   landed. Both engines toggle .wt-callout-lit on the callout's target element
   (pinned: setRevealedCount; synced: setSpot), and the delay here is what
   sequences the draw AFTER the spotlight/bloom settles (0.4s fade + a beat).
   background-image (not border/pseudo-element) so a phrase that wraps still
   underlines every fragment via box-decoration-break: clone. */
.wt-underline {
  padding-bottom: 3px;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
  background-image: linear-gradient(0deg, #b9a1f7, #b9a1f7);
  background-repeat: no-repeat;
  background-position: 0 100%;
  background-size: 0% 2px;
  transition: background-size 0.25s ease;
}

.wt-callout-lit .wt-underline {
  background-size: 100% 2px;
  transition: background-size 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.55s;
}

/* The highlight rect is the target's border-box in both engines, so padding is
   how a target buys the underline breathing room inside its own highlight. */
p[data-callout]:has(.wt-underline) {
  padding-bottom: 8px;
}

@media (prefers-reduced-motion: reduce) {
  .wt-underline,
  .wt-callout-lit .wt-underline {
    transition: none;
  }
}

.igg-block--shipping {
  opacity: 0.6;
}

/* Annotations column */
.campaign-walkthrough-annotations {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: flex-start;
  padding-top: var(--space-xl);
  max-height: calc(100vh - 80px - var(--space-lg) * 2);
}

/* Persistent section header */
.walkthrough-heading {
  margin-top: var(--space-3xl);
  margin-bottom: var(--space-3xl);
}

.walkthrough-heading-title {
  font-size: 1.9rem;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 3px;
  color: rgba(255, 255, 255, 0.85);
  margin: 0 0 var(--space-xs) 0;
  line-height: 1.2;
}

.walkthrough-heading-subtitle {
  font-size: 1.3rem;
  color: var(--color-text-muted);
  margin: 0;
  font-style: italic;
  letter-spacing: 0.5px;
}

/* External link to the live campaign — plain underlined text, not a button */
.walkthrough-live-link {
  display: inline-block;
  margin-top: var(--space-sm);
  font-size: 0.9rem;
  color: var(--color-text-muted);
  text-decoration: underline;
  text-underline-offset: 3px;
  transition: color var(--transition-fast);
}

.walkthrough-live-link:hover {
  color: var(--color-text);
}

/* Rotating annotation slot — annotations anchor to the TOP of the slot
   (just under the heading + live-campaign link), not its vertical center. */
.walkthrough-annotation-slot {
  position: relative;
  flex: 1;
  display: flex;
  align-items: flex-start;
}

.walkthrough-annotation {
  position: absolute;
  top: 0;
  left: 0;
  opacity: 0;
  transform: translateY(24px) scale(0.96);
  filter: blur(4px);
  /* Neutral reset transition. The enter (.active) and exit (.exiting) states
     below each supply their own direction-specific timing so a focus swap
     reads as "old card clears, then new card lands" rather than two
     half-faded, blurred cards crossing in the slot. */
  transition: opacity 0.4s ease, transform 0.4s ease, filter 0.4s ease;
  max-width: 380px;
  pointer-events: none;
}

/* Enter: slower with a strong decelerate landing, nudged slightly late so the
   outgoing card is mostly gone before this one arrives. */
.walkthrough-annotation.active {
  opacity: 1;
  transform: translateY(0) scale(1);
  filter: blur(0);
  pointer-events: auto;
  transition: opacity 0.55s cubic-bezier(0.22, 1, 0.36, 1) 0.1s,
              transform 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.1s,
              filter 0.55s cubic-bezier(0.22, 1, 0.36, 1) 0.1s;
}

/* Exit: accelerating, no blur — the outgoing card lifts and fades out of the
   way instead of lingering blurred underneath the incoming one. The flip now
   fires on the FIRST transit/exit frame (the final dwell is the whole read),
   so the release is softened — 0.45s with shorter travel (-10px) reads as a
   smooth fade rather than a snap when the flip lands at scroll speed. */
.walkthrough-annotation.exiting {
  opacity: 0;
  transform: translateY(-10px) scale(0.98);
  filter: blur(0);
  transition: opacity 0.45s cubic-bezier(0.4, 0, 1, 1),
              transform 0.5s cubic-bezier(0.4, 0, 1, 1);
}

/* Scroll cue (JS-generated by main.js): fills the deliberately blank
   annotation slot during the walkthrough's entry phase, inviting the scroll
   that drives the tour. Pinned mode only; the element never exists in
   static mode. */
.walkthrough-scroll-hint {
  position: absolute;
  top: 50%;
  left: 0;
  transform: translateY(calc(-50% + 16px));
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--space-sm);
  max-width: 320px;
  padding: var(--space-lg);
  background: var(--glass-fill-deep);
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  /* Gold stroke at .plsb-chip--ks weight + the soft .plsb-tag--gold halo:
     the cue speaks the site-wide "gold = go" language from first paint.
     Halo lives in box-shadow, not filter (GPU rule: no drop-shadow on
     large elements). */
  border: 1px solid color-mix(in srgb, var(--color-accent) 45%, transparent);
  border-radius: var(--radius-md);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1), 0 4px 24px rgba(0, 0, 0, 0.15),
              0 0 24px color-mix(in srgb, var(--color-accent) 12%, transparent);
  opacity: 0;
  transition: opacity 0.4s ease, transform 0.4s ease;
  pointer-events: none;
}

.walkthrough-scroll-hint.visible {
  opacity: 1;
  transform: translateY(-50%);
}

/* Mouse glyph + keycap row share one line — the two halves of the same
   "here's how to drive this" cue, so they sit together rather than with the
   keys stranded at the bottom of the column. The visibility gate stays on
   .wt-scroll-hint-keys below, never on this wrapper, so a touch device
   collapses the row back to the bare glyph. */
.wt-scroll-hint-cue {
  display: flex;
  align-items: center;
  gap: 10px;
}

/* Gold from the first frame (the old late-runway --arm teal→gold warm-up is
   retired — one steady "go" state, no transition left to run). Tiny 24×38
   glyph, so the drop-shadow filter is cheap and GPU-safe. */
.wt-scroll-hint-mouse {
  color: var(--color-accent);
  filter: drop-shadow(0 0 7px color-mix(in srgb, var(--color-accent) 55%, transparent));
}

/* Breathing halo. The pulse lives on an ::after overlay whose OPACITY
   animates — animating box-shadow/border-color keyframes directly on the
   backdrop-filter panel above would force repaints of the blurred region
   every frame in some engines; opacity stays compositor-only. Max-intensity
   border + halo are baked in here, so the visible pulse is the fade between
   the panel's static 45%-border/12%-halo and this 60%/16% ceiling.
   inset:-1px sits the overlay's 1px border exactly over the panel's own.
   Same play-state pause pattern as the dot below (pause, don't reset), and
   3.6s = 2× the dot's 1.8s — phase-locked, since both start and pause
   together with .visible. */
.walkthrough-scroll-hint::after {
  content: '';
  position: absolute;
  inset: -1px;
  border: 1px solid color-mix(in srgb, var(--color-accent) 60%, transparent);
  border-radius: inherit;
  box-shadow: 0 0 28px color-mix(in srgb, var(--color-accent) 16%, transparent);
  opacity: 0.35;
  pointer-events: none;
  animation: wt-scroll-hint-breathe 3.6s ease-in-out infinite;
  animation-play-state: paused;
}

.walkthrough-scroll-hint.visible::after {
  animation-play-state: running;
}

@keyframes wt-scroll-hint-breathe {
  0%, 100% { opacity: 0.35; }
  50%      { opacity: 1; }
}

.wt-scroll-hint-dot {
  animation: wt-scroll-hint-drop 1.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
  /* The hint hides via opacity, which leaves an infinite animation ticking
     for the whole pinned session — pause it (not `animation: none`, which
     would restart from 0% on every re-show). */
  animation-play-state: paused;
}

.walkthrough-scroll-hint.visible .wt-scroll-hint-dot {
  animation-play-state: running;
}

@keyframes wt-scroll-hint-drop {
  0%   { transform: translateY(0);    opacity: 0; }
  20%  { opacity: 1; }
  65%  { transform: translateY(14px); opacity: 1; }
  100% { transform: translateY(14px); opacity: 0; }
}

/* Belt-and-braces: pinned mode (the only mode that builds the hint) never
   runs under reduced motion, but the convention is that every infinite
   animation carries its own guard. */
@media (prefers-reduced-motion: reduce) {
  .wt-scroll-hint-dot { animation: none; }
  .walkthrough-scroll-hint::after { animation: none; }
}

.wt-scroll-hint-title {
  font-size: var(--type-label);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 2.5px;
  color: var(--color-text);
}

.wt-scroll-hint-sub {
  font-size: 0.95rem;
  line-height: 1.5;
  color: var(--color-text-muted);
}

/* Arrow-key row — rides beside the mouse glyph inside .wt-scroll-hint-cue,
   and is hidden unless the device plausibly has a keyboard. Pinned mode can
   run on big iPads (coarse pointer, see chooseMode in main.js), so the gate
   is input capability, not mode: the (hover:hover)+(pointer:fine) media pair
   for desktop, plus a :root.has-keys JS override that a real arrow press
   sets (initProjectBeatNav) for keyboards the media query can't see — iPad +
   Magic Keyboard. The gate must stay HERE and not move up to the cue
   wrapper, or a touch device would lose the glyph too. Dark-only site:
   literal values, no light variant. */
.wt-scroll-hint-keys {
  display: none;
  align-items: center;
  gap: 6px;
  font-size: 0.78rem;
  color: var(--color-text-muted);
}

@media (hover: hover) and (pointer: fine) {
  .wt-scroll-hint-keys { display: inline-flex; }
}

:root.has-keys .wt-scroll-hint-keys { display: inline-flex; }

.wt-hint-key {
  display: inline-grid;
  place-items: center;
  min-width: 20px;
  height: 20px;
  padding: 0 4px;
  font: inherit;
  font-size: 0.72rem;
  line-height: 1;
  color: var(--color-accent);
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid color-mix(in srgb, var(--color-accent) 35%, transparent);
  border-bottom-width: 2px; /* keycap read */
  border-radius: 5px;
}

/* Skip control (JS-generated by main.js, pinned mode only): a courtesy escape
   for skimmers/wheel users out of a runway that can be many viewport-heights
   long. Pinned bottom-centre of the frame, clear of the device (which sits
   left) and the annotation copy. */
.walkthrough-skip {
  position: absolute;
  bottom: var(--space-lg);
  left: 50%;
  transform: translateX(-50%);
  z-index: 5;
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  padding: 8px 16px;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--color-text-muted);
  background: var(--glass-fill-deep);
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  border: 1px solid var(--glass-edge-lite);
  border-radius: 999px;
  cursor: pointer;
  opacity: 0.72;
  transition: opacity 0.2s ease, color 0.2s ease, transform 0.15s ease;
}

.walkthrough-skip:hover {
  opacity: 1;
  color: var(--color-text);
  transform: translateX(-50%) translateY(1px);
}

.walkthrough-skip:focus-visible {
  outline: 2px solid var(--color-teal);
  outline-offset: 2px;
  opacity: 1;
}

/* Transient attention flag, set by the flick interceptor (flagSkip in
   main.js) for 2s after a flick is converted into a beat step — a reader who
   flicked may have been trying to leave, so point at the exit. Gold, matching
   the scroll hint's site-wide "gold = go" language, so it reads as the same
   cue system rather than an error state. The 1.8s glow finishes inside the
   2s class lifetime on purpose: the halo settles, the brightened resting
   state holds a beat, then everything releases together.
   HARD CONSTRAINT: this button is positioned by transform: translateX(-50%),
   so any keyframe touching `transform` must carry that translate in every
   frame (cf. the :hover rule above) — this one animates box-shadow only.
   No `filter: drop-shadow` (project GPU rule). */
.walkthrough-skip.is-flagged {
  opacity: 1;
  color: var(--color-text);
  border-color: color-mix(in srgb, var(--color-accent) 55%, transparent);
  animation: wt-skip-flag 1.8s ease-out 1;
}

@keyframes wt-skip-flag {
  0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-accent) 0%, transparent); }
  15%      { box-shadow: 0 0 22px 2px color-mix(in srgb, var(--color-accent) 45%, transparent); }
  60%      { box-shadow: 0 0 18px 1px color-mix(in srgb, var(--color-accent) 22%, transparent); }
}

/* Pinned mode never runs under reduced motion, but the convention is that
   every animation carries its own guard — the brightened resting state above
   still does the pointing. */
@media (prefers-reduced-motion: reduce) {
  .walkthrough-skip.is-flagged { animation: none; }
}

.walkthrough-skip svg {
  width: 12px;
  height: 12px;
  display: block;
}

.walkthrough-annotation-label {
  display: block;
  font-size: var(--type-label);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 2.5px;
  color: var(--color-teal);
  margin-bottom: var(--space-md);
}

.walkthrough-annotation-text {
  font-size: 1.1rem;
  line-height: 1.7;
  color: var(--color-text);
  margin: 0;
}

/* Inline links in annotation copy (e.g. cross-references to other projects) —
   the global `a` rule strips underlines, so restore an affordance here */
.walkthrough-annotation-text a {
  color: var(--color-teal);
  text-decoration: underline;
  /* 85% + explicit thickness: at 50% alpha the underline dropped below the
     WCAG G183 cue threshold against the glass/backdrop surfaces */
  text-decoration-color: color-mix(in srgb, var(--color-teal) 85%, transparent);
  text-decoration-thickness: 1.5px;
  text-underline-offset: 3px;
  transition: text-decoration-color 0.2s ease;
}

.walkthrough-annotation-text a:hover {
  text-decoration-color: var(--color-teal);
}

/* Small evidence image embedded in annotation copy (e.g. a lead-gen page
   cameo inside a Kickstarter act's prologue) — framed like a pinned artifact,
   not a content block, so it reads as an aside rather than part of the page.
   Must load eagerly with width/height attrs: the scroll choreography measures
   annotation-slot heights at init, and a late-loading image would resize the
   slot mid-scroll (native lazy-load also never fires while the box is 0-high). */
.walkthrough-annotation-artifact {
  display: block;
  width: 100%;
  max-width: 280px;
  height: auto;
  margin: 14px 0;
  border-radius: 10px;
  border: 1px solid rgba(255, 255, 255, 0.14);
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}

/* Progress indicator (dot rail) */
.walkthrough-progress {
  position: absolute;
  right: 0;
  top: 50%;
  transform: translateY(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  z-index: 5;
}

/* The dots + track/fill live in their own relative box so the absolutely
   positioned track spans only the dots, not the prev/next chevrons above and
   below it. */
.walkthrough-progress-rail {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0; /* each dot button carries 7px padding = the visual gap + hit area */
}

.walkthrough-progress-track,
.walkthrough-progress-fill {
  position: absolute;
  width: 2px;
  left: calc(50% - 1px);
  /* Inset to the first/last dot centers (dot buttons are 24px tall, dot at
     center) so the rail runs dot-to-dot rather than past the end dots. */
  top: 12px;
  bottom: 12px;
  border-radius: 1px;
}

.walkthrough-progress-track {
  background: var(--color-border, rgba(255, 255, 255, 0.12));
}

.walkthrough-progress-fill {
  background: var(--color-teal);
  transform-origin: top;
  transform: scaleY(0);
  transition: transform 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}

/* Dot is now a real <button> with a 24px hit target (meets WCAG 2.5.5's 24x24
   minimum); the 8px visual dot is drawn with ::before so the larger tap area
   doesn't change the rail's rhythm. */
.walkthrough-progress-dot {
  appearance: none;
  -webkit-appearance: none;
  border: 0;
  margin: 0;
  padding: 8px;
  background: transparent;
  cursor: pointer;
  display: block;
  line-height: 0;
  position: relative;
  z-index: 1;
}

.walkthrough-progress-dot::before {
  content: '';
  display: block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: var(--color-border, rgba(255, 255, 255, 0.15));
  transition: background-color 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease;
}

.walkthrough-progress-dot:hover::before {
  background: rgba(255, 255, 255, 0.55);
}

/* Completed / passed steps (all dots up to and including the active one) go
   teal. The scale is split off to :not([aria-current]) below so the two
   transform declarations never target the same dot — the current step's
   larger scale/ring can't be clobbered by source order. */
.walkthrough-progress-dot.active::before {
  background: var(--color-teal);
}

.walkthrough-progress-dot.active:not([aria-current="step"])::before {
  transform: scale(1.4);
}

/* The step you're actually on gets a bigger dot + ring so it reads as "here",
   distinct from the merely-passed dots behind it. */
.walkthrough-progress-dot[aria-current="step"]::before {
  transform: scale(1.5);
  box-shadow: 0 0 0 3px rgba(77, 182, 172, 0.25);
}

.walkthrough-progress-dot:focus-visible {
  outline: 2px solid var(--color-teal);
  outline-offset: 1px;
  border-radius: 50%;
}

/* Prev / next chevrons that cap the rail. Up = previous step, down = next,
   matching the vertical scroll direction and the arrow-key mapping. 5px padding
   around the 14px icon gives a 24px hit target (WCAG 2.5.5). */
.walkthrough-progress-nav {
  appearance: none;
  -webkit-appearance: none;
  border: 0;
  margin: 0;
  padding: 5px;
  background: transparent;
  cursor: pointer;
  /* Resting opacity raised from 0.4 so the prev/next step controls read as
     interactive (the tour is fully steppable without wheel-scrolling). */
  color: rgba(255, 255, 255, 0.55);
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 0;
  transition: color 0.2s ease, transform 0.15s ease;
}

.walkthrough-progress-nav:hover {
  color: rgba(255, 255, 255, 0.9);
}

.walkthrough-progress-nav:active {
  transform: scale(0.88);
}

.walkthrough-progress-nav:focus-visible {
  outline: 2px solid var(--color-teal);
  outline-offset: 1px;
  border-radius: 4px;
}

.walkthrough-progress-nav svg {
  width: 14px;
  height: 14px;
  display: block;
}

/* SVG connecting lines */
.walkthrough-lines {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  z-index: 3;
  color: var(--color-teal);
}

.walkthrough-line {
  opacity: 0;
  stroke: rgba(255, 255, 255, 0.7);
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  /* Removal = release (fade while still drawn), not retract. The line holds its
     drawn geometry (offset 0) through a quick opacity fade, then snaps the
     offset back to 1 with a 0-duration step delayed until *after* the fade —
     so it never un-draws backward, and the snap to invisible is hidden because
     opacity is already 0 by then. (0.32s matches the softened exit-at-dwell-end
     release; the freeze window for a departing line that stops tracking its
     target stays short.) */
  transition: opacity 0.32s ease,
              stroke 0.3s ease,
              stroke-width 0.3s ease,
              stroke-dashoffset 0s linear 0.34s;
}

/* Draw the connector from the annotation toward the box. It starts a beat after
   the origin dot and lands ~0.42s in — which is exactly when the box bloom and
   the arrival dot fire, so the motion relays from the copy to the artifact. */
.walkthrough-line.revealed {
  opacity: 0.9;
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 0.34s cubic-bezier(0.22, 1, 0.36, 1) 0.08s,
              opacity 0.28s ease,
              stroke 0.3s ease,
              stroke-width 0.3s ease;
}

.walkthrough-line.revealed.current {
  stroke: var(--color-teal);
  stroke-width: 3;
  /* The active connector crosses the spotlight cutout — the one un-dimmed,
     full-brightness region — where light teal on a white campaign page washes
     out. A background-independent dark halo keeps it legible without changing
     the accent color or the draw-on motion. */
  filter: drop-shadow(0 0 1.5px rgba(0, 0, 0, 0.55));
}

.walkthrough-dot {
  opacity: 0;
  fill: rgba(255, 255, 255, 0.7);
  /* Removal = quick fade (matches the line/box release timing). */
  transition: opacity 0.32s ease, fill 0.3s ease;
}

.walkthrough-dot.revealed {
  opacity: 0.95;
}

.walkthrough-dot.revealed.current {
  fill: var(--color-teal);
}

/* Relay ordering. The origin dot at the annotation leads — it pops just before
   the line draws away from it — while the arrival dot on the box lands with the
   line's end and the box bloom (0.42s ≈ the line's start delay + travel time). */
.walkthrough-dot--annotation.revealed {
  transition: opacity 0.22s ease 0.02s, fill 0.3s ease;
}

.walkthrough-dot--phone.revealed {
  transition: opacity 0.25s ease 0.42s, fill 0.3s ease;
}

/* Box outline rendered as two SVG paths that bloom outward from the right-edge
   midpoint (where the line dot lands) and meet at the back. Replaces both the
   CSS .callout-active outline and the .wt-spot-rect-outline rect so all
   walkthrough animations share the same drawing motion language. */
.walkthrough-bloom {
  fill: none;
  stroke: rgba(255, 255, 255, 0.7);
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  pointer-events: none;
  opacity: 0;
  /* Removal = release (fade while still drawn), not retract. The box holds its
     drawn perimeter (offset 0) through a quick opacity fade, then snaps the
     offset back to 1 with a 0-duration step delayed until after the fade — so
     it never un-draws backward and the snap to invisible lands while opacity is
     already 0. 0.32s matches the softened exit-at-dwell-end release while still
     keeping a departing box (which stops tracking its target mid-fade) from
     visibly drifting. */
  transition: opacity 0.32s ease,
              stroke 0.3s ease,
              stroke-width 0.3s ease,
              stroke-dashoffset 0s linear 0.34s;
}

/* Superseded box within the same focus: kept as a faint, thin ghost outline so
   the reader retains a map of what's already been covered — mirroring how a
   superseded connector line falls back to neutral white. It stays drawn; only
   its weight and opacity drop. */
.walkthrough-bloom.revealed {
  /* Effective visibility ~0.3 (0.5 opacity x 0.6 stroke alpha): clearly
     subordinate to the teal active box, but strong enough to actually read as a
     breadcrumb rather than dissolve into the dark screen. */
  opacity: 0.5;
  stroke: rgba(255, 255, 255, 0.6);
  stroke-width: 1.5;
  stroke-dashoffset: 0;
  transition: stroke-dashoffset 0.5s cubic-bezier(0.22, 1, 0.36, 1) 0.42s,
              opacity 0.35s ease,
              stroke 0.35s ease,
              stroke-width 0.35s ease;
}

/* Active box: full teal emphasis, drawn on as the connector line lands (the
   0.42s delay matches the line's travel). `.current` follows `.revealed` in
   source order so it wins the equal-specificity tie for a box that is both. */
.walkthrough-bloom.current {
  opacity: 1;
  stroke: var(--color-teal);
  stroke-width: 3;
  stroke-dashoffset: 0;
  /* Dark halo so the active teal outline holds up over the bright, un-dimmed
     campaign content inside the spotlight cutout (see .walkthrough-line.current).
     Scoped to .current only — superseded/idle outlines sit over the dimmed area. */
  filter: drop-shadow(0 0 1.5px rgba(0, 0, 0, 0.55));
  transition: stroke-dashoffset 0.5s cubic-bezier(0.22, 1, 0.36, 1) 0.42s,
              opacity 0.3s ease,
              stroke 0.3s ease,
              stroke-width 0.3s ease;
}

/* A single-annotation focus (noLine) has no connector to wait for — draw its
   box immediately instead of holding for the line-land relay beat. */
.walkthrough-bloom--noline.revealed,
.walkthrough-bloom--noline.current {
  transition-delay: 0s;
}

/* Short viewports: the pinned annotation column is height-capped and cannot
   scroll (the runway drives the tour, not the page), so a tall card — long
   copy, or one carrying a .walkthrough-annotation-artifact image — could run
   past the fold with no way to reach it. Reclaim the generous heading spacing
   and cap the artifact so the card stays within the frame. Height-based, so it
   composes with the width breakpoints above. */
@media (max-height: 820px) {
  .walkthrough-heading {
    margin-top: var(--space-lg);
    margin-bottom: var(--space-lg);
  }

  .walkthrough-annotation-artifact {
    max-height: 30vh;
    width: auto;
  }
}

/* The pinned column still clips in the 821–860px band above that breakpoint —
   the tallest slot-overflowing card bottoms out at 857px on an 850px-tall
   laptop viewport. Extend the heading reclaim through that band, scoped to the
   pinned engine's width range so the mobile engines keep their spacing (and
   the 390×844 gate viewport stays unmeasured-identical). */
@media (max-height: 860px) and (min-width: 900px) {
  .walkthrough-heading {
    margin-top: var(--space-lg);
    margin-bottom: var(--space-lg);
  }
}

/* Large desktop: allow a taller natural size on ≥1600px viewports (still capped
   by available viewport height via the fluid --wt-phone-h formula). The screen
   box tracks automatically — it is defined once in % on the base rule, and the
   JS recomputes the spotlight scale factor per frame for any phone-screen size. */
@media (min-width: 1600px) {
  .campaign-walkthrough-layout {
    --wt-phone-max-h: 1205px;
  }
}

/* Narrow desktop (769–1024px wide, still pinned for phone-frame acts): cap the
   natural size so the device + annotation column fit the narrower container.
   803px caps the width at ~400px, matching the pre-fluid value at this range. */
@media (max-width: 1024px) and (min-width: 769px) {
  .campaign-walkthrough-layout {
    --wt-phone-max-h: 803px;
  }

  .walkthrough-annotation {
    max-width: 300px;
  }

  .campaign-walkthrough-layout {
    /* Narrower annotation column so the group still fits this range's container */
    grid-template-columns: auto 340px;
    gap: var(--space-xl);
  }

  /* Vertical centering: the 803px-capped phone sits top-aligned in a ~1090px
     track at this range, opening a dead band under the device. Center both
     columns and key the annotation column's height to the phone's (the vars
     above make that readable here) so copy and rail travel with the device.
     Pinned mode only — the static fallback (reduced motion, or browser-frame
     acts below 1025px) stacks the columns and must keep natural heights. */
  .campaign-walkthrough-section:not(.is-static) .campaign-walkthrough-layout {
    align-items: center;
  }

  .campaign-walkthrough-section:not(.is-static) .campaign-walkthrough-phone {
    align-self: center;
  }

  .campaign-walkthrough-section:not(.is-static) .campaign-walkthrough-annotations {
    align-self: center;
    height: var(--wt-phone-h);
    max-height: none;
  }
}

/* Static fallback (reduced motion or <=768px): added by initCampaignWalkthrough.
   Replaces the scroll-jacked, pinned-and-clipped walkthrough with a de-pinned
   layout — a natively scrollable phone window plus a fully-visible stacked
   annotation list — so none of the copy this page exists to show is ever
   clipped. One ruleset serves both triggers (JS computes which applies). */
.campaign-walkthrough-section.is-static .campaign-walkthrough-runway { min-height: 0; }
.campaign-walkthrough-section.is-static .campaign-walkthrough-sticky {
  position: static;
  height: auto;
  overflow: visible;
  display: block;
}
.campaign-walkthrough-section.is-static .campaign-walkthrough-sticky > .container { height: auto; }
.campaign-walkthrough-section.is-static .campaign-walkthrough-layout {
  height: auto;
  grid-template-columns: 1fr;
  justify-items: center;
  gap: var(--space-xl);
}
.campaign-walkthrough-section.is-static .campaign-walkthrough-annotations {
  max-height: none;
  padding-top: var(--space-lg);
}
.campaign-walkthrough-section.is-static .wt-phone-screen {
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}
.campaign-walkthrough-section.is-static .walkthrough-annotation-slot {
  display: block;
  min-height: 0;
}
.campaign-walkthrough-section.is-static .walkthrough-annotation {
  position: relative;
  top: auto;
  opacity: 1;
  transform: none;
  filter: none;
  pointer-events: auto;
  max-width: 520px;
  margin: 0 auto var(--space-2xl);
}
.campaign-walkthrough-section.is-static .walkthrough-callout-text { opacity: 1; transform: none; }
.campaign-walkthrough-section.is-static .walkthrough-lines,
.campaign-walkthrough-section.is-static .walkthrough-progress,
.campaign-walkthrough-section.is-static .walkthrough-spot-svg { display: none; }

/* Mobile: stacked layout */
@media (max-width: 768px) {
  .campaign-walkthrough-sticky {
    padding-top: 40px;
  }

  .campaign-walkthrough-layout {
    grid-template-columns: 1fr;
    gap: var(--space-md);
    justify-items: center;
  }

  .wt-phone {
    width: 280px;
    height: 562px;
  }

  .wt-phone-screen {
    top: 8px;
    left: 8px;
    width: 264px;
    height: 547px;
    border-radius: 17px;
  }

  .campaign-walkthrough-annotations {
    position: relative;
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding-top: var(--space-sm);
  }

  .walkthrough-heading {
    text-align: center;
    margin-bottom: var(--space-md);
  }

  .walkthrough-heading-title {
    font-size: 1.4rem;
    letter-spacing: 2px;
  }

  .walkthrough-annotation-slot {
    position: relative;
    width: 100%;
  }

  /* Positioning/visibility handled by the .is-static ruleset; mobile only tunes
     the type treatment. */
  .walkthrough-annotation {
    text-align: center;
    padding: 0 var(--space-lg);
    box-sizing: border-box;
  }

  .walkthrough-annotation-text {
    font-size: 0.95rem;
    line-height: 1.5;
  }

  .walkthrough-lines {
    display: none;
  }

  .walkthrough-progress {
    display: none;
  }

  .igg-content {
    font-size: 11px;
    padding: 8px 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .walkthrough-annotation {
    opacity: 1 !important;
    transform: none !important;
    position: relative;
    margin-bottom: var(--space-lg);
    pointer-events: auto;
  }

  .walkthrough-line,
  .walkthrough-dot {
    display: none;
  }

  .wt-phone-scroll {
    will-change: auto;
  }

  .igg-block {
    transition: none;
  }

  .walkthrough-spot-svg,
  .walkthrough-bloom {
    transition: none !important;
  }
}

/* --------------------------------------------------------------------------
   Walkthrough browser frame — macOS-style window variant for acts with
   frameType:"browser" (desktop-width page captures, e.g. Reform RX). The
   screen holds a 4:3 ratio at every breakpoint, and each breakpoint's CSS
   dims are a uniform scale of the 944x736 window geometry (40px title bar,
   24px side/bottom border, 896x672 screen at 24,40). These rules
   deliberately follow every .wt-phone breakpoint block: at equal
   specificity, source order lets them re-dimension the frame wherever a
   phone rule would otherwise apply — including skipping the >=1600px phone
   upscale (the browser frame already fills its side of the centered pair).
   -------------------------------------------------------------------------- */

/* Base serves 769-1399px (scroll mode from 1025px; JS goes static below). 0.75x.
   The 0.875x (826px) upscale waits for the 1400px wide shell: at 1200-1399
   .container--wide is still 1140px, and a wider frame + 64px gap would starve
   the annotation column until the grid overflowed and the card was unreadable.
   With the 0.75x frame the same band keeps a ~320px column. */
.wt-phone--browser {
  /* 708x552 at the base breakpoint — height comes from --wt-browser-h on
     .campaign-walkthrough-layout, width tracks it at the 944:736 window
     aspect (both resolve to exact integers at each breakpoint). */
  width: calc(var(--wt-browser-h, 552px) * 944 / 736);
  height: var(--wt-browser-h, 552px);
  /* Solid window body: one fill covers the title bar and the side/bottom
     borders (the chrome bar itself is transparent — SAME FILL by
     construction), so no gutter shows around the screen. #1a1a1a matches
     the phone bezel body; hairline edge is an inset box-shadow (not a
     border) so the 708x552 geometry stays exact. No filters/drop-shadows
     anywhere on the frame stack (GPU + Firefox constraints). */
  background: #1a1a1a;
  border-radius: 12px;
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08),
              0 24px 48px rgba(0, 0, 0, 0.35);
}

.wt-phone--browser .wt-phone-screen {
  top: 30px;
  left: 18px;
  width: 672px;
  height: 504px;
  /* Square top corners meet the title bar; bottom corners round against
     the window border. */
  border-radius: 0 0 10px 10px;
}

/* No dynamic island to clear — the browser chrome sits above the screen box,
   so content can start at the screen's top edge */
.wt-phone--browser .wt-phone-scroll {
  padding-top: 0;
}

/* Title bar — traffic lights left, address pill dead-centered. Transparent
   over the window-body fill (same fill as the frame, per the one-solid-body
   rule above). Plain fills only: NO backdrop-filter (buys nothing over the
   site background; zero filter cost, zero Firefox risk). */
.wt-browser-chrome {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 30px;
  display: flex;
  align-items: center;
  padding: 0 12px;
  z-index: 2;
  pointer-events: none;
}

.wt-browser-dot {
  flex: 0 0 auto;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  margin-right: 6px;
}

.wt-browser-dot--close { background: #ff5f57; }
.wt-browser-dot--min   { background: #febc2e; }
.wt-browser-dot--max   { background: #28c840; }

.wt-browser-url {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  max-width: 60%;
  padding: 2px 14px;
  background: rgba(255, 255, 255, 0.06);
  border: 1px solid rgba(255, 255, 255, 0.08);
  border-radius: 6px;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  font-size: 11px;
  line-height: 1.5;
  color: rgba(255, 255, 255, 0.55);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Desktop-capture acts: the capture IS the window content, so the base
   .igg-content 12px padding reads as a white letterbox around every slice —
   glaring on dark pages (Onewheel GT) and the reason the default fit looked
   "too zoomed out" with dead space on every side. Synced already zeroes its
   horizontal padding at the wide-base scale (§ 22); this is the pinned/static
   counterpart. Vertical goes too: the browser chrome above and window border
   below already provide the frame's edges. */
.campaign-walkthrough-section--browser .igg-content {
  padding: 0;
}

/* The wider window frame + side annotation column needs the full container.
   1025–1399 keeps the stretched two-column layout (the 708px frame already
   fills its side, leaving a ~320px remainder column); the column floor keeps
   the card readable if a future frame/container pairing shrinks the remainder
   again. */
.campaign-walkthrough-section--browser .campaign-walkthrough-layout {
  grid-template-columns: auto minmax(300px, 1fr);
  justify-content: stretch;
  max-width: var(--container-wide);
}

/* ≥1400: the stretched-remainder contract left the annotation card ~304px,
   glued to the progress rail with no air on the right. Invert it to match
   phone acts: fixed 420px column (~380px card after the rail lane below),
   frame + column centered as a group. The window pays for it by dropping to
   0.875x in its own ≥1400 block below — the 1025–1399 band already reads
   fine at 0.75x, so 0.875x stays comfortably above the accepted floor. */
@media (min-width: 1400px) {
  .campaign-walkthrough-section--browser .campaign-walkthrough-layout {
    grid-template-columns: auto 420px;
    justify-content: center;
  }
}

/* The progress rail is absolutely positioned in the column's last 24px
   (right: 0, z-index 5). Phone acts clear it by construction (440px column
   vs 380px card), but a browser act's column is the container REMAINDER —
   344px on a 1400px container — so the card fills the whole column and its
   text lines render under the rail's dots. Reserve the rail's lane in the
   card's own width. Pinned mode only (min-width gate): the mobile docks
   own their card geometry and never show this rail alongside the text. */
@media (min-width: 1025px) {
  .campaign-walkthrough-section--browser .walkthrough-annotation {
    max-width: calc(100% - 40px);
  }
}

/* Mobile modes build their own .wt-mchrome bars — hide the static chrome so
   the two never stack (mirror of the .wt-phone-bezel hide in the mobile
   block). The .is-interleaved arm is belt-and-braces — that mode already hides
   the whole runway. The .is-synced arm is LOAD-BEARING and must not be
   collected as dead code: browser-capture acts now run synced on phones, where
   the synced screen is inset 0 on the same parent as this absolutely-positioned
   z-index:2 chrome, so without the hide the static traffic lights paint over
   the top of the live capture. */
.campaign-walkthrough-section.is-synced .wt-browser-chrome,
.campaign-walkthrough-section.is-interleaved .wt-browser-chrome {
  display: none;
}

@media (min-width: 1400px) {
  /* 0.875x of the 944x736 geometry (all insets stay integer: 35px title bar,
     21px side border, 784x588 screen at 21,35) — the full 944px window plus
     the fixed annotation column above no longer fit the 1400px shell, and the
     smaller window is what buys the card its width. */
  .campaign-walkthrough-layout {
    --wt-browser-h: 644px; /* 826x644 — the base rule's width calc tracks it */
  }

  .wt-phone--browser {
    border-radius: 14px;
  }

  .wt-phone--browser .wt-phone-screen {
    top: 35px;
    left: 21px;
    width: 784px;
    height: 588px;
    border-radius: 0 0 12px 12px;
  }

  .wt-browser-chrome {
    height: 35px;
    padding: 0 14px;
  }

  .wt-browser-dot {
    width: 12px;
    height: 12px;
    margin-right: 8px;
  }

  .wt-browser-url {
    font-size: 12px;
    padding: 3px 16px;
  }
}

/* Desktop pinned (>1024): the browser window is FIXED-height while its grid
   track stretches to the pinned viewport, so on tall monitors the frame sat
   top-aligned with a dead band beneath it (Jakob, 2026-08-07 round). Same
   treatment the 769-1024 block gives the capped phone: center both columns
   and key the annotation column's height to the frame, so copy and rail
   travel with the window. Browser-frame acts only — phone frames are
   fit-to-height and already fill their track. Synced/static keep their own
   stacked layouts. */
@media (min-width: 1025px) {
  .campaign-walkthrough-section:not(.is-static):not(.is-synced) .campaign-walkthrough-layout:has(.wt-phone--browser) {
    align-items: center;
  }

  .campaign-walkthrough-section:not(.is-static):not(.is-synced) .campaign-walkthrough-layout:has(.wt-phone--browser) .campaign-walkthrough-annotations {
    height: var(--wt-browser-h);
    max-height: none;
  }
}

@media (max-width: 768px) {
  /* Fluid below the 446px cap so narrow viewports (375px, 320px) never
     overflow horizontally; aspect-ratio keeps the frame a uniform scale of
     the 944x736 window geometry. Screen box AND chrome internals use
     geometry-relative percentages (24px side border, 40px title bar,
     896x672 screen of 944x736) so they track the fluid frame at any
     rendered size — fixed px would misalign against the aspect-ratio box. */
  .wt-phone--browser {
    width: min(446px, calc(100vw - 2 * var(--space-lg)));
    height: auto;
    aspect-ratio: 944 / 736;
    border-radius: 10px;
  }

  .wt-phone--browser .wt-phone-screen {
    top: 5.4348%;    /* 40/736 */
    left: 2.5424%;   /* 24/944 */
    width: 94.9153%; /* 896/944 */
    height: 91.3043%; /* 672/736 */
    border-radius: 0 0 8px 8px;
  }

  .wt-browser-chrome {
    height: 5.4348%;    /* 40/736 — meets the screen's top edge exactly */
    padding: 0 2.5424%; /* 24/944 — dots align with the screen's left edge */
  }

  .wt-browser-dot {
    width: 1.2712%; /* 12/944 */
    height: auto;
    aspect-ratio: 1;
    margin-right: 0.8475%; /* 8/944 */
  }

  .wt-browser-url {
    font-size: 9px;
    padding: 1px 8px;
  }
}

/* === § 15. Project Media & Social [.screenshot- .desktop- .influencer- .video- .social- .collaborator-] === */
/* The project page's supporting evidence: desktop screenshot frame,
   influencer review cards, video embeds, social embeds (legacy + extracted
   cards, glass seat, ?social= scroll modes), and the collaborators grid. */

.screenshot-heading {
  margin-bottom: var(--space-lg);
  color: var(--color-text-light);
}

/* Desktop Screenshot */
.desktop-screenshot-section {
  padding: var(--space-3xl) 0;
  background: var(--color-bg);
}

.desktop-screenshot-section .screenshot-heading {
  text-align: center;
  margin-bottom: var(--space-xl);
}

.desktop-frame {
  background: white;
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-md);
  overflow: hidden;
  max-width: var(--container-narrative);
  margin: 0 auto;
}

.desktop-screen {
  overflow-y: auto;
  overflow-x: hidden;
}

.desktop-screen::-webkit-scrollbar {
  width: 6px;
}

.desktop-screen::-webkit-scrollbar-thumb {
  background: rgba(0,0,0,0.2);
  border-radius: 3px;
}

.desktop-screen img {
  width: 100%;
}

/* Influencer Reviews */
.influencer-section {
  padding: var(--space-3xl) 0;
}

.influencer-section h2 {
  text-align: center;
  margin-bottom: var(--space-2xl);
}

.influencer-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  gap: var(--space-xl);
}

.influencer-card {
  background: var(--color-bg);
  border-radius: var(--radius-lg);
  overflow: hidden;
  box-shadow: var(--shadow-sm);
}

.video-embed {
  position: relative;
  padding-bottom: 56.25%;
}

.video-embed iframe {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

.influencer-info {
  padding: var(--space-md);
}

.channel-name {
  font-size: 1rem;
  margin-bottom: var(--space-xs);
}

.subscriber-count {
  font-size: 0.85rem;
  color: var(--color-text-muted);
}

/* Video Section */
.video-section {
  padding: var(--space-3xl) 0;
}

.video-section h2 {
  text-align: center;
  margin-bottom: var(--space-xl);
}

.video-embed-large {
  position: relative;
  /* aspect-ratio, not the padding-bottom trick: percentage padding resolves
     against the PARENT's width, so with max-width binding below the parent
     the padding box came out taller than 16:9 (900x761 on desktop) and the
     embed letterboxed. */
  aspect-ratio: 16 / 9;
  max-width: var(--container-narrative);
  margin: 0 auto;
  border-radius: var(--radius-lg);
  overflow: hidden;
  box-shadow: var(--shadow-md);
}

.video-embed-large iframe {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
}

/* Social Embeds */
.social-embeds-section {
  padding: var(--space-3xl) 0;
  background: var(--color-bg);
  overflow-x: hidden;
  /* Jump-link target (from the "Social" deliverable badge): keep the heading
     clear of the fixed header when scrolled to. */
  scroll-margin-top: calc(var(--header-height) + var(--hud-height));
}

.social-embeds-section .container {
  max-width: var(--container-max);
}

.social-embeds-section h2 {
  margin-bottom: var(--space-2xl);
}

.social-embeds-section .section-subtitle {
  color: var(--color-text-secondary);
  margin-top: calc(var(--space-sm) - var(--space-2xl));
  margin-bottom: var(--space-2xl);
}

.social-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-xl);
}

/* Legacy embed cards (Instagram blockquotes, Facebook iframes) */
.social-embed--legacy {
  break-inside: avoid;
  margin-bottom: var(--space-xl);
  background: white;
  border-radius: var(--radius-lg);
  padding: var(--space-md);
  box-shadow: var(--shadow-sm);
}

/* Extracted content cards — horizontal layout */
.social-embed--extracted {
  display: flex;
  height: 300px;
  background: #fff;
  border: 1px solid #dbdbdb;
  border-radius: var(--radius-lg);
  overflow: hidden;
  box-shadow: var(--shadow-sm);
}

/* Image — left side */
.social-post-image {
  position: relative;
  flex: 0 0 55%;
  overflow: hidden;
}

.social-post-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* Locally hosted post video — overlays the poster image */
.social-post-video {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
  background: #000;
}

/* Body — right side */
.social-post-body {
  flex: 1;
  display: flex;
  flex-direction: column;
  padding: var(--space-lg);
  min-width: 0;
}

/* Account header */
.social-post-header {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  margin-bottom: var(--space-md);
  padding-bottom: var(--space-md);
  border-bottom: 1px solid #efefef;
}

.social-post-pfp {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  object-fit: cover;
}

.social-post-handle {
  font-family: var(--font-body);
  font-size: 0.9rem;
  font-weight: 700;
  color: #262626;
}

.social-post-verified {
  flex-shrink: 0;
}

.social-post-time {
  font-family: var(--font-body);
  font-size: 0.85rem;
  color: #8e8e8e;
  margin-left: auto;
}

/* Caption */
.social-post-caption {
  flex: 1;
  overflow-y: auto;
}

.social-post-caption p {
  font-family: var(--font-body);
  font-size: 0.95rem;
  line-height: 1.65;
  color: #262626;
  margin: 0;
}

/* View on Instagram link */
.social-post-link {
  display: inline-flex;
  align-items: center;
  gap: var(--space-sm);
  margin-top: var(--space-md);
  padding: var(--space-sm) var(--space-md);
  font-family: var(--font-body);
  font-size: 0.85rem;
  font-weight: 600;
  color: #8e8e8e;
  text-decoration: none;
  border: 1px solid #dbdbdb;
  border-radius: var(--radius-md);
  flex-shrink: 0;
  align-self: flex-start;
  transition: color var(--transition-fast), border-color var(--transition-fast);
}

.social-post-link:hover {
  color: var(--color-primary);
  border-color: var(--color-primary);
}

/* The global accent (amber) focus ring washes out on the white card interior;
   use the card's own ink color for the ring here. */
.social-post-link:focus-visible {
  outline-color: #262626;
  outline-offset: 3px;
}

.social-post-link-icon {
  flex-shrink: 0;
}

/* Portrait media variant — 9:16 reels. The media column takes its width
   from the card height via aspect-ratio; the body centers its content
   vertically so short captions don't leave a gulf above the link. */
.social-embed--portrait {
  height: 480px;
}

.social-embed--portrait .social-post-image {
  flex: 0 0 auto;
  aspect-ratio: 9 / 16;
}

.social-embed--portrait .social-post-body {
  /* Header at the top, caption in the middle, link pinned to the floor —
     center-packing left one tall white gulf under the link on desktop. */
  justify-content: space-between;
}

.social-embed--portrait .social-post-caption {
  flex: 0 1 auto;
  margin: auto 0;
  padding: var(--space-sm) 0;
}

/* ── Glass seat on project pages ─────────────────────────────────────────
   The white Instagram-idiom interior is intentional; what ties the cards
   into the dark page is the frame: a frosted rim (two box-shadow rings)
   and a deeper seat shadow, echoing the LITE glass edge treatment.
   (A translucent frosted fill was tried and reverted — over the darkened
   morph background it muted the media and dropped caption contrast.)
   The rim lives in --glass-seat-rim because social-modes.js writes inline
   box-shadows on these cards from load (which would clobber a CSS-only
   rim); the script reads this property and carries the rings through
   every shadow it paints. Defined on .project-page only, so the script
   applies no rim in any future light context. */
.project-page {
  --glass-seat-rim:
    0 0 0 6px rgba(255, 255, 255, 0.10),
    0 0 0 8px rgba(255, 255, 255, 0.22);
}

.project-page .social-embed--extracted,
.project-page .social-embed--legacy {
  border-color: rgba(255, 255, 255, 0.28);
  box-shadow:
    var(--glass-seat-rim),
    0 16px 40px rgba(0, 0, 0, 0.45);
}

/* Post 5 crop (bedroom autofocus shot): the story — the aurora projected on
   the ceiling — sits along the top edge of the frame; the default center
   crop shows mostly blank wall. Keep the top edge in view. */
.social-post-image img[src*="post5-autofocus"],
.social-thumb img[src*="post5-autofocus"] {
  object-position: center top;
}

/* ── Social Scroll Modes (?social=stack|thumbs|story|morph) ── */

/* Shared: sticky viewport */
.social-sticky {
  position: sticky;
  top: 0;
  height: 100vh;
  overflow: hidden;
}

[class*="social-mode--"] {
  padding: 0;
  background: var(--color-bg);
  overflow: visible;
  /* Jump-link landing (from the "Social" deliverable badge): in a scroll-driven
     mode the module is centered inside a 100vh sticky, so land the section top
     flush with the viewport — the sticky then pins at top:0 and the whole module
     (heading, card, thumbnail strip) centers in full height. The header-height
     margin the static grid uses would instead leave the sticky un-pinned and the
     module ~64px low, clipping the thumbnail strip off the bottom. */
  scroll-margin-top: 0;
}

/* Stack + Thumbs: centered container */
.social-mode--stack .social-sticky,
.social-mode--thumbs .social-sticky {
  display: flex;
  align-items: center;
}

.social-mode--stack .container {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: var(--container-narrative);
}

.social-mode--thumbs .container {
  position: relative;
  z-index: 1;
  width: 100%;
  max-width: 850px;
}

.social-mode--stack .social-grid,
.social-mode--thumbs .social-grid {
  display: block;
  position: relative;
}

.social-mode--stack .social-embed--extracted,
.social-mode--thumbs .social-embed--extracted {
  border-radius: var(--radius-lg);
  /* Keep the glass-seat rim (see "Glass seat on project pages") under the
     mode's deeper float shadow — this later rule wins the cascade. The
     transparent fallback keeps the shadow valid outside .project-page,
     where the rim property is undefined. */
  box-shadow:
    var(--glass-seat-rim, 0 0 0 0 transparent),
    0 8px 32px rgba(0, 0, 0, 0.35);
}

/* Portrait cards in the stack get a taller frame so the 9:16 media reads
   at a useful size (measureCards() honors this CSS height). */
.social-mode--stack .social-embed--portrait,
.social-mode--thumbs .social-embed--portrait {
  height: min(58vh, 560px);
}

/* Section title in scroll modes */
.social-mode--stack h2,
.social-mode--thumbs h2 {
  color: #fff;
  text-align: center;
  margin-bottom: var(--space-lg);
  text-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}

.social-mode--stack .section-subtitle,
.social-mode--thumbs .section-subtitle {
  color: rgba(255, 255, 255, 0.7);
  text-align: center;
  margin-top: calc(var(--space-sm) - var(--space-lg));
  margin-bottom: var(--space-lg);
  text-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}

/* Blurred background layers (shared by stack + thumbs) */
.social-morph-bgs {
  position: absolute;
  top: 0; right: 0; bottom: 0; left: 0;
  z-index: 0;
}

.social-morph-bg {
  position: absolute;
  top: 0; right: 0; bottom: 0; left: 0;
}

.social-morph-bg img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  filter: blur(25px) saturate(150%) brightness(0.45);
  transform: scale(1.1);
  transition: opacity 0.5s ease;
}

/* Progress dots */
.social-progress-dots {
  position: absolute;
  bottom: 28px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 8px;
  z-index: 10;
  /* Liquid-glass chrome (LITE recipe). These controls are static — only their
     children transition opacity/width — so a real backdrop-filter is safe here,
     unlike the transform-animated cards. Matches the header/filter-bar glass. */
  padding: 10px 16px;
  background: var(--glass-fill-deep);
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  border: 1px solid var(--glass-edge-lite);
  border-radius: 999px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}

.social-dot {
  width: 8px;
  height: 8px;
  border-radius: 4px;
  background: rgba(255, 255, 255, 0.3);
  transition: width 0.3s ease, background 0.3s ease;
}

.social-dot.active {
  width: 24px;
  background: #fff;
}

/* Thumbnail strip (thumbs mode) */
.social-thumb-strip {
  position: absolute;
  bottom: 28px;
  left: 50%;
  transform: translateX(-50%);
  display: flex;
  align-items: center;
  gap: 12px;
  z-index: 10;
  /* Liquid-glass chrome (LITE recipe) — static overlay, so backdrop-filter is
     safe. The frosted seat gathers the thumbnails onto one glass rail. */
  padding: 10px 14px;
  background: var(--glass-fill-deep);
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  border: 1px solid var(--glass-edge-lite);
  border-radius: 20px;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}

.social-thumb {
  width: 80px;
  height: 80px;
  border-radius: 8px;
  overflow: hidden;
  border: 2px solid transparent;
  padding: 0;
  cursor: pointer;
  opacity: 0.5;
  transition: opacity 0.3s ease, border-color 0.3s ease, transform 0.3s ease;
  background: none;
}

.social-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

.social-thumb.active {
  opacity: 1;
  border-color: #fff;
  transform: scale(1.1);
}

.social-thumb:hover:not(.active) {
  opacity: 0.75;
}

.social-thumb-progress {
  position: absolute;
  bottom: 0;
  left: 0;
  right: 0;
  height: 100%;
  transform: scaleY(0);
  transform-origin: bottom;
  background: rgba(255, 255, 255, 0.3);
  border-radius: 0 0 6px 6px;
  pointer-events: none;
}

/* Collaborators */
.collaborators-section {
  padding: var(--space-3xl) 0;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-xl);
  margin: var(--space-2xl) auto;
  max-width: var(--container-max);
}

.collaborators-section h2 {
  text-align: center;
  margin-bottom: var(--space-2xl);
  color: var(--color-text-light);
}

.collaborators-grid {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-xl);
  padding: 0 var(--space-lg);
}

.collaborator-card {
  display: flex;
  gap: var(--space-lg);
  max-width: 480px;
  flex: 1 1 360px;
}

.collaborator-photo {
  flex-shrink: 0;
}

.collaborator-photo img {
  width: 100px;
  height: 100px;
  border-radius: 50%;
  object-fit: cover;
}

.collaborator-name {
  font-size: 1.2rem;
  color: var(--color-primary);
  margin-bottom: var(--space-xs);
}

.collaborator-role {
  font-size: 0.9rem;
  color: var(--color-teal);
  margin-bottom: var(--space-sm);
}

.collaborator-bio {
  font-size: 0.95rem;
  color: var(--color-text-light);
  margin-bottom: var(--space-sm);
}

.collaborator-linkedin {
  display: inline-block;
  padding: 0.3rem 0.8rem;
  background: #0A66C2;
  color: white;
  font-size: 0.8rem;
  font-weight: 600;
  border-radius: var(--radius-sm);
}

/* === § 16. Project HUD [.project-hud .hud-] === */
/* Wayfinding local-nav for long project pages.
   Fixed bar below the site header (Apple localnav pattern): page identity
   left, current-section label center, next-project + back-to-top right, and
   a reading-progress hairline along the bottom edge. project-hud.js drives
   visibility, --hud-progress, and the section label. LITE glass by design —
   it's chrome, and it composites every frame while scrolling. */
.project-hud {
  position: fixed;
  top: var(--header-height, 64px);
  left: 0;
  right: 0;
  height: var(--hud-bar-height);
  z-index: 95; /* under .site-header (100), over pinned page content */
  background: var(--glass-fill-deep);
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  border-bottom: 1px solid var(--glass-edge-lite);
  /* Hidden over the hero. Fade + slight rise, NOT a slide from under the
     header — the header glass is translucent, so anything sliding beneath
     it would show through. visibility gates tab order while hidden. */
  opacity: 0;
  transform: translateY(-6px);
  visibility: hidden;
  transition: opacity 0.28s ease, transform 0.28s ease, visibility 0s linear 0.28s;
}

.project-hud.is-visible {
  opacity: 1;
  transform: none;
  visibility: visible;
  transition: opacity 0.28s ease, transform 0.28s ease, visibility 0s;
}

/* Reading-progress hairline: scaleX driven by --hud-progress (transform-only,
   cheap to composite — same recipe as the walkthrough beat bars). Neutral
   fill (see --hud-progress-fill) so it reads as a distinct signal rather than
   borrowing the teal highlight or amber attention hues. */
.project-hud::after {
  content: '';
  position: absolute;
  left: 0;
  bottom: 0;
  width: 100%;
  height: 2px;
  background: var(--hud-progress-fill);
  transform: scaleX(var(--hud-progress, 0));
  transform-origin: 0 50%;
}

.project-hud-inner {
  height: 100%;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: var(--space-lg);
}

.hud-identity {
  display: flex;
  align-items: baseline;
  gap: var(--space-sm);
  min-width: 0;
}

.hud-title {
  font-family: var(--font-heading);
  font-weight: 600;
  font-size: 0.95rem;
  color: var(--color-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.hud-client {
  font-size: 0.8rem;
  color: var(--color-text-muted);
  white-space: nowrap;
}

.hud-section {
  max-width: 34vw;
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--color-text-muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.hud-actions {
  justify-self: end;
  display: flex;
  align-items: center;
  gap: var(--space-md);
  min-width: 0;
}

.hud-next {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  min-width: 0;
  font-size: 0.85rem;
  color: var(--color-text-light);
  text-decoration: none;
  transition: color var(--transition-fast);
}

.hud-next:hover {
  color: var(--color-teal);
}

.hud-next-label {
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--color-text-muted);
}

.hud-next-title {
  max-width: 220px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.hud-top {
  flex: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  padding: 0;
  border: 1px solid var(--glass-edge-lite);
  border-radius: 50%;
  background: none;
  color: var(--color-text-light);
  cursor: pointer;
  transition: color var(--transition-fast), border-color var(--transition-fast);
}

.hud-top:hover {
  color: var(--color-teal);
  border-color: var(--color-teal);
}

.hud-top svg {
  width: 12px;
  height: 12px;
  display: block;
}

@media (max-width: 900px) {
  .hud-client,
  .hud-next-title {
    display: none;
  }
}

@media (max-width: 640px) {
  .project-hud-inner {
    grid-template-columns: 1fr auto;
  }
  .hud-section {
    display: none;
  }
}

/* === § 17. Pagination & Related [.project-pagination .pagination- .related- .industry-filter] === */
/* Project Pagination — prev/next band at the end of each project page.
   The primary "what's next" path (one obvious default), ahead of the
   related grid's browsing options. Dark-first like all project-page chrome.
   Also owns the related-projects grid and its industry filter chips. */
.project-pagination {
  padding: var(--space-3xl) 0 0;
}

.pagination-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: var(--space-lg);
}

.pagination-card {
  display: flex;
  align-items: center;
  gap: var(--space-lg);
  /* Grid item: without this, a long neighbor title's nowrap intrinsic width
     leaks through min-width:auto into the 1fr track and widens the whole
     page (mobile horizontal scroll on every neighbor of a long-titled
     project) — zeroing it lets .pagination-meta ellipsize as designed. */
  min-width: 0;
  padding: var(--space-lg);
  border: 1px solid var(--glass-edge-lite);
  border-radius: var(--radius-lg);
  background: var(--glass-fill-deep);
  text-decoration: none;
  transition: border-color var(--transition-base), transform var(--transition-base);
}

.pagination-card:hover {
  border-color: var(--color-teal);
  transform: translateY(-2px);
}

/* Next mirrors Prev: arrow on the outer edge, text toward the middle */
.pagination-card--next {
  flex-direction: row-reverse;
  text-align: right;
}

.pagination-direction {
  flex: none;
  font-size: 1.25rem;
  color: var(--color-text-muted);
  transition: color var(--transition-fast), transform var(--transition-base);
}

.pagination-card:hover .pagination-direction {
  color: var(--color-teal);
}

.pagination-card--prev:hover .pagination-direction {
  transform: translateX(-3px);
}

.pagination-card--next:hover .pagination-direction {
  transform: translateX(3px);
}

.pagination-meta {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

.pagination-label {
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--color-text-muted);
}

.pagination-title {
  font-family: var(--font-heading);
  font-weight: 600;
  color: var(--color-text);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

.pagination-client {
  font-size: 0.8rem;
  color: var(--color-text-muted);
}

.pagination-thumb {
  flex: none;
  width: 96px;
  aspect-ratio: 16 / 10;
  border-radius: var(--radius-md);
  overflow: hidden;
}

.pagination-thumb img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

@media (max-width: 640px) {
  .pagination-grid {
    grid-template-columns: 1fr;
  }
  .pagination-thumb {
    width: 72px;
  }
}

/* Related Projects */
.related-section {
  padding: var(--space-3xl) 0;
}

.related-section h2 {
  text-align: center;
  margin-bottom: var(--space-lg);
}

.industry-filters {
  display: flex;
  justify-content: center;
  flex-wrap: wrap;
  gap: var(--space-sm);
  margin-bottom: var(--space-2xl);
}

.industry-filter-btn {
  padding: 0.5rem 1.25rem;
  font-size: 0.9rem;
  font-weight: 500;
  text-transform: uppercase;
  border: 2px solid var(--color-primary);
  border-radius: var(--radius-md);
  color: var(--color-primary);
  background: white;
  transition: all var(--transition-fast);
}

.industry-filter-btn:hover,
.industry-filter-btn.active {
  background: var(--color-primary);
  color: white;
}

.related-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  gap: var(--space-xl);
  /* Cap on ultra-wide: this grid's shell (.container--wide) grows to 2000px
     at ≥2800px (Large Screen Enhancements), which would sprawl to 5–6 cards
     a row. */
  max-width: 1400px;
  margin-inline: auto;
}

.related-card {
  background: white;
  border: 2px solid var(--color-border);
  border-radius: var(--radius-lg);
  overflow: hidden;
  transition: all var(--transition-base);
}

.related-card:hover {
  transform: translateY(-4px);
  box-shadow: var(--shadow-md);
  border-color: var(--color-teal);
}

.related-image {
  aspect-ratio: 695 / 460;
  overflow: hidden;
}

.related-image img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform var(--transition-base);
}

.related-card:hover .related-image img {
  transform: scale(1.05);
}

.related-content {
  padding: var(--space-lg);
}

.related-content h3 {
  font-size: 1.25rem;
  margin-bottom: var(--space-sm);
}

.related-logo {
  max-height: 30px;
  max-width: 100px;
}

.related-client {
  font-size: 0.9rem;
  color: var(--color-text-muted);
}

/* ── Dark-glass reskin on project pages ──────────────────────────────────
   The flat white cards above are the light idiom; project pages are dark
   (data-theme="dark" + .project-page), where they read as a hard theme
   break after the dark story sections. Scoped to .project-page — currently
   the only pages rendering this partial — so any future light context
   keeps the light cards untouched. */
.project-page .related-section h2 {
  color: var(--color-text);
}

.project-page .industry-filter-btn {
  background: rgba(255, 255, 255, 0.06);
  border-color: rgba(255, 255, 255, 0.3);
  color: var(--color-text-light);
}

.project-page .industry-filter-btn:hover,
.project-page .industry-filter-btn.active {
  background: var(--color-primary);
  border-color: var(--color-primary);
  color: #fff;
}

.project-page .related-card {
  /* LITE glass tier (see :root) */
  background: var(--glass-fill-deep);
  border: 1px solid var(--glass-edge-lite);
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.08),
    0 8px 24px rgba(0, 0, 0, 0.35);
}

.project-page .related-card:hover {
  border-color: var(--color-teal);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.08),
    var(--shadow-md);
}

.project-page .related-content h3 {
  color: var(--color-text);
}

/* Client logos on these cards were authored for the light idiom above;
   dark-ink marks (Logitech, Adventure Together, Hallmark) vanish on the
   dark glass fill. White them out, matching the .logo-invert treatment. */
.project-page .related-logo {
  filter: brightness(0) invert(1);
}

/* === § 18. Education Acts [.education- #education] === */
/* Header nav's "About > Education" destination (the wrapper in about.html).
   The § 4 [id] default adds --space-md of cushion on top of the chrome, which
   landed the section 16px low — a visible seam under the sticky bar. Jakob
   wants the section's top edge flush against the header's bottom edge, so drop
   the cushion and clear the chrome exactly, same as the other named jump
   targets (.prelaunch-section, .social-embeds-section). The edge that meets the
   bar is .edu-acts-intro's border-top, so nothing collapses through it. */
#education {
  scroll-margin-top: calc(var(--header-height) + var(--hud-height));
}

/* Non-signature coursework compresses into a chip row behind a native
   <details> — the full transcript is opt-in depth, not a wall. */
.education-more {
  margin-top: var(--space-md);
}
.education-more summary {
  display: inline-block;
  cursor: pointer;
  font-size: 0.9rem;
  color: var(--color-text-muted);
  transition: color var(--transition-fast);
  list-style: none;
}
.education-more summary::-webkit-details-marker { display: none; }
/* display:inline-block drops the native disclosure triangle, so draw our own
   — without it the summary reads as dead text, not a control. */
.education-more summary::before {
  content: '\25B8';
  display: inline-block;
  margin-right: 0.45rem;
  transition: transform var(--transition-fast);
}
.education-more[open] summary::before { transform: rotate(90deg); }
.education-more summary:hover {
  color: var(--color-text-light);
}
.education-more[open] summary {
  margin-bottom: var(--space-sm);
}
.education-course-chips {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs) var(--space-sm);
}
.education-course-chip {
  padding: 0.3rem 0.75rem;
  border: 1px solid var(--glass-edge-lite);
  border-radius: 999px;
  background: var(--glass-fill-deep);
  font-size: 0.82rem;
  color: var(--color-text-light);
}

/* === § 19. Responsive [@media] === */
@media (max-width: 1024px) {
  .projects-grid {
    grid-template-columns: repeat(2, 1fr);
  }

  .hero-layout {
    grid-template-columns: 1fr;
  }

  .hero-image {
    order: -1;
    max-width: min(500px, 80vw);
    margin: 0 auto var(--space-xl);
  }
}

@media (max-width: 768px) {
  h1 { font-size: 2rem; }
  h2 { font-size: 1.5rem; }

  .header-container {
    gap: var(--space-sm);
  }

  .nav-link {
    font-size: 1.25rem;
  }

  /* Submenu rows become thumb targets rather than cursor targets. */
  .nav-sublink {
    display: flex;
    align-items: center;
    min-height: 2.75rem;
  }

  .projects-grid-section {
    padding-top: var(--space-xl);
  }

  .projects-grid {
    grid-template-columns: 1fr;
  }

  .filters-row {
    flex-direction: column;
  }

  .filter-toggle {
    margin-left: 0;
  }

  .social-grid {
    grid-template-columns: 1fr;
  }

  .social-embed--extracted {
    flex-direction: column;
    height: auto;
  }

  .social-post-image {
    flex: none;
    /* Fixed, not max-height: these images lazy-load right at the
       walkthrough→social boundary, and an auto-height container grows ~280px
       per card as each lands — the layout shift travels under the reader's
       thumb in both scroll directions. object-fit: cover absorbs the crop. */
    height: 280px;
  }

  .social-embed--portrait {
    height: auto;
  }

  .social-embed--portrait .social-post-image {
    height: auto; /* undo the landscape 280px pin — the ratio sizes this one */
    /* Full card width — the old centered 293px column letterboxed the reel
       between white card margins. cover absorbs the small vertical crop. */
    aspect-ratio: 9 / 16;
    max-height: 520px;
    width: 100%;
  }

  .collaborator-card {
    flex-direction: column;
    align-items: center;
    text-align: center;
  }

  .cta-section {
    flex-direction: column;
  }

  .btn {
    width: 100%;
  }

}

/* Mobile: collapse the logo to its JP monogram so logo + nav fit one row
   (the full wordmark + centered nav otherwise overflow narrow viewports).
   The collapse is a CROP, not a resize. Hiding .logo-text paints nothing past
   the monogram but leaves the 6.7:1 viewBox intact, so the SVG has to keep its
   full 214px intrinsic width and let .site-logo clip it. flex-shrink: 0 is what
   holds that: as a flex item the SVG would otherwise shrink to the 44px window,
   and preserveAspectRatio (xMidYMid meet) would then fit the entire viewBox into
   44px — drawing the monogram ~6px tall, centered in a 32px box. Kept inside the
   media query so wider viewports still scale the wordmark down gracefully rather
   than clipping its tail. 2.75rem is the touch-target minimum; the monogram
   paints 33.6px of it. */
@media (max-width: 640px) {
  .site-logo {
    width: 2.75rem;
    overflow: hidden;
  }
  .site-logo-img {
    flex-shrink: 0;
  }
  .logo-text {
    display: none;
  }
  .nav-link {
    font-size: 1.2rem;
  }
}

@media (max-width: 480px) {
  .container {
    padding: 0 var(--space-md);
  }

  .nav-link {
    font-size: 1.1rem;
  }
}

/* === § 20. Large Screen Enhancements [@media] === */

/* Wider shell for large screens — reaches ONLY the layouts that opted in with
   .container--wide (§ 4). --container-max deliberately does not move: the page
   shell, header, footer and reading columns keep their margins at every width. */
@media (min-width: 1400px) {
  :root {
    --container-wide: 1400px;
  }

  /* Archive exception: the card grid takes a narrower shell than the other
     wide layouts. A card is image (scales with width) + a fixed-height chin,
     so at the full 1400 a portrait card stands taller than a 14" laptop
     viewport and can never be seen whole. The viewport budget: a portrait
     card spans 2 grid rows + gap and must clear 100vh minus the header (64),
     its 8px offset, and the stuck filter sentence (~50) — ~748px on a 14"
     MacBook. First capped at 1240 (806px portrait), then 1180 on Jakob's
     callout; then the chin diet (§ 8: md chin padding + one-line badge rows)
     bought the height back, so the shell eased to 1200 — portrait ≈ 727px,
     inside budget with margin. The sub-native card render also keeps the
     695px hero sources crisp on retina. Scoped to the archive rather than
     moved into --container-wide: that variable is shared with the
     walkthrough frame, which wants the width. */
  .archive-page .container--wide {
    max-width: 1200px;
  }
}

/* Ultra-wide: wider shell + 4th column */
@media (min-width: 2800px) {
  :root {
    --container-wide: 2000px;
  }

  .projects-grid {
    grid-template-columns: repeat(4, 1fr);
  }
}

/* === § 21. About Page [.about- .contact-link .metrics-note .year-rail] === */
/* Intro / Bio  +  Metrics transparency note */
.about-intro {
  padding: var(--space-3xl) 0 var(--space-2xl);
}
/* Temporary until the next shoot: garden photo as the intro background.
   The scrim mixes from --color-bg so it tracks the active theme; it is heaviest
   over the left text column and fades toward Jakob on the photo's right side.
   Remove alongside the .about-intro--photo class in about.html to revert. */
.about-intro--photo {
  background:
    linear-gradient(90deg,
      color-mix(in srgb, var(--color-bg) 96%, transparent) 0%,
      color-mix(in srgb, var(--color-bg) 88%, transparent) 42%,
      color-mix(in srgb, var(--color-bg) 35%, transparent) 62%,
      transparent 80%),
    url('/assets/images/about-intro-bg.jpg') 80% 32% / cover no-repeat;
}
@media (max-width: 768px) {
  /* Stacked layout: text spans the full width, so use a near-opaque scrim */
  .about-intro--photo {
    background:
      linear-gradient(
        color-mix(in srgb, var(--color-bg) 90%, transparent),
        color-mix(in srgb, var(--color-bg) 90%, transparent)),
      url('/assets/images/about-intro-bg.jpg') 80% center / cover no-repeat;
  }
}
.about-name {
  font-size: clamp(2rem, 5vw, 3rem);
  margin-bottom: var(--space-xs);
}
.about-role {
  font-size: 1.15rem;
  font-weight: 500;
  color: var(--color-teal);
  margin-bottom: var(--space-xl);
}
.about-bio {
  max-width: 68ch;
}
.about-bio p {
  font-size: 1.1rem;
  line-height: 1.85;
  color: var(--color-text-light);
}
/* Intro grid: text column + portrait column; portrait spans all text rows.
   DOM order (label, name, role, portrait, bio) is the mobile stacking order. */
.about-intro-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) clamp(220px, 24vw, 300px);
  grid-template-rows: auto auto auto 1fr;
  grid-template-areas:
    "label    portrait"
    "name     portrait"
    "role     portrait"
    "bio      portrait";
  column-gap: var(--space-3xl);
}
.about-intro-grid > .section-label { grid-area: label; }
.about-intro-grid > .about-name { grid-area: name; }
.about-intro-grid > .about-role { grid-area: role; }
.about-intro-grid > .about-bio { grid-area: bio; }
.about-portrait {
  grid-area: portrait;
  align-self: start;
  margin: 0;
}
.about-portrait-img,
.about-portrait-placeholder {
  width: 100%;
  aspect-ratio: 4 / 5;
  border-radius: var(--radius-xl);
  border: 1px solid var(--glass-edge-lite);
  box-shadow: var(--shadow-md);
}
.about-portrait-img {
  display: block;
  object-fit: cover;
}
.about-portrait-placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--color-text-muted);
  background:
    radial-gradient(120% 90% at 18% 0%, rgba(77, 182, 172, 0.18), transparent 62%),
    radial-gradient(130% 100% at 92% 100%, rgba(121, 134, 203, 0.16), transparent 62%),
    var(--color-bg-white);
}
.about-portrait-placeholder svg {
  width: 42%;
  height: auto;
  opacity: 0.45;
}
@media (max-width: 768px) {
  .about-intro-grid {
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: none;
    grid-template-areas: "label" "name" "role" "portrait" "bio";
  }
  .about-portrait {
    max-width: 240px;
    margin-bottom: var(--space-xl);
  }
}
.about-section-header {
  border-top: 1px solid var(--color-border);
  padding-top: var(--space-2xl);
}
.about-section-title {
  font-size: 2rem;
  margin-bottom: 0;
}

/* Experience chapters live in § 26 (Chapter System) — they share their
   primitives with the education acts, so both consumers sit next to the
   vocabulary they consume rather than in their own page section. */

/* Get in touch */
.about-contact {
  padding: var(--space-lg) 0 var(--space-2xl);
}
.about-contact-lede {
  max-width: var(--container-prose);
  font-size: var(--type-body-lg);
  color: var(--color-text-light);
  margin-bottom: var(--space-lg);
}
/* Contact links — shared by About "Get in touch" and the Home closer */
.contact-links {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-md) var(--space-xl);
}
.contact-link {
  font-size: 1.05rem;
  font-weight: 600;
  color: var(--color-teal);
  text-decoration: none;
  border-bottom: 1px solid currentColor;
  padding-bottom: 2px;
  transition: color var(--transition-fast);
}
.contact-link:hover {
  color: var(--color-accent);
}

/* Year rail — the V made legible. A fixed timeline instrument built by
   about-year-rail.js from [data-rail-years] waypoints: the year under the
   reader's eye counts down through Experience, turns at Walla Walla, and
   climbs through Education. Dots are jump links, colored per chapter accent.
   It lives in the left gutter OUTSIDE the container, so it only exists where
   that gutter does — the 1360px cutoff leaves ~110px of clear margin beside
   the 1140px container. */
.year-rail {
  position: fixed;
  left: clamp(var(--space-md), 3vw, var(--space-2xl));
  top: 50%;
  transform: translateY(-50%) translateX(-8px);
  z-index: 30;
  display: none;
  flex-direction: column;
  align-items: center;
  gap: var(--space-md);
  opacity: 0;
  transition: opacity 0.4s ease, transform 0.4s ease;
  pointer-events: none;
}
@media (min-width: 1360px) {
  .year-rail { display: flex; }
}
.year-rail.is-active {
  opacity: 1;
  transform: translateY(-50%);
  pointer-events: auto;
}
/* Constant-size head. The chapter label wraps to one line or two depending on
   its length, and because the rail is centered with translateY(-50%), any
   height change used to re-center the whole rail — the dots visibly jumped on
   every chapter handoff and read as a bug. Reserving two label lines keeps the
   rail's geometry fixed; only the text inside changes. */
.year-rail-head {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
}
.year-rail-year {
  font-size: 1.35rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.02em;
  color: var(--rail-accent, var(--color-teal));
  transition: color 0.3s ease;
}
.year-rail-dir {
  font-size: 0.55rem;
  line-height: 1;
  color: var(--color-text-muted);
}
.year-rail-label {
  font-size: 0.66rem;
  line-height: 1.4;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  text-align: center;
  /* Fixed box, not max-width: see the constant-size head note above. */
  width: 6.5rem;
  min-height: 2.8em;
  color: var(--color-text-muted);
}
/* Chapter handoff: the label (and direction glyph) swap with a small rise-in
   instead of an instant repaint, so the change reads as the instrument doing
   its job. Re-triggered by about-year-rail.js toggling .is-swapping. */
@keyframes year-rail-swap {
  from { opacity: 0; transform: translateY(4px); }
  to   { opacity: 1; transform: none; }
}
.year-rail-label.is-swapping,
.year-rail-dir.is-swapping {
  animation: year-rail-swap 0.35s ease;
}
.year-rail-stops {
  position: relative;
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 12px;
}
/* Connecting line behind the dots — the timeline itself. */
.year-rail-stops::before {
  content: '';
  position: absolute;
  top: 4px;
  bottom: 4px;
  width: 1px;
  background: var(--color-border);
}
.year-rail-dot {
  position: relative;
  display: block;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--rail-accent, var(--color-teal)) 40%, var(--color-bg));
  border: 1px solid color-mix(in srgb, var(--rail-accent, var(--color-teal)) 65%, transparent);
  transition: transform var(--transition-fast), background var(--transition-fast);
}
.year-rail-dot:hover,
.year-rail-dot:focus-visible {
  transform: scale(1.4);
  background: var(--rail-accent, var(--color-teal));
}
.year-rail-dot.is-current {
  transform: scale(1.25);
  background: var(--rail-accent, var(--color-teal));
}

/* InDemand / post-campaign transparency note under metrics */
.metrics-note {
  margin-top: var(--space-md);
  font-size: 0.85rem;
  font-style: italic;
  color: var(--color-text-muted);
  text-align: center;
}

/* === § 22. Mobile Walkthrough [.wt-m .is-synced .is-interleaved] === */
/* Phone-width presentations for the campaign walkthrough, driven by
   walkthrough-mobile.js through the mode seam in main.js. Everything here is
   scoped to .is-synced / .is-interleaved (set on the section by applyMode) or
   to wt-m* elements the module creates, so no desktop rule changes. */

/* -- Shared browser-style chrome bar (dots + address pill) ----------------- */
.wt-mchrome {
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  height: 32px;
  padding: 0 12px;
  background: #e6e7ec;
  border-radius: 10px 10px 0 0;
}

.wt-mchrome-dots {
  display: flex;
  gap: 5px;
  flex: 0 0 auto;
}

.wt-mchrome-dots i {
  display: block;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #c6c9d1;
}

.wt-mchrome-dots i:nth-child(1) { background: #ec8b85; }
.wt-mchrome-dots i:nth-child(2) { background: #e8c364; }
.wt-mchrome-dots i:nth-child(3) { background: #7ecc8f; }

.wt-mchrome-address {
  flex: 1 1 auto;
  min-width: 0;
  text-align: center;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
  font-size: 11px;
  line-height: 18px;
  color: #5b5f6b;
  background: #fff;
  border-radius: 6px;
  padding: 1px 12px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Scroll-position affordance: hairline along the chrome's bottom edge showing
   how far through the campaign page the panel has panned (synced mode). */
.wt-mchrome-progress {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 2px;
  background: rgba(0, 0, 0, 0.10);
}

.wt-mchrome-progress-fill {
  display: block;
  height: 100%;
  width: 100%;
  background: var(--color-teal);
  transform: scaleX(0);
  transform-origin: 0 50%;
}

/* -- Synced mode: sticky content panel + annotation dock ------------------- */
.campaign-walkthrough-section.is-synced .campaign-walkthrough-runway {
  min-height: 0; /* JS sets the exact px height */
}

.campaign-walkthrough-section.is-synced .campaign-walkthrough-sticky {
  position: sticky;
  top: 0;
  /* Large-viewport unit, NOT dvh: dvh re-resolves through the iOS URL-bar
     collapse/expand cycle, which resized the whole pinned frame mid-scroll —
     panel and dock visibly jumped at the section edges, worst when reversing
     direction near the social boundary. lvh keeps the frame geometry fixed;
     while the bar is expanded the dock bottom briefly sits under it, and the
     first downward scroll collapses the bar and squares everything up.
     walkthrough-mobile.js budgets are keyed to the same stable basis. */
  height: 100vh;
  height: 100lvh;
  padding: calc(var(--header-height) + var(--hud-height) + 4px) 0 0;
  overflow: hidden;
  display: flex;
  align-items: stretch;
}

.campaign-walkthrough-section.is-synced .campaign-walkthrough-sticky > .container {
  height: 100%;
  display: flex;
  flex-direction: column;
  padding: 0 10px calc(10px + env(safe-area-inset-bottom, 0px));
}

.campaign-walkthrough-section.is-synced .campaign-walkthrough-layout {
  display: flex;
  flex-direction: column;
  height: 100%;
  width: 100%;
  max-width: 640px;
  margin: 0 auto;
  padding: 0;
  gap: 0;
}

/* Tablet-width touch (769-1024) reaches synced only for desktop-capture acts —
   chooseMode() sends them here instead of the stacked static fallback. The
   640px cap above is a phone measure: it wastes a quarter of an 834px portrait
   and strands the panel mid-screen on a 1194px landscape. Give the layout the
   room it has — the capture is a 1000px-wide page, so panel width IS readable
   type (15 x panelW / region.w / 970), not decoration. This can only bind
   above ~660px, so no phone viewport ever sees it, and phone-capture acts
   can't reach synced up here at all (mobileEligible caps at 768). */
@media (min-width: 769px) {
  .campaign-walkthrough-section[data-wt-desktop-capture].is-synced .campaign-walkthrough-layout {
    max-width: 900px;
  }
}

/* Heading above the panel (JS moves .walkthrough-heading there). Enters at
   full section-heading scale so the act announces what the panel is, then
   condenses to a slim row once the first annotation lands —
   walkthrough-mobile.js toggles .is-condensed with the entry state, and the
   transitions below hand the freed height to the dock. */
.campaign-walkthrough-section.is-synced .walkthrough-heading {
  margin: 0 2px 10px;
  min-width: 0;
}

.campaign-walkthrough-section.is-synced .walkthrough-heading-title {
  font-size: 1.4rem;
  letter-spacing: 2px;
  line-height: 1.25;
  margin: 0;
  transition: font-size 0.35s ease;
}

.campaign-walkthrough-section.is-synced .walkthrough-heading-subtitle {
  font-size: 1.1rem;
  font-style: italic;
  margin: 2px 0 0;
  overflow: hidden;
  max-height: 1.6em;
  transition: max-height 0.35s ease, opacity 0.25s ease, margin 0.35s ease;
}

.campaign-walkthrough-section.is-synced .walkthrough-heading.is-condensed .walkthrough-heading-title {
  font-size: 0.85rem;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Condensed drops the subtitle line entirely — the annotation labels and the
   address bar carry the context once the walkthrough is running. */
.campaign-walkthrough-section.is-synced .walkthrough-heading.is-condensed .walkthrough-heading-subtitle {
  max-height: 0;
  opacity: 0;
  margin: 0;
}

.campaign-walkthrough-section.is-synced .walkthrough-live-link { display: none; }

/* Content panel: no bezel — the chrome bar + full-width screen ARE the frame */
.campaign-walkthrough-section.is-synced .campaign-walkthrough-phone {
  flex: 0 0 auto;
  width: 100%;
}

.campaign-walkthrough-section.is-synced .wt-phone {
  position: relative;
  width: 100%;
  /* The panel takes everything the chrome and a ~210px dock don't need:
     100lvh minus header pad, condensed heading, dock margin/padding and the
     dock's budget (330px total). The HUD band joins the header pad above
     when active (see the sticky), so it comes out of the panel's share too —
     a bare 330px would squeeze the dock by the HUD's 44px instead. The clamp
     keeps sane bounds on extreme viewports. A fixed-share height (the old
     52lvh) gave tall phones a luxurious dock and a cramped page — backwards:
     the page is the exhibit, the dock is a caption. lvh for URL-bar
     stability (see the sticky). */
  height: clamp(46vh, calc(100vh - 330px - var(--hud-height)), 66vh);
  height: clamp(46lvh, calc(100lvh - 330px - var(--hud-height)), 66lvh);
}

/* .wt-phone--browser's window-body styling (background + hairline + drop
   shadow at style.css:5767, aspect-ratio in its <=768px block) never rendered
   at phone width until synced became reachable for browser-capture acts —
   interleaved hides the whole runway. The synced panel IS the frame: the
   chrome bar sits above a screen inset to 0 on all four sides, so the window
   body has no visible surface left and must not paint. The 0 24px 48px drop
   shadow would smudge into the 10px container gutter and over the act
   backdrop, and aspect-ratio 944/736 is a latent trap if the height clamp
   above is ever relaxed (it is inert only while width AND height are set).
   The attribute selector is the same gate the mobile engine reads, so this
   can never reach a phone act. */
.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .wt-phone {
  background: none;
  box-shadow: none;
  aspect-ratio: auto;
}

.campaign-walkthrough-section.is-synced .wt-phone-bezel { display: none; }
/* .walkthrough-spot-svg stays live: walkthrough-mobile.js drives the same
   dim-mask as the pinned mode (highlight window per annotation, no arrows) */
.campaign-walkthrough-section.is-synced .walkthrough-lines { display: none; }
.campaign-walkthrough-section.is-synced .walkthrough-progress { display: none; }
/* Interleaved already hides the whole runway, but scope the rail explicitly too
   so its interactive buttons can never be exposed if that ancestor rule changes
   — matching the pattern used for static/synced. */
.campaign-walkthrough-section.is-interleaved .walkthrough-progress { display: none; }

.campaign-walkthrough-section.is-synced .wt-phone-screen {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  border-radius: 0 0 10px 10px;
}

/* The wide-base layer below is transformed, and a transformed border box
   contributes horizontal SCROLLABLE overflow even under the base
   `overflow: hidden` — so scrollLeft becomes drift-able (Find-on-Page,
   scrollIntoView, a focus escaping the tabindex parking), and every frame's
   pan math assumes it is 0. `clip` removes the scroll container entirely.
   Its own rule, deliberately NOT merged into the base .wt-phone-screen:
   `overflow-x: clip` paired with .is-static's `overflow-y: auto` (see the
   .is-static rule above) computes back to hidden and would silently degrade
   static mode. Engines without `clip` (Safari 15 and older) fall back to the
   base `hidden`, which still clips visually — there the engine's own
   scrollLeft resets are the live defence. */
.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .wt-phone-screen {
  overflow: clip;
}

.campaign-walkthrough-section.is-synced .wt-phone-scroll {
  padding-top: 0; /* no dynamic island to clear */
  will-change: transform;
}

/* Mode-2 wide base (browser-capture acts, synced only). The capture stack is
   laid out 1.6x wider than the panel and the engine's rest transform scales it
   back down by 1/1.6, so a park can ramp scale UP toward 1.0 and show the
   raster at its own layout size instead of upscaling a panel-width raster.
   1.6 is the ceiling a 1000px source capture supports (the engine reads this
   very custom property — see walkthrough-mobile.js WIDE) and it matches the
   pinned engine's zoom clamp.

   An act whose captures carry more source pixels can raise its own ceiling with
   `campaignWalkthrough.wideBase`, which build.js emits as --wt-wide-act on the
   section. The ceiling IS the readable-type ceiling: the A+ column is ~970 CSS
   px wide with ~15px body copy, so the type a reader actually gets is
   15 * (panelW * WIDE) / 970 — 9.6px at 1.6 on a 410px panel, 13.2px at 2.2.
   Raise it only as far as the capture's own pixels go: rendered CSS px * dpr
   must stay at or under the source width, or the zoom buys size by trading away
   sharpness. be-cool's captures are 2000px wide and it runs 2.2.

   The width basis is load-bearing: .wt-phone-scroll is a static block child of
   .wt-phone-screen, so `100%` resolves against that containing block's WIDTH.
   Viewport width does not move during an iOS URL-bar cycle, so the bar can
   never reflow the capture stack (a vh-derived width would relayout a ~4300px
   stack and discard the promoted layer's raster on every bar animation).

   transform-origin is MANDATORY: at the CSS default 50% 50% the first scaled
   frame displaces a ~4300px stack by ~800px vertically, silently. */
.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .wt-phone-scroll {
  --wt-wide: var(--wt-wide-act, 1.6);
  width: calc(100% * var(--wt-wide));
  transform-origin: 0 0;
}

/* Campaign type reflows at the full panel width — restore the readable size
   (the generic <=768px rule shrinks it for the 264px bezel screen). */
.campaign-walkthrough-section.is-synced .igg-content {
  font-size: 13px;
  padding: 10px 0; /* horizontal gutters live on the text elements now */
}

/* In-layer seam scaling for the wide base. Fixed-px lengths inside the layer
   do NOT scale with --wt-wide, so at the 1/1.6 rest scale a 10px padding would
   render at 6.25px and 13px type at 8.1px. Scaling every fixed-px seam keeps
   the rest frame pixel-identical to the un-widened engine and keeps the
   engine's content height in P space exactly what it was — which is what makes
   the wide base a pure resolution change rather than a relayout.
   Base values scaled here: .is-synced .igg-content padding 10px / font-size
   13px (above), .igg-content img + video margin-bottom 10px, .igg-block
   margin-bottom 16px. --wt-wide is declared on .wt-phone-scroll and inherits. */
/* Horizontal padding is ZERO here, and only here. A capture act's blocks are
   full-bleed screenshots of a page that already has its own margins baked into
   the pixels, so the layer's own 10px gutter is a second, redundant one — and at
   the 1/--wt-wide rest scale it renders as a 10px white bar down each side of the
   panel, which is what made the un-zoomed frame look inset rather than full.
   (Measured: a 410px panel showed the capture 390px wide.) Vertical padding
   stays: it is real separation between stacked modules, and zeroing it would
   reflow contentH and every position derived from it.

   The blocks widening from 858 to 902 layout px does NOT change legibility at a
   park, which is worth knowing before anyone "fixes" this back. `u` is chosen to
   fit region.w × blockWidth into the panel, so the region's RENDERED width is
   panelW whatever the block measures — readable type is 15 × (panelW / region.w)
   / 970 and depends on region.w alone. What changes is only the rest frame, and
   the peak zoom factor needed to reach the same place (2.02 → 1.92). */
.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .igg-content {
  font-size: calc(13px * var(--wt-wide));
  padding: calc(10px * var(--wt-wide)) 0;
}

.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .igg-content img,
.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .igg-content video {
  margin-bottom: calc(10px * var(--wt-wide));
}

.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .igg-block {
  margin-bottom: calc(16px * var(--wt-wide));
}

/* --joined drops the seam so a split focus pair still reads as one run. The
   scaled .igg-block rule above outranks the base --joined rule, so restate the
   collapse at the gated specificity. No gated act uses --joined today; without
   this the next one to would lose the collapse with no error. */
.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .igg-block--joined {
  margin-bottom: 0;
}

.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .igg-block--joined img:last-child,
.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .igg-block--joined video:last-child {
  margin-bottom: 0;
}

/* Same gated restatement for --flush (see the base rule): the scaled img
   margin above outranks it, and the first gated act to use a flush block
   would silently regain internal seams without this. */
.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .igg-block--flush img {
  margin-bottom: 0;
}

/* And the same for the tail collapse. Synced pays it three times over — the
   scaled block margin, the scaled img margin AND the scaled content padding
   all outrank the base rule — so the recreation ended on ~42px of panel white
   here while desktop already ended flush. Zero all three at the gated
   specificity; the padding stays symmetric because only the BOTTOM is
   dropped. */
.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .igg-content {
  padding-bottom: 0;
}

.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .igg-content > .igg-block:last-child,
.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .igg-content > .igg-block:last-child > img:last-child,
.campaign-walkthrough-section[data-wt-desktop-capture].is-synced .igg-content > .igg-block:last-child > video:last-child {
  margin-bottom: 0;
}

/* Annotation dock: glass panel filling the rest of the viewport. Solid enough
   that the backdrop photo never sits behind the copy. Display-only — no inner
   touch scroller (overflow: hidden), so nothing competes with the
   scroll-jacked page swipe. Copy taller than the dock is revealed by page
   scroll instead: walkthrough-mobile.js budgets extra hold runway per
   overflowing beat and drives scrollTop from it (programmatic scrolling
   still works on hidden overflow). */
.campaign-walkthrough-section.is-synced .campaign-walkthrough-annotations {
  flex: 1 1 auto;
  min-height: 0;
  max-height: none;
  width: 100%;
  position: relative; /* anchors the .wt-mdock-cue overlay */
  margin-top: 10px;
  padding: 12px 16px;
  overflow: hidden;
  background: var(--glass-fill-deep);
  /* LITE glass tier (see :root) */
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  border: 1px solid var(--glass-edge-lite);
  border-radius: 14px;
  display: block;
}

/* Overflow cue: bottom fade stuck to the dock's visible edge while the active
   beat still has copy past the fold. Without it the clipped copy reads as
   complete. .has-overflow is set by walkthrough-mobile.js and cleared once
   page scroll has revealed the rest. Sticky + negative top margin = zero
   layout footprint inside the (programmatically scrolled) dock. */
.campaign-walkthrough-section.is-synced .campaign-walkthrough-annotations::after {
  content: '';
  position: sticky;
  display: block;
  bottom: -12px;         /* reach past the dock's bottom padding */
  height: 32px;
  margin: -32px -16px 0; /* and span past its side padding */
  background: linear-gradient(to bottom, rgba(13, 15, 27, 0), rgba(13, 15, 27, 0.95));
  border-radius: 0 0 13px 13px;
  opacity: 0;
  transition: opacity 0.2s ease;
  pointer-events: none;
}

.campaign-walkthrough-section.is-synced .campaign-walkthrough-annotations.has-overflow::after {
  opacity: 1;
}

.campaign-walkthrough-section.is-synced .walkthrough-annotation-slot {
  position: static;
  display: block;
  min-height: 0;
}

/* The dock shows one annotation at a time *visually*; inactive ones collapse
   to a screen-reader-only box rather than display:none, which would strip the
   act's copy from the accessibility tree and leave AT users nothing but the
   scroll-driven active swap. */
.campaign-walkthrough-section.is-synced .walkthrough-annotation {
  display: block;
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  opacity: 1;
  transform: none;
  filter: none;
  pointer-events: none;
  max-width: none;
  margin: 0;
  padding: 0;
  text-align: left;
}

.campaign-walkthrough-section.is-synced .walkthrough-annotation.active {
  position: static;
  width: auto;
  height: auto;
  overflow: visible;
  clip-path: none;
  white-space: normal;
  pointer-events: auto;
  animation: wt-mdock-in 0.3s ease both;
}

/* Gold, not the desktop teal. The dock label is the ONLY thing naming the beat
   on mobile — there is no annotation column, no stacked cards, and the copy
   underneath is body-weight grey — so it has to survive a fast scroll. Gold is
   already the site's action/focus colour (the scroll cue, the progress fill), it
   carries far more luminance against the dark dock than teal does
   (#FFD54F vs #4DB6AC), and at 0.78rem/700 that headroom is what the label is
   spending. It also stops reading as the same colour as the spotlight outline
   (.wt-spot-stroke, teal) — label and highlight now name themselves apart.

   Desktop keeps teal: there the label sits in a glass card beside the panel with
   whitespace and scale doing this job already.

   Specificity note: this is (0,3,0) and beats the --bg act override at 4432
   (0,2,0) regardless of source order, so every synced act gets gold — which is
   the point, the change is for all walkthroughs and not just be-cool. */
.campaign-walkthrough-section.is-synced .walkthrough-annotation-label {
  font-size: 0.78rem;
  letter-spacing: 2px;
  margin-bottom: 6px;
  color: var(--color-accent);
}

.campaign-walkthrough-section.is-synced .walkthrough-annotation-text {
  font-size: 0.95rem;
  line-height: 1.55;
  color: var(--color-text);
}

/* One sub-step at a time in the dock; the swap replaces desktop's stacked
   reveal cascade. Passed (.seen) and upcoming sub-steps use the same
   screen-reader-only collapse as inactive annotations, so the active focus
   stays fully AT-readable. */
.campaign-walkthrough-section.is-synced .walkthrough-callout-text {
  display: block;
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  opacity: 1;
  transform: none;
  margin: 0;
}

.campaign-walkthrough-section.is-synced .walkthrough-callout-text.current {
  position: static;
  width: auto;
  height: auto;
  overflow: visible;
  clip-path: none;
  white-space: normal;
  animation: wt-mdock-in 0.3s ease both;
}

@keyframes wt-mdock-in {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}

/* Sub-step dots (JS-built for multi-callout focuses) */
.wt-mdock-steps {
  display: flex;
  gap: 5px;
  margin: -2px 0 8px;
}

.wt-mdock-steps i {
  display: block;
  width: 14px;
  height: 3px;
  border-radius: 2px;
  background: rgba(255, 255, 255, 0.18);
  transition: background 0.3s ease;
}

.wt-mdock-steps i.on { background: var(--color-teal); }

/* Entry cue — the dock-filling "Scroll to explore" invitation shown while the
   entry runway pans in (mobile counterpart of .walkthrough-scroll-hint).
   Absolute over the dock: it occupies no layout, so the annotations swap in
   underneath it without a reflow when it fades. Synced mode never runs under
   prefers-reduced-motion (the seam routes those users to interleaved), so the
   chevron bob needs no reduced-motion guard. */
.wt-mdock-cue {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 0 24px;
  text-align: center;
  color: var(--color-text-muted);
  opacity: 0;
  transition: opacity 0.3s ease;
  pointer-events: none;
}

.wt-mdock-cue.visible { opacity: 1; }

/* Gold like the pinned hint's mouse glyph, matching glow — both cues speak
   the same "gold = go" invitation. 22×18 SVG, so the filter is GPU-safe. */
.wt-mdock-cue-chevrons {
  color: var(--color-accent);
  filter: drop-shadow(0 0 7px color-mix(in srgb, var(--color-accent) 55%, transparent));
  animation: wt-mdock-cue-bob 1.6s ease-in-out infinite;
}

.wt-mdock-cue-title {
  font-weight: 700;
  font-size: 0.95rem;
  letter-spacing: 1.5px;
  text-transform: uppercase;
  color: var(--color-text);
}

.wt-mdock-cue-sub {
  font-size: 0.85rem;
  line-height: 1.4;
}

@keyframes wt-mdock-cue-bob {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(5px); }
}

/* -- Interleaved mode: per-focus slices + inline annotation cards ---------- */
.campaign-walkthrough-section.is-interleaved .campaign-walkthrough-runway {
  display: none;
}

.wt-mi {
  width: 100%;
  max-width: 560px;
  margin: 0 auto;
  padding: var(--space-2xl) 14px;
  box-sizing: border-box;
}

.wt-mi-heading {
  text-align: center;
  margin: 0 0 var(--space-xl);
}

.wt-mi-heading .walkthrough-heading-title { font-size: 1.4rem; letter-spacing: 2px; }
.wt-mi-heading .walkthrough-heading-subtitle { font-size: 1.1rem; }

.wt-mi-slice {
  margin: 0 0 4px;
}

.wt-mi-page {
  border-radius: 0 0 10px 10px;
  overflow: hidden;
}

/* Slice type at full width — same readable floor as the synced panel */
.wt-mi .igg-content {
  font-size: 13px;
  padding: 10px 0;
}

.wt-mi .igg-block { opacity: 1; }

/* Crop-window: static region crop for desktop-capture slices. The slice
   builder (walkthrough-mobile.js applyCropWindow) writes the raw region
   fractions as custom props --rx/--ry/--rw/--rh plus an inline aspect-ratio
   of (rw·imgW)/(rh·imgH). The img spans 1/rw of the window so the region's
   width fills it exactly; translate percentages are relative to the img's
   own box, so -rx/-ry put the region's origin at the window's. These are
   static by design because INTERLEAVED HAS NO PER-FRAME ENGINE to drive them,
   not because scaling a promoted layer is unsafe on mobile: the synced path
   now ramps .wt-phone-scroll's scale every frame on the same capture stack,
   shipped on real-hardware evidence (iPhone Safari and Firefox, high-teens ms
   frames). Keep the crop windows — interleaved is still the reduced-motion,
   short-viewport and ?layout=interleaved presentation, and there they are the
   only legible region framing. */
.wt-mi-crop {
  overflow: hidden;
  margin-bottom: 10px; /* stands in for the wrapped img's clipped margin */
}

.wt-mi-crop img {
  width: calc(100% / var(--rw, 1));
  max-width: none;
  height: auto;
  display: block;
  margin-bottom: 0;
  transform: translate(calc(var(--rx, 0) * -100%), calc(var(--ry, 0) * -100%));
}

/* Annotation as a glass card directly under its slice */
.wt-mi-card {
  margin: 10px 0 0;
  padding: 14px 16px;
  background: var(--glass-fill-deep);
  /* LITE glass tier (see :root) */
  -webkit-backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  backdrop-filter: blur(var(--glass-blur-lite)) saturate(var(--glass-saturate-lite));
  border: 1px solid var(--glass-edge-lite);
  border-radius: 12px;
}

.wt-mi-card .walkthrough-annotation-label {
  font-size: 0.78rem;
  letter-spacing: 2px;
  margin-bottom: 6px;
}

.wt-mi-card .walkthrough-annotation-text {
  opacity: 1;
  transform: none;
  font-size: 0.95rem;
  line-height: 1.55;
  color: var(--color-text);
  margin: 0 0 10px;
  padding: 0;
  text-align: left;
}

.wt-mi-card .walkthrough-annotation-text:last-child { margin-bottom: 0; }

/* Collapsed transit seam — the connective tissue between excerpts of the same
   page ("shared frame edge" + label), standing in for the skipped blocks. */
.wt-mi-seam {
  position: relative;
  display: flex;
  justify-content: center;
  align-items: center;
  padding: 18px 0;
  margin: 0 0 4px;
}

.wt-mi-seam::before {
  content: '';
  position: absolute;
  top: 0;
  bottom: 0;
  left: 50%;
  border-left: 2px dashed rgba(255, 255, 255, 0.22);
}

.wt-mi-seam span {
  position: relative;
  font-size: 0.68rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 1.5px;
  color: var(--color-text-muted);
  background: var(--color-bg);
  border: 1px solid rgba(255, 255, 255, 0.12);
  border-radius: 999px;
  padding: 3px 12px;
}

/* === § 23. Home Page [.home- .js-reveal] === */
/* Scrollytelling pitch
   (greeting → what I do → brands → featured work → closer) */

/* Beat 1: greeting — self-sufficient first screen; entrance animates on load
   via pure CSS so it never depends on JS */
.home-hero {
  position: relative;
  min-height: calc(100svh - var(--header-height));
  display: flex;
  align-items: center;
  padding: var(--space-2xl) 0 var(--space-3xl);
  /* Ribbed-glass hero backdrop (Adobe Stock #1882632382). The scrim mixes from
     --color-bg so it tracks the theme; heaviest over the left text column, then
     fades right so the glass detail stays visible. Mirrors .about-intro--photo. */
  background:
    linear-gradient(90deg,
      color-mix(in srgb, var(--color-bg) 95%, transparent) 0%,
      color-mix(in srgb, var(--color-bg) 88%, transparent) 38%,
      color-mix(in srgb, var(--color-bg) 60%, transparent) 66%,
      color-mix(in srgb, var(--color-bg) 32%, transparent) 100%),
    url('/assets/images/home-hero-glass.jpg') center / cover no-repeat;
}
.home-hero-title {
  font-size: clamp(2.6rem, 6vw, 4rem);
  margin-bottom: var(--space-md);
  animation: home-rise 0.7s ease both;
}
.home-hero-role {
  max-width: var(--container-prose);
  font-size: var(--type-body-lg);
  color: var(--color-text-light);
  margin-bottom: var(--space-lg);
  animation: home-rise 0.7s ease 0.12s both;
}
.home-hero-cta {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-md);
  animation: home-rise 0.7s ease 0.24s both;
}
/* Pill CTA base — shared by the hero pair (Beat 1) and the archive button
   (Beat 4, .home-cta-btn); each variant supplies its own colors below.
   The hero keeps its own class on purpose: the Copy Board addresses the
   Beat 4 button by .home-cta-btn with no nth, so a second element carrying
   that class would steal the mapping. */
.home-hero-btn,
.home-cta-btn {
  display: inline-block;
  padding: 0.85rem 2rem;
  border-radius: 999px;
  border: 2px solid transparent;
  font-weight: 600;
  text-decoration: none;
  transition: background var(--transition-fast), color var(--transition-fast),
              border-color var(--transition-fast);
}
.home-hero-btn--primary {
  background: var(--color-teal);
  border-color: var(--color-teal);
  color: var(--color-bg);
}
.home-hero-btn--primary:hover {
  background: color-mix(in srgb, var(--color-teal) 84%, #FFFFFF);
  border-color: color-mix(in srgb, var(--color-teal) 84%, #FFFFFF);
}
.home-hero-btn--quiet {
  border-color: var(--color-border);
  color: var(--color-text-light);
}
.home-hero-btn--quiet:hover {
  border-color: var(--color-teal);
  color: var(--color-teal);
}
.home-scroll-cue {
  position: absolute;
  left: 50%;
  bottom: var(--space-xl);
  color: var(--color-text-muted);
  transition: color var(--transition-fast);
  animation: home-cue-bob 2.4s ease-in-out 1s infinite;
}
.home-scroll-cue:hover {
  color: var(--color-teal);
}
@keyframes home-rise {
  from { opacity: 0; transform: translateY(18px); }
  to { opacity: 1; transform: none; }
}
@keyframes home-cue-bob {
  0%, 100% { transform: translateX(-50%) translateY(0); }
  50% { transform: translateX(-50%) translateY(6px); }
}

/* Beats 2–6: shared vertical rhythm */
.home-what,
.home-brands,
.home-featured,
.home-closer {
  padding: var(--space-3xl) 0;
  border-top: 1px solid var(--color-border);
}
.home-what-lede,
.home-closer-lede {
  max-width: var(--container-prose);
  font-size: var(--type-body-lg);
  color: var(--color-text-light);
  margin-bottom: var(--space-lg);
}
.home-credentials {
  max-width: var(--container-prose);
  font-size: 0.95rem;
  color: var(--color-text-muted);
  margin-bottom: 0;
}

/* Brand wall — monochrome logo wall (moved here from About) */
.brand-wall {
  list-style: none;
  margin: var(--space-lg) 0 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  /* Column gap sized so all seven marks hold one line at desktop: an orphan
     on a second row reads as a break in the list, not as wrapping. */
  gap: var(--space-xl);
}
.brand-wall-item img {
  /* --mark-scale: authored optical balance, see brand-wall.html. 26px, not
     the old 30px: at 30 the seven scaled marks overrun the container and
     Save the Children orphans onto a row of its own. */
  height: calc(26px * var(--mark-scale, 1));
  width: auto;
  display: block;
  filter: brightness(0);
  opacity: 0.55;
}
[data-theme="dark"] .brand-wall-item img {
  filter: brightness(0) invert(1);
  opacity: 0.6;
}

/* Featured work */
.home-featured-title {
  font-size: 2rem;
  margin-bottom: var(--space-xl);
}
.home-featured-cta {
  margin-top: var(--space-2xl);
  text-align: center;
}
.home-cta-btn {
  border-color: var(--color-teal);
  color: var(--color-teal);
}
.home-cta-btn:hover {
  background: var(--color-teal);
  color: var(--color-bg);
}

/* Scroll reveal — hiding styles are scoped under html.js-reveal (added by
   scroll-reveal.js), so without JS everything stays visible. The script
   removes the data attributes once a reveal finishes, returning elements to
   normal styling so these rules can't fight hover transforms elsewhere. */
.js-reveal [data-reveal],
.js-reveal [data-reveal-stagger] > * {
  opacity: 0;
  transform: translateY(20px);
  transition: opacity 0.6s ease, transform 0.6s ease;
  transition-delay: calc(var(--reveal-i, 0) * 70ms);
}
.js-reveal [data-reveal].is-visible,
.js-reveal [data-reveal-stagger].is-visible > * {
  opacity: 1;
  transform: none;
}

@media (max-width: 768px) {
  .home-hero {
    padding-top: var(--space-xl);
    /* Stacked text spans full width — near-opaque scrim keeps it readable */
    background:
      linear-gradient(
        color-mix(in srgb, var(--color-bg) 90%, transparent),
        color-mix(in srgb, var(--color-bg) 82%, transparent)),
      url('/assets/images/home-hero-glass.jpg') center / cover no-repeat;
  }
  .home-what,
  .home-brands,
  .home-featured,
  .home-closer {
    padding: var(--space-2xl) 0;
  }
  .brand-wall {
    gap: var(--space-lg) var(--space-xl);
  }
  .brand-wall-item img {
    height: 24px;
    max-width: 110px;
  }
  /* Narrow phones: trim the pill padding so the hero pair stays on one row */
  .home-hero-btn {
    padding: 0.8rem 1.5rem;
  }
}

@media (prefers-reduced-motion: reduce) {
  .home-hero-title,
  .home-hero-role,
  .home-hero-cta,
  .home-scroll-cue {
    animation: none;
  }
  .js-reveal [data-reveal],
  .js-reveal [data-reveal-stagger] > * {
    opacity: 1;
    transform: none;
    transition: none;
  }
}

/* === § 24. Hero Metric Layouts: 3-up [body.hero-metrics-3up] === */
/* Hero metric layouts. A hero that ships heroMetricLabels gets the
   finished label-driven treatment for its metric count via a body
   class from build.js: .hero-metrics-3up — the Gringotts hero's
   featured 2+1 hierarchy on phones, uniform stepped-down row on
   desktop — and .hero-metrics-2up — a uniform open-gap pair
   (Screeneo U4, Logitech Reach). Unlabeled heroes keep the base grid.
   (Collapsed from the retired body.hero-opt-N experiment ladder.) */

/* Desktop 3up: label pairs render stacked (the injected lead+sub spans only
   have styling inside the mobile query otherwise and would concatenate into
   one inline run above 768px), reading order matches mobile — sign-up rate,
   in pre-orders, customers — and the figures step down to 0.88x so the single
   row reads as a set, not three competing headlines. */
@media (min-width: 769px) {
  body.hero-metrics-3up .hero-metrics-floor .metric-label-lead {
    display: block; white-space: nowrap;
  }
  body.hero-metrics-3up .hero-metrics-floor .metric-label-sub {
    display: block; margin-top: 2px; font-size: 0.72rem; font-weight: 500;
    letter-spacing: 0.4px; line-height: 1.2; white-space: nowrap;
    color: rgba(255, 255, 255, 0.62);
  }
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(3) { order: 1; }
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(1) { order: 2; }
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(2) { order: 3; }

  body.hero-metrics-3up .hero-metrics-floor .metric-value {
    font-size: calc(var(--type-headline) * 0.88);
  }
  /* The legacy "one scan anchor" rule (.metrics-grid .metric:first-child
     .metric-value, 1.22x — built for simpler heroes with a single featured
     figure) is MORE specific than the rule above and silently bumps whichever
     metric is first in the DOM (always the $ figure) back up. Re-assert 0.88x
     with matching specificity so the row actually reads as a set. */
  body.hero-metrics-3up .hero-metrics-floor .metric:first-child .metric-value {
    font-size: calc(var(--type-headline) * 0.88);
  }
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(1) .metric-label-sub,
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(2) .metric-label-sub {
    display: none;
  }
  /* Desktop: the conversion metric collapses to one self-sufficient line
     ("Email sign-up rate") — a lone sublabel on the single desktop row read
     as an orphan. Mobile keeps the lead+sub pair (rules in the <=768 block). */
  body.hero-metrics-3up .hero-metrics-floor .metric-label-lead--mobile,
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(3) .metric-label-sub {
    display: none;
  }
}

/* The funded shot sits lower than the base 3rem — 5rem, matching imgGap=80 in
   main.js — so the pinned heading → row → image ensemble distributes across
   the viewport instead of clumping mid-screen. Applies at every width. */
body.hero-metrics-3up .hero-metrics-floor .scrolly-hero-image {
  top: calc(100% + 5rem);
}

/* Short desktop viewports (iPad-landscape class, e.g. 1194x750): the hero
   stack — title, byline card, heading group, metric row — outgrows the
   above-fold step by ~15px and the metric labels clip at the fold. Tighten
   the two big vertical seams and step the 3up figures down to ~0.82x of
   their desktop size so the labels' bottom edge rests clear of the fold.
   Lives here (not beside the base hero rules) because the figure sizing
   must follow the 3up block above in source order to win its cascade. */
@media (min-width: 769px) and (max-height: 800px) {
  .scrolly-step[data-step="hero"] {
    gap: var(--space-lg);
  }

  .metrics-heading-group {
    margin-bottom: var(--space-md);
  }

  body.hero-metrics-3up .hero-metrics-floor .metric-value {
    font-size: calc(var(--type-headline) * 0.88 * 0.82);
  }
  /* Same first-child re-assert as the 3up block above — the legacy scan-anchor
     rule would silently bump the $ figure back up otherwise. */
  body.hero-metrics-3up .hero-metrics-floor .metric:first-child .metric-value {
    font-size: calc(var(--type-headline) * 0.88 * 0.82);
  }
}

@media (max-width: 768px) {

  /* Breakpoint signal: main.js derives its mobile-side choreography flags
     (syncModeFlags) from this custom property instead of restating the
     threshold, so CSS and JS can never disagree about which side of the
     hero breakpoint the viewport is on. */
  .hero-scrolly { --hero-mobile: 1; }

  /* 3up phones — 2+1 hierarchy, sign-up rate featured. The conversion figure
     (28%) is promoted to the featured top row — inline beside its two-line
     label so the featured row's width comes close to the pair row's spread —
     and raised + backers become a tight supporting pair divided by a hairline
     keyline beneath. NOTE: the featured metric is nth-child(3) (not
     :first-child) and a SUPPORT metric is :first-child, so every base 2+1
     rule keyed on :first-child / :not(:first-child) is re-targeted explicitly
     by nth-child here — the body-class prefix wins the cascade over those
     base rules. */
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) {
    flex-direction: row; flex-wrap: wrap; align-items: flex-start; justify-content: center;
    /* Row gap: the featured→pair seam opened up so the tiers breathe; the
       column gap is 0 — the pair's padding + keyline below divide it. */
    gap: clamp(1.35rem, 0.9rem + 2.1vw, 1.9rem) 0;
    max-width: min(360px, 80vw); margin-inline: auto;
    /* Grow the pinned figures AWAY from the heading seam: with a center-center
       origin the grid's 1.28x growth lifts its top edge into the
       attribution→grid gap (the "Driven by" squish); anchoring the grid to
       its TOP (and the heading group to its BOTTOM below) makes both tiers
       grow outward from the seam. main.js centers off offsetHeight
       (transform-agnostic), so pin positioning is unaffected. */
    transform-origin: center top;
  }
  /* Heading→grid seam sized for the PINNED peak, not just rest (the grid's
     growth eats into this gap), plus the outward grow anchor. */
  body.hero-metrics-3up .hero-metrics-floor .metrics-heading-group {
    margin-bottom: 0.55rem;
    transform-origin: center bottom;
  }
  /* A little air between KEY RESULTS and the "Driven by" line so the two-line
     heading reads as a titled credit, not a stacked clump, once revealed. */
  body.hero-metrics-3up .hero-metrics-floor .metrics-heading { margin-bottom: 0.5rem; }
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric-value {
    line-height: 1.1;
  }
  /* Row assignment: 28% (DOM 3) featured full-width on top, its number and
     label side by side; raised (DOM 1) then backers (DOM 2) split the row
     beneath as a snug pair — padding replaces column-gap so the keyline sits
     exactly mid-gap, landing on backers, the visually-second item. */
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(3) {
    order: 1; flex: 0 0 100%; min-width: 0;
    display: flex; flex-direction: row; align-items: center; justify-content: center;
    gap: 0.7rem;
  }
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(1) {
    order: 2; flex: 0 1 auto; min-width: 0;
    padding-right: clamp(1rem, 4.5vw, 1.4rem);
  }
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(2) {
    order: 3; flex: 0 1 auto; min-width: 0;
    padding-left: clamp(1rem, 4.5vw, 1.4rem);
    border-left: 1px solid rgba(255, 255, 255, 0.22);
  }
  /* Featured value — the fold's one focal number (~37px at 430w): sized to
     stay clearly heavier than the pair even though "$613,056" carries seven
     glyphs of visual mass against "28%"'s three. */
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(3) .metric-value {
    font-size: clamp(2.05rem, 1.6rem + 2.6vw, 2.6rem); white-space: nowrap;
  }
  /* Supporting pair values — clearly secondary, still legible */
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(1) .metric-value,
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(2) .metric-value {
    font-size: clamp(1.15rem, 0.95rem + 1vw, 1.4rem); white-space: nowrap;
  }
  /* Labels: stacked lead(+sub) (base :not(:first-child) 0.6rem shrink re-targeted) */
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric-label,
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:not(:first-child) .metric-label {
    display: block; margin-top: 0.22rem; font-weight: 600; letter-spacing: 0.4px; line-height: 1.1;
  }
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric-label-lead {
    display: block; white-space: nowrap; font-size: 0.85rem;
  }
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric-label-sub {
    display: block; margin-top: 1px; font-size: 0.72rem; font-weight: 500;
    letter-spacing: 0.2px; line-height: 1.15; white-space: nowrap; color: rgba(255, 255, 255, 0.62);
  }
  /* Featured label rides beside its number: no top margin, ragged-left */
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(3) .metric-label {
    margin-top: 0; text-align: left;
  }
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(3) .metric-label-lead { font-size: 1rem; }
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(3) .metric-label-sub { font-size: 0.78rem; }
  /* Pair labels to a single line: drop the qualifier sub, keeping just the
     lead. The desktop-only lead variant never renders at phone widths. */
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(1) .metric-label-sub,
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(2) .metric-label-sub,
  body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric-label-lead--desktop {
    display: none;
  }
  /* "KEY RESULTS" hidden at rest — the metrics float alone at the top, and the
     heading fades in only as the pinned Key Results phase takes focus (main.js
     rests its opacity floor at 0 here, so the scroll ramp starts from
     transparent). Gated on :root.js-motion so the pre-JS frame doesn't flash
     the heading, and so no-JS / reduced motion — which have no ramp to reveal
     it — keep the base 0.5 resting heading instead. */
  :root.js-motion body.hero-metrics-3up .hero-metrics-floor .metrics-heading {
    color: rgba(255, 255, 255, 0);
  }
  /* Nudge the whole metrics unit up off the browser chrome: the hero step is
     a centered flex column, so bottom margin here spends the remaining fold
     headroom lifting the floor (JS measures rest position at runtime, so the
     drift/pin math follows automatically). */
  body.hero-metrics-3up .hero-metrics-floor { margin-bottom: 0.9rem; }
  /* Support pair rests dimmed (first-paint mirror of the pairOpacity curve in
     main.js, which takes over per frame and ramps them to full during the
     pinned phase). js-motion-gated: no-JS / reduced motion have no ramp, so
     they keep the base 0.85 rest. Labels get their resting alpha from the
     same JS curve, so only the gradient-clipped values need a CSS floor. */
  :root.js-motion body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(1) .metric-value,
  :root.js-motion body.hero-metrics-3up .hero-metrics-floor .metrics-grid:has(.metric:nth-child(3)) .metric:nth-child(2) .metric-value {
    opacity: 0.6;
  }
  /* Reclaim the hidden KEY RESULTS group's reserved space at rest (js-motion
     only — main.js drives these per frame once scrolling and eases them back
     as the heading types in; these CSS values are the rest floor it lands on,
     so the rest→fixed handoff has nothing to jump). The custom props are the
     single source: main.js reads both in syncModeFlags() instead of
     restating them. No-JS / reduced motion rest with the heading VISIBLE,
     so they keep the base group spacing and no negative lift — otherwise
     the grid would ride up over a shown heading. */
  :root.js-motion body.hero-metrics-3up .hero-metrics-floor .metrics-grid {
    --hero-grid-reclaim: 48px;
    margin-top: calc(-1 * var(--hero-grid-reclaim));
  }
  :root.js-motion body.hero-metrics-3up .hero-metrics-floor .metrics-heading-group {
    --hero-seam-rest: 4px;
    margin-bottom: var(--hero-seam-rest);
  }
}

/* === § 25. Hero Metric Layouts: 2-up [body.hero-metrics-2up] === */
/* Two-metric layout (body.hero-metrics-2up): uniform figures that read
   as equal peers — no "one scan anchor" 1.22x bump — set side by side
   with an open column gap. (The hairline-divided pair is retired: with
   lead-only labels the keyline read as leftover chrome, not structure.)
   Self-contained so none of 3up's heading-hide / grid-lift rules leak
   in. Layout is here; the label copy is data-driven (heroMetricLabels). */
body.hero-metrics-2up .hero-metrics-floor .metrics-grid {
  flex-wrap: nowrap;
  /* Both figures share one baseline regardless of glyph count/width. */
  align-items: baseline;
  /* An open gap divides the pair — wide enough to read as two figures,
     tight enough to read as one set (base .metrics-grid ships the larger
     var(--space-2xl), sized for three columns). */
  column-gap: clamp(2rem, 6vw, 3.5rem);
}
/* Uniform figures — override the base 1.22x :first-child anchor so the two
   read as equal peers, not lead + support, stepped to 0.88x so the pair
   matches the 3up band's desktop weight. Targets every value (not just
   :first-child) so the mobile rule below can restate the same selector at
   equal specificity and win by source order at phone widths. */
body.hero-metrics-2up .hero-metrics-floor .metrics-grid .metric .metric-value {
  font-size: calc(var(--type-headline) * 0.88);
}
body.hero-metrics-2up .hero-metrics-floor .metric {
  flex: 0 1 auto;
  min-width: 0;
}
/* Labels: block lead over muted sub (same treatment as the 3up family).
   The sub line is optional per item — heroes that ship one (Logitech
   Reach) get the two-line pair; lead-only items (Screeneo U4) never
   build the span (guard in project.html's label injector). */
body.hero-metrics-2up .hero-metrics-floor .metric-label {
  display: block;
  margin-top: 0.3rem;
  font-weight: 600;
  letter-spacing: 0.4px;
  line-height: 1.15;
}
body.hero-metrics-2up .hero-metrics-floor .metric-label-lead {
  display: block;
  white-space: nowrap;
  font-size: 0.9rem;
}
body.hero-metrics-2up .hero-metrics-floor .metric-label-sub {
  display: block;
  margin-top: 2px;
  font-size: 0.72rem;
  font-weight: 500;
  letter-spacing: 0.3px;
  line-height: 1.2;
  white-space: nowrap;
  color: rgba(255, 255, 255, 0.62);
}
/* Phones: the pair stays side by side, but $6.8M + 9,304 won't fit at the
   base clamp — step the figures down so both hold one row. */
@media (max-width: 768px) {
  body.hero-metrics-2up .hero-metrics-floor .metrics-grid .metric .metric-value {
    font-size: clamp(1.25rem, 0.95rem + 1.4vw, 1.7rem);
    white-space: nowrap;
  }
  body.hero-metrics-2up .hero-metrics-floor .metric-label-lead { font-size: 0.8rem; }
  body.hero-metrics-2up .hero-metrics-floor .metric-label-sub { font-size: 0.65rem; }
}

/* === § 26. Chapter System [.chapter- .xp-] === */
/* The About page tells one braided story in two registers: Experience descends
   2025 -> 2018, the education acts climb 2017 -> 2026, and they meet in Walla
   Walla. Both are built from the SAME primitives below (kicker, title, lede,
   seam, shelf) so the page reads as one system; each section then layers its
   own register on top — education gets photography and full-viewport acts,
   experience gets the typographic treatment in this section (.xp-).
   Two registers, one vocabulary: that contrast is the point. Forking these
   primitives per section is the regression to avoid.

   Placement: last in the file, so consumers must out-specify it to override
   (`.edu-act .chapter-lede` beats `.chapter-lede`). Every section-specific
   rule is therefore scoped to its own ancestor — never bare.

   --chapter-accent is the per-chapter hue. Education overrides it per act
   (amber / periwinkle / teal); experience keeps one accent throughout, which
   is what preserves the acts' per-chapter colour as THEIR signature. */

/* ---- Shared primitives ---- */
.chapter-rule {
  width: 3rem;
  height: 3px;
  border-radius: 2px;
  background: var(--chapter-accent, var(--color-teal));
  margin-bottom: var(--space-md);
}
.chapter-kicker {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin-bottom: var(--space-md);
  font-size: 0.95rem;
  color: var(--color-text-muted);
}
/* Distinct from § 8's .logo-chip (archive cards) — this one is the chapter
   masthead chip: white plate so client/school marks keep their own colours. */
.chapter-logo-chip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  /* Auto width, not a square: employer marks are wide wordmarks (Rainfactory,
     Envoli) and a square plate crushes them to an unreadable smudge, while
     school marks are roughly square. Height is the constant; width follows. */
  width: auto;
  min-width: 34px;
  height: 34px;
  background: #fff;
  border-radius: 6px;
  padding: 3px 6px;
}
.chapter-logo-chip img {
  width: auto;
  height: 100%;
  max-width: 110px;
  object-fit: contain;
  display: block;
}
.chapter-logo-chip--sm {
  min-width: 26px;
  height: 26px;
}
/* No plate: employer wordmarks are drawn for dark backgrounds (Envoli's
   letterforms are literally #fff and disappear on a white chip), and they sit
   next to the flattened brand chips further down the same chapter. Flattening
   to the page ink makes both legible and consistent. School marks keep the
   plate — their colour is part of the identity. */
.chapter-logo-chip--flat {
  background: transparent;
  padding: 0;
  border-radius: 0;
}
.chapter-logo-chip--flat img {
  filter: brightness(0);
  opacity: 0.75;
}
[data-theme="dark"] .chapter-logo-chip--flat img {
  filter: brightness(0) invert(1);
  opacity: 0.8;
}
.chapter-title {
  font-size: clamp(1.6rem, 1.2rem + 1.8vw, 2.4rem);
  margin-bottom: var(--space-md);
  max-width: 24ch;
}
.chapter-lede {
  max-width: 56ch;
  font-size: var(--type-body-lg);
  line-height: 1.75;
  color: var(--color-text-light);
  margin-bottom: var(--space-xl);
}

/* ---- Seam: the beat between chapters ----
   Chapters butted edge to edge read as slides; a named gap ("two years of
   agency work in between") is what makes them read as chapters. The seam is
   also where the two sections cite each other, so it carries an accent from
   whichever chapter it points at. */
.chapter-seam {
  position: relative;
  padding: var(--space-2xl) 0;
  /* Tinted toward the chapter the seam points at (the hairline's accent), so
     seams read as "colored by where you're going" — and stop being visually
     identical to the neutral section-heading strips. */
  background: linear-gradient(180deg,
    var(--color-bg) 0%,
    color-mix(in srgb, var(--chapter-seam-accent, var(--color-border)) 6%, var(--color-bg)) 100%);
}
.chapter-seam-inner {
  max-width: var(--container-prose);
  display: flex;
  align-items: baseline;
  gap: var(--space-lg);
}
.chapter-seam-years {
  flex: none;
  font-size: 0.82rem;
  letter-spacing: 0.06em;
  color: var(--color-text-muted);
  font-variant-numeric: tabular-nums;
}
.chapter-seam-text {
  margin: 0;
  font-size: var(--type-body-lg);
  line-height: 1.7;
  color: var(--color-text-light);
}
.chapter-seam::before {
  content: '';
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  height: 1px;
  background: linear-gradient(90deg,
    var(--chapter-seam-accent, var(--color-border)) 0%,
    transparent 46%);
  transform-origin: left;
}
/* Draw-in is scoped under .js-reveal so the hairline is simply present when
   the reveal script never runs — the decoration degrades, the content doesn't. */
.js-reveal .chapter-seam::before {
  transform: scaleX(0);
  transition: transform 0.9s ease;
}
.js-reveal .chapter-seam.is-visible::before { transform: scaleX(1); }

/* ---- Vertex: the V's turning point ----
   One seam on the page is not a beat but the hinge of the whole structure —
   Walla Walla, where the descending work timeline hands off to the climbing
   education one. It gets what no other seam has: the golden-hour photograph
   (the same place the first education act then delivers full-bleed), a
   full-width amber-to-teal hairline bracketing it top and bottom, and room
   to breathe. Text bed matches §21's left-heavy scrim pattern. */
.chapter-seam--vertex {
  padding: var(--space-3xl) 0;
  background:
    linear-gradient(90deg,
      color-mix(in srgb, var(--color-bg) 94%, transparent) 0%,
      color-mix(in srgb, var(--color-bg) 86%, transparent) 46%,
      color-mix(in srgb, var(--color-bg) 55%, transparent) 74%,
      color-mix(in srgb, var(--color-bg) 30%, transparent) 100%),
    linear-gradient(
      color-mix(in srgb, var(--color-bg) 30%, transparent),
      color-mix(in srgb, var(--color-bg) 30%, transparent)),
    url('/assets/images/education/walla-walla-golden-hour.webp') center 72% / cover no-repeat;
}
/* Full-width amber-to-teal hairline: the handoff in one line. Top edge only —
   the Education threshold directly below draws its own accent bar, and two
   hairlines a few px apart read as a rendering mistake. */
.chapter-seam--vertex::before {
  content: '';
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: 1px;
  background: linear-gradient(90deg, var(--color-accent) 0%, var(--color-teal) 100%);
}
.chapter-seam--vertex .chapter-seam-years {
  color: var(--color-accent);
}
@media (max-width: 768px) {
  .chapter-seam--vertex {
    background:
      linear-gradient(
        color-mix(in srgb, var(--color-bg) 82%, transparent),
        color-mix(in srgb, var(--color-bg) 82%, transparent)),
      url('/assets/images/education/walla-walla-golden-hour.webp') 68% 62% / cover no-repeat;
  }
}

/* ---- Cross-citation: the arms of the V cite each other ----
   A small pill linking a chapter to its counterpart on the other timeline
   (Rainfactory -> the master's; the Whitman act -> the internship chapter).
   Takes the chapter's accent via --chapter-accent. */
.chapter-cross {
  margin: var(--space-lg) 0 0;
}
.chapter-cross-link {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  padding: 0.35rem 0.85rem;
  border: 1px solid color-mix(in srgb, var(--chapter-accent, var(--color-teal)) 40%, transparent);
  border-radius: 999px;
  font-size: 0.88rem;
  font-weight: 500;
  color: var(--chapter-accent, var(--color-teal));
  text-decoration: none;
  transition: border-color var(--transition-fast), background var(--transition-fast);
}
.chapter-cross-link:hover,
.chapter-cross-link:focus-visible {
  border-color: color-mix(in srgb, var(--chapter-accent, var(--color-teal)) 75%, transparent);
  background: color-mix(in srgb, var(--chapter-accent, var(--color-teal)) 12%, transparent);
}

/* ---- Depth shelf: user-paced horizontal scroll (never autoplay) ---- */
.chapter-shelf-label {
  font-size: 0.82rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--chapter-accent, var(--color-text-muted));
  margin-bottom: var(--space-sm);
}
.chapter-shelf {
  display: flex;
  gap: var(--space-md);
  overflow-x: auto;
  scroll-snap-type: x proximity;
  padding: var(--space-xs) 0 var(--space-md);
  scrollbar-width: thin;
}
.chapter-shelf-card {
  flex: 0 0 clamp(240px, 30vw, 320px);
  scroll-snap-align: start;
  background: var(--glass-fill-deep);
  border: 1px solid var(--glass-edge-lite);
  border-radius: var(--radius-lg);
  padding: var(--space-md) var(--space-lg);
}
.chapter-shelf-card h4 {
  font-size: 1rem;
  margin-bottom: var(--space-sm);
  color: var(--chapter-accent, var(--color-primary));
}
.chapter-shelf-card p {
  font-size: 0.92rem;
  line-height: 1.6;
  color: var(--color-text-light);
  margin: 0;
}
.chapter-shelf-tag {
  display: inline-block;
  font-size: 0.7rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--chapter-accent, var(--color-teal));
  margin-bottom: var(--space-sm);
}

/* ---- Proof cards: the chapter's link out to the actual work ----
   Shared, because both registers earn the same move — an experience chapter
   points at the launches it produced, an education act at the coursework that
   became a real project. The default grid is the experience one, tuned for a
   row of three; --compact below is for the shorter rows. */
.chapter-proof {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
  gap: var(--space-md);
  margin: var(--space-xl) 0 0;
  padding: 0;
  list-style: none;
}
.chapter-proof > li { display: flex; }
/* Compact: a row of two stretched across the full container reads as banners
   rather than cards, so a short row caps the column instead of filling it.
   Both education consumers take this — the acts because an act still has to
   fit a screen, the degree detail because it carries the same short row. */
.chapter-proof--compact {
  grid-template-columns: repeat(auto-fill, minmax(200px, 250px));
  margin-top: 0;
}
/* Phone: the cap would leave the row hanging in dead space next to a container
   barely wider than one card, so the columns share the width instead. */
@media (max-width: 600px) {
  .chapter-proof--compact {
    grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
  }
}
.chapter-proof-card {
  /* Column flex so cards in a row share a height regardless of title wrap.
     The card's inner elements are spans (an <a> may not wrap <p> cleanly
     across all parsers), so each one is re-blocked below. */
  display: flex;
  flex-direction: column;
  flex: 1;
  border-radius: var(--radius-lg);
  border: 1px solid var(--glass-edge-lite);
  background: var(--glass-fill-deep);
  overflow: hidden;
  text-decoration: none;
  transition:
    transform var(--transition-fast),
    box-shadow var(--transition-fast),
    border-color var(--transition-fast);
}
.chapter-proof-card:hover {
  transform: translateY(-3px);
  box-shadow: var(--shadow-md);
  border-color: color-mix(in srgb, var(--chapter-accent, var(--color-teal)) 55%, transparent);
}
.chapter-proof-thumb {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 10;
  object-fit: cover;
  background: color-mix(in srgb, var(--color-text) 8%, transparent);
}
.chapter-proof-body {
  display: block;
  padding: var(--space-md);
}
.chapter-proof-title {
  display: block;
  font-size: 0.98rem;
  font-weight: 600;
  color: var(--color-text);
  margin-bottom: var(--space-xs);
}
.chapter-proof-note {
  display: block;
  font-size: 0.85rem;
  line-height: 1.5;
  color: var(--color-text-muted);
}

/* ==========================================================================
   Experience chapters — the typographic register

   Education's texture is PLACES (licensed photography). Experience's texture
   is TYPE, NUMBERS, BRANDS, and PROOF: a ghosted era numeral behind each
   chapter, a timeline rail down the left with a node at each promotion, real
   aggregate figures, client marks, and cards linking to the actual work.
   No photography here — that is what keeps the two sections from competing.
   ========================================================================== */
.xp-section {
  padding: var(--space-lg) 0 var(--space-2xl);
  --chapter-accent: var(--color-teal);
}
.xp-chapter {
  position: relative;
  /* `clip`, not `hidden`: the ghost numeral bleeds past the container and must
     not add horizontal scroll, but `hidden` would create a scroll container
     and break sticky positioning for any ancestor that later needs it. */
  overflow-x: clip;
  padding: var(--space-2xl) 0;
}
.xp-chapter > .container {
  position: relative;
  z-index: 1;
}

/* Era numeral: the descending timeline made visible. Editorial folio scale,
   a few percent above the background — it must never compete with the résumé
   facts sitting on top of it. aria-hidden in markup; it is decoration. */
.xp-ghost-year {
  position: absolute;
  right: 0;
  /* Top-anchored, not centred: the kicker/lede band is the only part of a
     chapter whose right side is reliably empty (the lede caps at 56ch), so
     that is where the numeral can sit at full size without landing on top of
     anything. Centring put it straight through the amber stat figures — two
     sets of numerals in the same space. */
  top: var(--space-2xl);
  z-index: 0;
  font-family: var(--font-heading);
  font-weight: 800;
  font-size: clamp(5rem, 21vw, 17rem);
  line-height: 0.8;
  letter-spacing: -0.045em;
  font-variant-numeric: tabular-nums;
  color: color-mix(in srgb, var(--color-text) 7%, transparent);
  pointer-events: none;
  user-select: none;
}

/* Timeline rail — draws downward on reveal, ticks at each phase. */
.xp-chapter-body {
  position: relative;
  padding-left: var(--space-xl);
}
.xp-chapter-body::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.35rem;
  bottom: 0;
  width: 2px;
  border-radius: 2px;
  background: linear-gradient(180deg,
    var(--chapter-accent) 0%,
    color-mix(in srgb, var(--chapter-accent) 22%, transparent) 72%,
    transparent 100%);
  transform-origin: top;
}
.js-reveal .xp-chapter-body::before {
  transform: scaleY(0);
  transition: transform 0.9s ease 0.1s;
}
.js-reveal .xp-chapter.is-visible .xp-chapter-body::before { transform: scaleY(1); }
/* Node at the chapter head; the bg ring punches it out of the rail. */
.xp-chapter-body > .chapter-kicker { position: relative; }
.xp-chapter-body > .chapter-kicker::before {
  content: '';
  position: absolute;
  left: calc(-1 * var(--space-xl));
  top: 1rem;
  width: 10px;
  height: 10px;
  margin-left: -4px;
  border-radius: 50%;
  background: var(--chapter-accent);
  box-shadow: 0 0 0 4px var(--color-bg);
}

/* Phase: one title/dates block inside a company chapter. Two phases in one
   chapter IS the promotion beat — the tick on the rail is what shows it. */
.xp-phase { position: relative; }
.xp-phase + .xp-phase { margin-top: var(--space-xl); }
.xp-phase::before {
  content: '';
  position: absolute;
  left: calc(-1 * var(--space-xl));
  top: 0.65rem;
  width: 6px;
  height: 6px;
  margin-left: -2px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--chapter-accent) 65%, transparent);
  box-shadow: 0 0 0 3px var(--color-bg);
}
.xp-phase-header {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-md);
  flex-wrap: wrap;
  margin-bottom: var(--space-sm);
}
.xp-phase-title {
  font-size: var(--type-subhead);
  margin: 0;
}
.xp-phase-dates {
  color: var(--color-text-muted);
  font-size: 0.95rem;
  margin: 0;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}
.xp-highlights {
  margin: 0;
  padding-left: 1.2rem;
}
.xp-highlight {
  color: var(--color-text-light);
  line-height: 1.7;
  margin-bottom: var(--space-sm);
}

/* Aggregate figures — derived from projects.json at build time, never
   hand-maintained. Amber per § 1's reserved-accent convention (stat figures). */
.xp-stats {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-lg) var(--space-2xl);
  margin: var(--space-xl) 0 0;
  padding: 0;
  list-style: none;
}
.xp-stat { min-width: 9rem; }
.xp-stat-figure {
  display: block;
  font-family: var(--font-heading);
  font-weight: 700;
  font-size: clamp(1.9rem, 1.2rem + 2.4vw, 3rem);
  line-height: 1;
  color: var(--color-accent);
  font-variant-numeric: tabular-nums;
}
.xp-stat-label {
  display: block;
  margin-top: var(--space-xs);
  font-size: 0.85rem;
  letter-spacing: 0.03em;
  line-height: 1.4;
  color: var(--color-text-muted);
  max-width: 18ch;
}
/* Caveat under the figures. Quieter than the labels and set on its own measure
   so it reads as a footnote to the row, not a fourth stat. */
.xp-stats-note {
  margin: var(--space-md) 0 0;
  max-width: 58ch;
  font-size: 0.85rem;
  line-height: 1.6;
  color: var(--color-text-muted);
}

/* Client marks — the same seven brands the home page's wall shows, in the same
   one ink. Deliberately NOT plated: a per-brand plate makes each mark a
   separate object and the row stops reading as one row, and any tint that
   inverts a mark's own luminance lands at ~1:1 contrast for mid-tone marks.
   Recognition is the whole effect, so silhouette carries it and spacing does
   the containing. Sized down from the home wall (30px) because these sit
   inside a chapter's prose column, not a full-width section. */
.xp-brands {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-lg) var(--space-xl);
  margin: var(--space-xl) 0 0;
  padding: 0;
  list-style: none;
}
.xp-brand-mark img {
  /* --mark-scale (authored per brand, see brand-wall.html) balances optical
     weight. No max-width: a cap silently shrinks the widest marks instead,
     which is the same arbitrary sizing the scale exists to replace. */
  height: calc(22px * var(--mark-scale, 1));
  width: auto;
  display: block;
  filter: brightness(0);
  opacity: 0.55;
}
[data-theme="dark"] .xp-brand-mark img {
  filter: brightness(0) invert(1);
  /* Higher than the home wall's 0.6: without a plate behind them these are the
     only marks on the page carrying themselves, and 0.6 read as washed out. */
  opacity: 0.78;
}

/* Proof cards — the thing a résumé can't do: the claim, then the work.
   Slugs resolve against projects.json at build, so these can never drift. */
/* Deep links into the filtered archive (filters.js reads these params). */
.xp-evidence {
  margin: var(--space-lg) 0 0;
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-md) var(--space-xl);
}
.xp-evidence-link {
  font-size: 0.95rem;
  font-weight: 600;
  color: var(--chapter-accent);
  text-decoration: none;
  border-bottom: 1px solid currentColor;
  padding-bottom: 2px;
  transition: color var(--transition-fast);
}
.xp-evidence-link:hover { color: var(--color-accent); }

/* The numeral needs a genuinely empty right column, and it only exists once
   the viewport clears the 56ch lede plus the numeral's own width — measured,
   that is ~1100px. Below it the numeral can only land on text, and a watermark
   under body copy costs more legibility than the effect is worth. Desktop-only
   decoration; the rail and the kicker still carry the chronology everywhere. */
@media (max-width: 1099px) {
  .xp-ghost-year { display: none; }
}

@media (max-width: 768px) {
  .xp-chapter { padding: var(--space-xl) 0; }
  .xp-chapter-body { padding-left: var(--space-lg); }
  .xp-chapter-body > .chapter-kicker::before,
  .xp-phase::before { left: calc(-1 * var(--space-lg)); }
  .xp-stats { gap: var(--space-lg) var(--space-xl); }
  .chapter-seam { padding: var(--space-2xl) 0; }
  .chapter-seam-inner { display: block; }
  .chapter-seam-years { margin-bottom: var(--space-xs); }
  .chapter-shelf-card { flex-basis: min(78vw, 300px); }
}

@media (prefers-reduced-motion: reduce) {
  /* Rails and hairlines arrive drawn; nothing sweeps. */
  .js-reveal .xp-chapter-body::before,
  .js-reveal .chapter-seam::before {
    transform: none;
    transition: none;
  }
  .xp-proof-card { transition: none; }
  .xp-proof-card:hover { transform: none; }
}

/* === § 27. Document Reader [.doc-reader .doc-sheet .doc-zoom] === */
/* A print piece shown as pages, not reflowed. The band goes darker than the
   page (--color-bg-white is #242442 here) so each white sheet reads as paper.

   The whole design turns on one measurement: body copy in these pieces sits
   around 11pt on an 11in sheet, so rendered size is 11 x (sheet px / 792).
   At the container width that is ~16px and simply readable; at phone width it
   is ~6px, which is why touch gets the zoom layer below rather than a
   "fits on screen" view that would be readable at no size at all. */
.doc-reader {
  background: #05070f;
  background-image: radial-gradient(120% 80% at 50% 0%, #16203a 0%, #0a0e1c 45%, #05070f 78%);
  padding: clamp(40px, 7vw, 88px) 0 clamp(48px, 8vw, 104px);
  color: #fff;
}

.doc-reader__head {
  max-width: 1240px;
  margin: 0 auto clamp(28px, 4vw, 48px);
  padding: 0 clamp(14px, 4vw, 32px);
}

.doc-reader__eyebrow {
  font-family: var(--font-heading);
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--color-accent);
  margin-bottom: var(--space-sm);
}

.doc-reader__title {
  font-family: var(--font-heading);
  font-weight: 600;
  font-size: clamp(1.5rem, 4vw, 2.15rem);
  line-height: 1.15;
  color: #fff;
  margin-bottom: var(--space-sm);
}

.doc-reader__meta {
  margin: 0;
  color: rgba(255, 255, 255, 0.62);
  font-size: 0.95rem;
}
.doc-reader__meta span + span::before {
  content: '·';
  margin: 0 0.55em;
  opacity: 0.55;
}

/* --- the page stack --- */
.doc-reader__sheets {
  max-width: 1240px;
  margin: 0 auto;
  padding: 0 clamp(14px, 4vw, 32px);
  display: flex;
  flex-direction: column;
  gap: clamp(22px, 4vw, 56px);
}

.doc-sheet { margin: 0; }

/* The hint anchors to the sheet image, not the figure — the caption below
   would otherwise push it off the page edge. */
.doc-sheet__frame { position: relative; }

/* Only the coarse-pointer build is interactive (document-reader.js sets
   .is-interactive); desktop reads the sheet in place, so there is nothing
   to open and nothing to advertise. */
.doc-reader.is-interactive .doc-sheet { cursor: zoom-in; }

.doc-sheet__page {
  display: block;
  width: 100%;
  height: auto;
  background: #fff;
  border-radius: 3px;
  box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), 0 18px 44px rgba(0, 0, 0, 0.42);
}

.doc-sheet__caption {
  margin-top: 0.85rem;
  font-family: var(--font-heading);
  font-size: 0.74rem;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: rgba(255, 255, 255, 0.42);
}

.doc-sheet__hint {
  position: absolute;
  right: 10px;
  bottom: 10px;
  display: none;
  align-items: center;
  font-family: var(--font-heading);
  font-size: 0.7rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: #fff;
  background: rgba(5, 7, 15, 0.72);
  -webkit-backdrop-filter: blur(6px);
  backdrop-filter: blur(6px);
  padding: 0.45em 0.7em;
  border-radius: 999px;
  pointer-events: none;
}
.doc-reader.is-interactive .doc-sheet__hint { display: inline-flex; }

/* --- zoom layer (touch) --- */
.doc-zoom[hidden] { display: none; }

.doc-zoom {
  position: fixed;
  inset: 0;
  z-index: 90;
  background: #05070f;
  overflow: auto;
  -webkit-overflow-scrolling: touch;
  overscroll-behavior: contain;
  touch-action: pan-x pan-y pinch-zoom;
}

/* Portrait can only reach readable type by overflowing both axes, which costs
   a sideways pan per line. Landscape reaches ~13px at full width and pans on
   one axis, so rotating is worth prompting — but only for THIS, not for a
   whole-page fit: a landscape sheet inside a ~440px-tall phone would render
   body copy at ~7px, worse than portrait. */
.doc-zoom img {
  display: block;
  width: 250vw;
  max-width: none;
  height: auto;
  margin: env(safe-area-inset-top) 0 40vh;
}
@media (orientation: landscape) and (max-height: 560px) {
  .doc-zoom img { width: 100vw; }
}

.doc-zoom__bar {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: calc(env(safe-area-inset-top) + 8px) 12px 8px;
  background: linear-gradient(to bottom, rgba(5, 7, 15, 0.92), rgba(5, 7, 15, 0));
  color: #fff;
  font-family: var(--font-heading);
  font-size: 0.8rem;
  font-weight: 600;
  pointer-events: none;
}
.doc-zoom__bar > * { pointer-events: auto; }

.doc-zoom__close,
.doc-zoom__step {
  border: 0;
  border-radius: 999px;
  color: #fff;
  background: rgba(255, 255, 255, 0.16);
  -webkit-backdrop-filter: blur(8px);
  backdrop-filter: blur(8px);
  font: inherit;
  padding: 0.5em 0.9em;
  cursor: pointer;
}
.doc-zoom__close { background: rgba(255, 255, 255, 0.22); }
.doc-zoom__step[disabled] { opacity: 0.3; }

.doc-zoom__nav {
  display: flex;
  gap: 8px;
  align-items: center;
}
.doc-zoom__count {
  opacity: 0.7;
  font-variant-numeric: tabular-nums;
}

.doc-zoom__hint {
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  color: rgba(255, 255, 255, 0.72);
}
@media (orientation: landscape) { .doc-zoom__hint { display: none; } }

body.doc-zoom-open { overflow: hidden; }

/* === § 28. Transcript [.tr-doc .tr-page .tr-] === */
/* Text version of a document-reader piece, on its own noindex page. Reading
   type, not display type: this exists so a screen reader (or anyone who
   prefers text) gets what the page images hold, so measure and rhythm are
   the whole design. Deliberately plain — no scroll effects, no reveal. */
.tr-doc {
  max-width: 68ch;
  margin: 0 auto;
  padding: var(--space-3xl) var(--space-lg) var(--space-3xl);
}
.tr-doc__head {
  padding-bottom: var(--space-xl);
  margin-bottom: var(--space-xl);
  border-bottom: 1px solid var(--color-border);
}
.tr-doc__eyebrow {
  margin: 0 0 var(--space-md);
  font-size: 0.95rem;
}
.tr-doc__eyebrow a { color: var(--color-text-muted); }
.tr-doc__eyebrow a:hover { color: var(--color-teal); }
.tr-doc__title {
  font-size: clamp(1.8rem, 1.3rem + 2vw, 2.6rem);
  margin: 0 0 var(--space-xs);
}
.tr-doc__sub {
  margin: 0 0 var(--space-md);
  color: var(--color-text-muted);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-size: 0.8rem;
}
.tr-doc__meta {
  margin: 0 0 var(--space-md);
  color: var(--color-text-muted);
  font-size: 0.9rem;
}
.tr-doc__meta span + span::before { content: " · "; }
/* The note explains provenance and attribution before any of the text — it is
   read first on purpose, so it is set as a quiet standoff rather than a box. */
.tr-doc__note {
  margin: 0;
  padding-left: var(--space-md);
  border-left: 2px solid var(--color-border);
  color: var(--color-text-muted);
  font-size: 0.92rem;
  line-height: 1.7;
}
.tr-page { margin-bottom: var(--space-3xl); }
.tr-page__label {
  font-size: 1.05rem;
  text-transform: uppercase;
  letter-spacing: 0.1em;
  color: var(--color-text-muted);
  margin-bottom: var(--space-lg);
  padding-bottom: var(--space-xs);
  border-bottom: 1px solid var(--color-border);
}
.tr-page__num {
  display: block;
  font-size: 0.75rem;
  letter-spacing: 0.14em;
  color: var(--color-teal);
  margin-bottom: 0.2rem;
}
.tr-page p {
  max-width: none;
  margin: 0 0 var(--space-md);
  line-height: 1.8;
}
/* Print-piece display lines (mastheads, section titles) keep their hierarchy
   without becoming headings — they are not navigation, just the page's type. */
.tr-display {
  font-family: var(--font-display, inherit);
  font-size: 1.5rem;
  line-height: 1.3;
  color: var(--color-text-light);
}
.tr-kicker {
  color: var(--color-text-muted);
  letter-spacing: 0.04em;
}
.tr-byline { color: var(--color-text-muted); font-style: italic; }
/* Editorial descriptions of things the transcript cannot carry — a photo
   mosaic, a paw-print layout. Marked as the transcriber's voice, not the
   newsletter's, so the reader always knows which is which. */
.tr-note,
.tr-caption {
  color: var(--color-text-muted);
  font-size: 0.92rem;
  line-height: 1.7;
  padding-left: var(--space-md);
  border-left: 2px solid var(--color-border);
}
.tr-doc__foot {
  padding-top: var(--space-xl);
  border-top: 1px solid var(--color-border);
}
.tr-doc__back { font-weight: 600; }
.doc-reader__transcript { margin: var(--space-md) 0 0; font-size: 0.95rem; }
.doc-reader__transcript a {
  color: var(--color-teal);
  text-decoration: underline;
  text-underline-offset: 3px;
}

/* === § 29. Campaign Highlights [.chl-] === */
/* Alternating (zig-zag) rows of shipped-page blocks — the exhibit for pages
   where the full walkthrough is the wrong one (several hands touched the
   copy; only the curated blocks should show). Static by design: no deck, no
   autoplay, nothing to advance — the reader scrolls, the same contract as
   the rest of the site. Each row pairs one block capture with its label and
   caption; rows alternate sides on desktop and stack image-first on small
   screens. */
.chl {
  padding: var(--space-3xl) 0;
  /* Anchor/HUD jumps land the heading clear of the sticky chrome (§ 4). */
  scroll-margin-top: calc(var(--header-height) + var(--hud-height));
}
.chl-heading {
  max-width: 720px; margin: 0 auto var(--space-2xl);
  text-align: center;
}
.chl-title { margin-top: var(--space-xs); }
.chl-sub {
  font-family: var(--font-heading);
  font-size: clamp(1.05rem, 1rem + 0.5vw, 1.3rem);
  font-weight: 500;
  color: var(--color-text-light);
  margin: var(--space-sm) 0 0;
}

.chl-rows {
  display: grid; gap: var(--space-2xl);
  max-width: 1000px; margin: 0 auto;
}
.chl-row {
  display: grid;
  grid-template-columns: minmax(0, 6fr) minmax(0, 5fr);
  gap: clamp(28px, 4.5vw, 64px);
  align-items: center;
  margin: 0;
}
/* Every other row swaps sides. Explicit grid placement rather than a
   direction flip, so tab/reading order never diverges from source order. */
.chl-row:nth-child(even) .chl-row-media { grid-column: 2; grid-row: 1; }
.chl-row:nth-child(even) .chl-row-copy { grid-column: 1; grid-row: 1; }
.chl-row-media {
  background: #10101e;
  border: 1px solid var(--color-border-dark);
  border-radius: 12px;
  overflow: hidden;
}
.chl-row-media img { display: block; width: 100%; height: auto; }
.chl-row-copy { min-width: 0; }
.chl-row-label {
  font-family: var(--font-heading);
  font-size: 0.78rem; font-weight: 700;
  letter-spacing: 0.12em; text-transform: uppercase;
  color: var(--color-accent);
  margin: 0 0 10px;
}
.chl-row-text {
  font-size: 0.98rem; line-height: 1.6;
  color: var(--color-text-light);
  margin: 0;
}

.chl-note {
  max-width: 620px; margin: var(--space-lg) auto 0;
  text-align: center;
  font-size: 0.88rem; color: var(--color-text-muted);
}

@media (max-width: 820px) {
  .chl-rows { gap: var(--space-xl); max-width: 560px; }
  .chl-row { grid-template-columns: 1fr; gap: 16px; }
  /* Single column: source order rules — image above its copy, every row. */
  .chl-row:nth-child(even) .chl-row-media,
  .chl-row:nth-child(even) .chl-row-copy { grid-column: auto; grid-row: auto; }
  .chl-row-text { font-size: 0.94rem; }
}

/* === § 30. Article Reader [.article-shelf .ashelf- .ar-] === */
/* Editorial engagement pages: a shelf of posts written for a client's own
   site (project page section), each opening a reader page that reproduces
   the post as it ran — real text inside a browser frame styled after the
   host site (first consumer: the 2020 rainfactory.com blog). The shelf
   speaks the site's dark language; the reader page inside the frame speaks
   the HOST site's (white page, its brand teal, its Lato, its orange links).
   No JS on either surface. */

/* --- The shelf (project page) --- */
.article-shelf { padding: var(--space-3xl) 0; }
.ashelf-head {
  max-width: 720px;
  margin: 0 auto var(--space-xl);
  text-align: center;
}
.ashelf-title {
  font-size: clamp(1.6rem, 1.2rem + 1.6vw, 2.2rem);
  margin: 0 0 var(--space-md);
}
.ashelf-note {
  margin: 0 auto;
  max-width: 620px;
  text-align: left;
  padding-left: var(--space-md);
  border-left: 2px solid var(--color-border);
  color: var(--color-text-muted);
  font-size: 0.92rem;
  line-height: 1.7;
}
.ashelf-list {
  max-width: 860px;
  margin: 0 auto;
  display: grid;
  gap: var(--space-lg);
}
.ashelf-card {
  display: grid;
  grid-template-columns: 264px 1fr;
  gap: var(--space-lg);
  align-items: center;
  padding: var(--space-md);
  background: rgba(255, 255, 255, 0.03);
  border: 1px solid var(--color-border);
  border-radius: 14px;
  text-decoration: none;
  transition: transform 0.25s ease, border-color 0.25s ease, box-shadow 0.25s ease;
}
.ashelf-card:hover,
.ashelf-card:focus-visible {
  transform: translateY(-3px);
  border-color: var(--color-teal);
  box-shadow: 0 14px 34px rgba(0, 0, 0, 0.35);
}
.ashelf-thumb img {
  display: block;
  width: 100%;
  height: auto;
  border-radius: 8px;
  border: 1px solid var(--color-border);
}
.ashelf-body { min-width: 0; }
.ashelf-card-title {
  display: block;
  font-family: var(--font-heading);
  font-size: 1.12rem;
  font-weight: 700;
  line-height: 1.4;
  color: var(--color-text-light);
  margin-bottom: 8px;
}
.ashelf-card-meta {
  display: block;
  font-size: 0.85rem;
  color: var(--color-text-muted);
  margin-bottom: 12px;
}
.ashelf-cta {
  display: inline-block;
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--color-teal);
}
.ashelf-card:hover .ashelf-cta { text-decoration: underline; text-underline-offset: 3px; }

/* --- The reader page (site chrome outside, host-site page inside) --- */
.ar-shell {
  max-width: 960px;
  margin: 0 auto;
  padding: var(--space-2xl) var(--space-lg) var(--space-3xl);
}
.ar-head { margin-bottom: var(--space-lg); }
.ar-head__eyebrow { margin: 0 0 var(--space-xs); font-size: 0.95rem; }
.ar-head__eyebrow a { color: var(--color-text-muted); }
.ar-head__eyebrow a:hover { color: var(--color-teal); }
.ar-head__provenance {
  margin: 0;
  font-size: 0.8rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--color-text-muted);
}

/* Browser chrome: neutral dots (a window, not a specific OS) + the post's
   published URL. The frame is the provenance claim — treat it as chrome,
   never as content. */
.ar-frame {
  border: 1px solid var(--color-border);
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
}
.ar-frame__bar {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 16px;
  background: rgba(255, 255, 255, 0.05);
  border-bottom: 1px solid var(--color-border);
}
.ar-frame__dots { display: inline-flex; gap: 6px; flex: none; }
.ar-frame__dots i {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.18);
}
.ar-frame__url {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 0.8rem;
  color: var(--color-text-muted);
  letter-spacing: 0.02em;
}

/* Inside the frame: the host site's design language, deliberately NOT the
   portfolio's tokens. Values come from rainfactory.com's recovered 2020
   Divi customizer CSS (teal #16708a masthead, Lato, #f2841c links). */
.ar-page { background: #fff; }
.ar-masthead { background: #16708a; padding: 16px 28px; }
.ar-masthead img { display: block; width: auto; height: 42px; }
.ar-article {
  padding: 44px clamp(24px, 7vw, 88px) 60px;
  font-family: 'Lato', 'Source Sans 3', -apple-system, 'Helvetica Neue', Arial, sans-serif;
  font-size: 1.0625rem;
  line-height: 1.75;
  color: #4a4a4a;
}
.ar-article__title {
  font-size: clamp(1.5rem, 1.1rem + 1.8vw, 2.1rem);
  line-height: 1.25;
  color: #131415;
  margin: 0 0 12px;
}
.ar-article__meta {
  margin: 0 0 24px;
  font-size: 0.85rem;
  color: #6d6d6d;
}
.ar-article__meta span + span::before { content: " | "; color: #c9c9c9; }
.ar-article__cat { color: #f2841c; }
.ar-article__featured { margin: 0 0 28px; }
.ar-article__featured img,
.ar-article__body img { max-width: 100%; height: auto; }
.ar-article__body p { margin: 0 0 1.4em; max-width: none; }
.ar-article__inline-img { text-align: center; }
.ar-article__inline-img img,
.ar-article__inline-img video { display: inline-block; max-width: 100%; height: auto; }
.ar-article__body a { color: #f2841c; text-decoration: none; }
.ar-article__body a:hover { color: #fa6400; text-decoration: underline; text-underline-offset: 2px; }
.ar-article__body h2,
.ar-article__body h3 {
  color: #131415;
  line-height: 1.3;
  margin: 1.6em 0 0.6em;
}
.ar-article__body h2 { font-size: 1.4rem; }
.ar-article__body h3 { font-size: 1.15rem; }

.ar-foot { padding-top: var(--space-xl); }
.ar-foot__note {
  margin: 0 0 var(--space-lg);
  padding-left: var(--space-md);
  border-left: 2px solid var(--color-border);
  color: var(--color-text-muted);
  font-size: 0.92rem;
  line-height: 1.7;
}
.ar-foot__links { margin: 0; display: flex; gap: var(--space-lg); flex-wrap: wrap; }
.ar-foot__links a { font-weight: 600; }

@media (max-width: 700px) {
  .ashelf-card { grid-template-columns: 1fr; gap: var(--space-md); }
  .ar-shell { padding: var(--space-xl) var(--space-md) var(--space-2xl); }
  .ar-masthead { padding: 12px 18px; }
  .ar-masthead img { height: 32px; }
  .ar-article { padding: 28px 18px 44px; font-size: 1rem; }
}
